Hosting-Produkte: Staffelpreise nach Laufzeit (1/3/6/12/24 Monate)
This commit is contained in:
parent
e0fd57532d
commit
5efe37ecc0
12 changed files with 145 additions and 28 deletions
|
|
@ -15,11 +15,16 @@ import { canInOrg } from '../../core/policy.js';
|
|||
import type { KcModule } from '../../core/module.js';
|
||||
|
||||
const int = (max: number) => z.number().int().min(0).max(max);
|
||||
const termPriceSchema = z.array(z.object({ termMonths: z.number().int().min(1).max(120), recurringCents: int(100_000_00) })).max(12)
|
||||
.refine((l) => new Set(l.map((t) => t.termMonths)).size === l.length, 'Jede Laufzeit darf nur einmal vorkommen');
|
||||
const versionSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200), description: z.string().trim().max(2000).optional(), taxRateId: z.string().uuid(), priceBasis: z.enum(['net', 'gross']).default('net'),
|
||||
setupCents: int(100_000_00).default(0), recurringCents: int(100_000_00).default(0), billingInterval: z.enum(['once', 'monthly', 'yearly']),
|
||||
termMonths: int(120).default(0), renewal: z.enum(['auto', 'none']).default('none'), renewalTermMonths: int(120).optional(), noticeDays: int(365).default(30),
|
||||
provisioning: z.record(z.string(), z.unknown()).default({}),
|
||||
// Staffelpreise nach Laufzeit (optional): z. B. 1/3/6/12/24 Monate mit je eigenem Gesamtpreis für die Laufzeit.
|
||||
// Wählt ein Kunde/Personal bei der Bestellung eine Laufzeit daraus, gilt deren Preis statt recurringCents/billingInterval.
|
||||
termPrices: termPriceSchema.default([]),
|
||||
});
|
||||
type VersionIn = z.infer<typeof versionSchema>;
|
||||
import { CUSTOMER_ACTIONS } from '../../core/actions.js';
|
||||
|
|
@ -63,9 +68,11 @@ async function insertVersion(c: Parameters<typeof run>[2], productId: string, v:
|
|||
`INSERT INTO product_versions (id, product_id, version, name, description, tax_rate_id, tax_bp, price_basis, setup_cents, recurring_cents, billing_interval, term_months, renewal, renewal_term_months, notice_days, provisioning_json, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
[id, productId, Number(last!.n) + 1, v.name, v.description ?? null, v.taxRateId, tax.rate_bp, v.priceBasis, v.setupCents, v.recurringCents, v.billingInterval, v.termMonths, v.renewal, renewalMonths, v.noticeDays, JSON.stringify(v.provisioning), by], c);
|
||||
for (const t of v.termPrices) await run('INSERT INTO product_term_prices (id, product_version_id, term_months, recurring_cents) VALUES (?,?,?,?)', [randomUUID(), id, t.termMonths, t.recurringCents], c);
|
||||
await run('UPDATE products SET current_version_id = ? WHERE id = ?', [id, productId], c);
|
||||
return id;
|
||||
}
|
||||
const termPricesFor = (versionId: string | null) => versionId ? query('SELECT term_months, recurring_cents FROM product_term_prices WHERE product_version_id = ? ORDER BY term_months', [versionId]) : Promise.resolve([]);
|
||||
interface NewProduct { sku: string; category: string; connectorInstanceId?: string | null; externalRef?: string; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; status: 'draft' | 'active'; version: VersionIn }
|
||||
/** Legt ein Produkt mit erster Version an (Regeln, Verbindungsprüfung, Eindeutigkeit der Artikelnummer). */
|
||||
async function createProduct(b: NewProduct, actorId: string): Promise<string> {
|
||||
|
|
@ -124,7 +131,8 @@ export const catalogModule: KcModule = {
|
|||
});
|
||||
app.get('/admin/products', async (req) => {
|
||||
requirePermission(req, 'products.read');
|
||||
return (await query(`${productSelect} ORDER BY p.created_at DESC`)).map(productView);
|
||||
const rows = await query(`${productSelect} ORDER BY p.created_at DESC`);
|
||||
return Promise.all(rows.map(async (r) => ({ ...productView(r), termPrices: (await termPricesFor(r.vid ?? null)).map((t: any) => ({ termMonths: t.term_months, recurringCents: t.recurring_cents })) })));
|
||||
});
|
||||
app.get('/admin/products/:id', async (req) => {
|
||||
requirePermission(req, 'products.read');
|
||||
|
|
@ -132,7 +140,8 @@ export const catalogModule: KcModule = {
|
|||
const r = await one(`${productSelect} WHERE p.id = ?`, [id]);
|
||||
if (!r) throw notFound();
|
||||
const versions = await query('SELECT * FROM product_versions WHERE product_id = ? ORDER BY version DESC', [id]);
|
||||
return { ...productView(r), versions: versions.map(versionView) };
|
||||
const termPrices = (await termPricesFor(r.vid ?? null)).map((t: any) => ({ termMonths: t.term_months, recurringCents: t.recurring_cents }));
|
||||
return { ...productView(r), termPrices, versions: versions.map(versionView) };
|
||||
});
|
||||
app.post('/admin/products', async (req) => {
|
||||
const a = requirePermission(req, 'products.write');
|
||||
|
|
@ -217,7 +226,7 @@ export const catalogModule: KcModule = {
|
|||
const provisioning = inst.connector_key === 'licensing' ? { programId: Number(p.provisioning.programId ?? b.programRef), ...p.provisioning } : p.provisioning;
|
||||
if (inst.connector_key === 'licensing') provisioning.programId = Number(b.programRef);
|
||||
const id = await createProduct({ sku: p.sku, category: p.category, connectorInstanceId: b.connectorInstanceId, externalRef: b.programRef, orderableByCustomer: p.orderableByCustomer, requiresApproval: p.requiresApproval, customerActions: p.customerActions, status: 'draft',
|
||||
version: { name: p.name, description: p.description, taxRateId: t.id, priceBasis: p.priceBasis, setupCents: p.setupCents, recurringCents: p.recurringCents, billingInterval: p.interval, termMonths: p.termMonths, renewal: p.renewal, renewalTermMonths: p.renewalTermMonths || undefined, noticeDays: p.noticeDays, provisioning } }, a.user.id);
|
||||
version: { name: p.name, description: p.description, taxRateId: t.id, priceBasis: p.priceBasis, setupCents: p.setupCents, recurringCents: p.recurringCents, billingInterval: p.interval, termMonths: p.termMonths, renewal: p.renewal, renewalTermMonths: p.renewalTermMonths || undefined, noticeDays: p.noticeDays, provisioning, termPrices: [] } }, a.user.id);
|
||||
created.push({ sku, id });
|
||||
} catch (e) { skipped.push({ sku, reason: e instanceof AppError ? e.message : 'Fehler beim Anlegen' }); }
|
||||
}
|
||||
|
|
@ -264,10 +273,15 @@ export const catalogModule: KcModule = {
|
|||
if (!canInOrg(a.principal, q.org, 'orders.read', 'products.read')) throw notFound();
|
||||
const org = await one('SELECT customer_type FROM organizations WHERE id = ?', [q.org]);
|
||||
const rows = await query(`${productSelect} WHERE p.status = 'active' AND p.orderable_by_customer = 1 ORDER BY v.name`);
|
||||
return rows.map((r) => {
|
||||
return Promise.all(rows.map(async (r) => {
|
||||
const price = calculatePrice({ basis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 });
|
||||
return { id: r.id, sku: r.sku, category: r.category, name: r.vname, description: r.description, requiresApproval: !!r.requires_approval, customerType: org?.customer_type, price, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days };
|
||||
});
|
||||
// Staffelpreise nach Laufzeit (optional): je gewählter Laufzeit ein eigener Gesamtpreis statt des Basispreises.
|
||||
const tiers = (await termPricesFor(r.vid ?? null)).map((t: any) => ({
|
||||
termMonths: t.term_months,
|
||||
price: calculatePrice({ basis: r.price_basis, setupCents: 0, recurringCents: t.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 }).recurring,
|
||||
}));
|
||||
return { id: r.id, sku: r.sku, category: r.category, name: r.vname, description: r.description, requiresApproval: !!r.requires_approval, customerType: org?.customer_type, price, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days, termPrices: tiers };
|
||||
}));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue