Hosting-Produkte: Staffelpreise nach Laufzeit (1/3/6/12/24 Monate)

This commit is contained in:
Kundencenter 2026-09-27 18:35:34 +02:00
parent e0fd57532d
commit 5efe37ecc0
12 changed files with 145 additions and 28 deletions

View file

@ -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 };
}));
});
},
};

View file

@ -147,13 +147,20 @@ export const invoicesModule: KcModule = {
/** Vorschlag aus dem aktuellen Katalogpreis eines Produkts (Einrichtung und/oder wiederkehrender Preis als eigene Positionen). */
app.get('/invoices/suggest-from-product/:productId', async (req) => {
const a = requirePermission(req, 'invoices.write'); const { productId } = z.object({ productId: z.string().uuid() }).parse(req.params);
const p = await one('SELECT p.*, v.name, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.billing_interval FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?', [productId]);
const q = z.object({ termMonths: z.coerce.number().int().min(1).max(120).optional() }).parse(req.query);
const p = await one('SELECT p.*, v.id AS vid, v.name, v.tax_bp, v.price_basis, v.setup_cents, v.recurring_cents, v.billing_interval FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.id = ?', [productId]);
if (!p) throw notFound();
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents: p.recurring_cents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: 1, discountBp: 0 });
let recurringCents = p.recurring_cents; let label = p.name;
if (q.termMonths !== undefined) {
const tier = await one('SELECT recurring_cents FROM product_term_prices WHERE product_version_id = ? AND term_months = ?', [p.vid, q.termMonths]);
if (!tier) throw badRequest('Diese Laufzeit wird für dieses Produkt nicht angeboten', 'TERM_NOT_AVAILABLE');
recurringCents = tier.recurring_cents; label = `${p.name} (${q.termMonths} Monate)`;
}
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: 1, discountBp: 0 });
const items = [];
if (price.setup.net > 0) items.push({ description: `Einrichtung: ${p.name}`, quantity: 1, unitPriceNetCents: price.setup.net, taxBp: p.tax_bp });
if (price.recurring.net > 0) items.push({ description: p.name, quantity: 1, unitPriceNetCents: price.recurring.net, taxBp: p.tax_bp });
if (items.length === 0) items.push({ description: p.name, quantity: 1, unitPriceNetCents: 0, taxBp: p.tax_bp });
if (price.recurring.net > 0) items.push({ description: label, quantity: 1, unitPriceNetCents: price.recurring.net, taxBp: p.tax_bp });
if (items.length === 0) items.push({ description: label, quantity: 1, unitPriceNetCents: 0, taxBp: p.tax_bp });
return { items };
});
/** Vorschlag aus der Domain-Aufstellung: der dort errechnete Verkaufspreis (nie der Einkauf), plus Einrichtung falls vorhanden. */

View file

@ -59,7 +59,7 @@ export const ordersModule: KcModule = {
// ---- Bestellung anlegen ------------------------------------------------
app.post('/orders', async (req) => {
const a = requireAuth(req);
const b = z.object({ orgId: z.string().uuid(), note: z.string().trim().max(500).optional(), items: z.array(z.object({ productId: z.string().uuid(), quantity: z.number().int().min(1).max(100).default(1), discountBp: z.number().int().min(0).max(10000).default(0) })).min(1).max(20) }).parse(req.body);
const b = z.object({ orgId: z.string().uuid(), note: z.string().trim().max(500).optional(), items: z.array(z.object({ productId: z.string().uuid(), quantity: z.number().int().min(1).max(100).default(1), discountBp: z.number().int().min(0).max(10000).default(0), termMonths: z.number().int().min(1).max(120).optional() })).min(1).max(20) }).parse(req.body);
const staff = can(a.principal, 'orders.write');
if (!canInOrg(a.principal, b.orgId, 'orders.create', 'orders.write')) { if (!canInOrg(a.principal, b.orgId, 'orders.read', 'orders.read')) throw notFound(); throw forbidden(); }
const org = await one('SELECT id, status, customer_type FROM organizations WHERE id = ?', [b.orgId]);
@ -74,9 +74,17 @@ export const ordersModule: KcModule = {
if (!staff && !p.orderable_by_customer) throw badRequest('Dieses Produkt kann nicht selbst bestellt werden', 'NOT_ORDERABLE');
if (!staff && it.discountBp > 0) throw forbidden('Rabatte können nur vom Personal gewährt werden', 'DISCOUNT_FORBIDDEN');
if (p.connector_instance_id && it.quantity !== 1) throw badRequest('Provisionierte Produkte können nur einzeln bestellt werden', 'QUANTITY_NOT_ALLOWED');
// Preis IMMER serverseitig aus der aktuellen Produktversion; Eingaben des Clients beeinflussen den Preis nicht
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents: p.recurring_cents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: it.quantity, discountBp: it.discountBp });
let terms = { termMonths: p.term_months as number, renewal: p.renewal as 'auto' | 'none', renewalTermMonths: p.renewal_term_months as number, noticeDays: p.notice_days as number };
// Staffelpreis nach Laufzeit (optional): ersetzt den wiederkehrenden Basispreis, wenn eine passende Laufzeit gewählt wurde
// (das Abrechnungsintervall bleibt für die Anzeige erhalten, z. B. "jährlich"; die tatsächliche Laufzeit steht separat in terms.termMonths).
let recurringCents = p.recurring_cents, termMonths = p.term_months as number, renewalTermMonths = p.renewal_term_months as number;
if (it.termMonths !== undefined) {
const tier = await one('SELECT recurring_cents FROM product_term_prices WHERE product_version_id = ? AND term_months = ?', [p.vid, it.termMonths]);
if (!tier) throw badRequest('Diese Laufzeit wird für dieses Produkt nicht angeboten', 'TERM_NOT_AVAILABLE');
recurringCents = tier.recurring_cents; termMonths = it.termMonths; if (p.renewal === 'auto') renewalTermMonths = it.termMonths;
}
// Preis IMMER serverseitig aus der aktuellen Produktversion; Eingaben des Clients beeinflussen den Preis nicht (außer der Laufzeitwahl oben)
const price = calculatePrice({ basis: p.price_basis, setupCents: p.setup_cents, recurringCents, taxBp: p.tax_bp, interval: p.billing_interval, quantity: it.quantity, discountBp: it.discountBp });
let terms = { termMonths, renewal: p.renewal as 'auto' | 'none', renewalTermMonths, noticeDays: p.notice_days as number };
if (org.customer_type === 'private' && terms.renewal === 'auto') terms = { ...terms, ...consumerTerms(terms.renewalTermMonths, terms.noticeDays) }; // Verbraucherregel, rechtlich zu prüfen
lines.push({ p, it, snapshot: { productId: p.id, sku: p.sku, productVersionId: p.vid, version: p.version, name: p.vname, category: p.category, customerType: org.customer_type, ...price, terms } });
}

View file

@ -113,3 +113,20 @@ describe('Rechnungspositionen aus der Domain-Aufstellung', () => {
expect((await call(app, acc, 'GET', `/invoices/${draft.id}`)).json().totalNetCents).toBe(1891);
});
});
describe('Rechnungsposition aus Produkt mit Laufzeit-Staffel', () => {
it('Ohne Laufzeitangabe der Basispreis, mit Laufzeitangabe die Staffel; unbekannte Laufzeit abgelehnt', async () => {
const admin = await staff('psugg-admin@example.com', 'superadmin'); const acc = await staff('psugg-acc@example.com', 'accounting');
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
const prod = await call(app, admin, 'POST', '/admin/products', {
sku: 'HOST-PSUGG', category: 'hosting', orderableByCustomer: true, requiresApproval: false, customerActions: [], status: 'active',
version: { name: 'Hosting X', taxRateId: t19, setupCents: 0, recurringCents: 999, billingInterval: 'monthly', termMonths: 1, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {},
termPrices: [{ termMonths: 12, recurringCents: 9588 }] },
});
const base = (await call(app, acc, 'GET', `/invoices/suggest-from-product/${prod.json().id}`)).json();
expect(base.items).toEqual([{ description: 'Hosting X', quantity: 1, unitPriceNetCents: 999, taxBp: 1900 }]);
const tier = (await call(app, acc, 'GET', `/invoices/suggest-from-product/${prod.json().id}?termMonths=12`)).json();
expect(tier.items).toEqual([{ description: 'Hosting X (12 Monate)', quantity: 1, unitPriceNetCents: 9588, taxBp: 1900 }]);
expect((await call(app, acc, 'GET', `/invoices/suggest-from-product/${prod.json().id}?termMonths=6`)).statusCode).toBe(400);
});
});

View file

@ -266,3 +266,37 @@ describe('Familytool-Editionen (Paket), Edition und Laufzeitpflege', () => {
const days = (new Date(tr.validUntil).getTime() - Date.now()) / 86400000; expect(days).toBeGreaterThan(13); expect(days).toBeLessThan(14.1);
});
});
describe('Staffelpreise nach Laufzeit', () => {
it('Produkt mit mehreren Laufzeiten: Bestellung wählt eine Staffel, Preis/Laufzeit werden serverseitig aus der Staffel übernommen', async () => {
const admin = await staff('term-admin@example.com', 'admin');
const A = await customer(admin, 'business', 'Firma Term', 'term-a@example.com');
const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id;
const prod = await call(app, admin, 'POST', '/admin/products', {
sku: 'HOST-TERM', category: 'hosting', orderableByCustomer: true, requiresApproval: false, customerActions: [], status: 'active',
version: { name: 'Hosting Staffel', taxRateId: t19, setupCents: 500, recurringCents: 999, billingInterval: 'monthly', termMonths: 1, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {},
termPrices: [{ termMonths: 1, recurringCents: 999 }, { termMonths: 3, recurringCents: 2699 }, { termMonths: 12, recurringCents: 9588 }, { termMonths: 24, recurringCents: 17988 }] },
});
expect(prod.statusCode).toBe(200);
// doppelte Laufzeit wird abgelehnt
expect((await call(app, admin, 'POST', '/admin/products', {
sku: 'HOST-TERM-BAD', category: 'hosting', orderableByCustomer: true, requiresApproval: false, customerActions: [], status: 'draft',
version: { name: 'x', taxRateId: t19, setupCents: 0, recurringCents: 0, billingInterval: 'monthly', termMonths: 1, renewal: 'none', noticeDays: 30, provisioning: {}, termPrices: [{ termMonths: 12, recurringCents: 100 }, { termMonths: 12, recurringCents: 200 }] },
})).statusCode).toBe(400);
const cat = (await call(app, A.client, 'GET', `/catalog?org=${A.org}`)).json();
const item = cat.find((c: any) => c.sku === 'HOST-TERM');
expect(item.termPrices).toEqual([
{ termMonths: 1, price: { net: 999, tax: 190, gross: 1189 } }, { termMonths: 3, price: { net: 2699, tax: 513, gross: 3212 } },
{ termMonths: 12, price: { net: 9588, tax: 1822, gross: 11410 } }, { termMonths: 24, price: { net: 17988, tax: 3418, gross: 21406 } },
]);
// nicht angebotene Laufzeit wird abgelehnt
expect((await call(app, A.client, 'POST', '/orders', { orgId: A.org, items: [{ productId: prod.json().id, termMonths: 6 }] })).statusCode).toBe(400);
const order = (await call(app, A.client, 'POST', '/orders', { orgId: A.org, items: [{ productId: prod.json().id, termMonths: 12 }] })).json();
const items = (await call(app, admin, 'GET', `/orders/${order.id}`)).json().items;
expect(items[0].snapshot.recurring).toEqual({ net: 9588, tax: 1822, gross: 11410 });
expect(items[0].snapshot.terms).toEqual({ termMonths: 12, renewal: 'auto', renewalTermMonths: 12, noticeDays: 30 });
});
});

View file

@ -3,7 +3,7 @@ import mysql from 'mysql2/promise';
import '../src/core/config.js';
/** Vor jeder Testdatei: Datenzeilen leeren (Testdatenbank!), Stammdaten (Steuersätze, Einstellungen) und Zähler zurücksetzen. */
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'ticket_attachments', 'ticket_messages', 'tickets', 'invoice_items', 'invoices', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
const DATA = ['backup_targets', 'backup_settings', 'domain_tlds', 'domain_records', 'ticket_attachments', 'ticket_messages', 'tickets', 'invoice_items', 'invoices', 'product_term_prices', 'audit_events', 'jobs', 'mail_log', 'contracts', 'order_items', 'orders', 'product_versions', 'products', 'resources', 'connector_instances', 'sessions', 'mfa_totp', 'recovery_codes', 'user_tokens', 'memberships', 'billing_profiles', 'organizations', 'users'];
beforeAll(async () => {
if (process.env.DB_NAME !== 'kundencenter_test') throw new Error('Tests dürfen nur gegen kundencenter_test laufen');
const c = await mysql.createConnection({ host: process.env.DB_HOST ?? '127.0.0.1', user: process.env.DB_USER!, password: process.env.DB_PASSWORD!, database: 'kundencenter_test' });