diff --git a/apps/api/src/modules/catalog/index.ts b/apps/api/src/modules/catalog/index.ts index 091ac1c..42aa2ce 100644 --- a/apps/api/src/modules/catalog/index.ts +++ b/apps/api/src/modules/catalog/index.ts @@ -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; import { CUSTOMER_ACTIONS } from '../../core/actions.js'; @@ -63,9 +68,11 @@ async function insertVersion(c: Parameters[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 { @@ -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 }; + })); }); }, }; diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts index 01745df..22cec91 100644 --- a/apps/api/src/modules/invoices/index.ts +++ b/apps/api/src/modules/invoices/index.ts @@ -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. */ diff --git a/apps/api/src/modules/orders/index.ts b/apps/api/src/modules/orders/index.ts index 2e2c0e0..6682f56 100644 --- a/apps/api/src/modules/orders/index.ts +++ b/apps/api/src/modules/orders/index.ts @@ -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 } }); } diff --git a/apps/api/test/invoices.test.ts b/apps/api/test/invoices.test.ts index 2c66b6a..c2a1b17 100644 --- a/apps/api/test/invoices.test.ts +++ b/apps/api/test/invoices.test.ts @@ -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); + }); +}); diff --git a/apps/api/test/orders.test.ts b/apps/api/test/orders.test.ts index f7e4b8c..5f34934 100644 --- a/apps/api/test/orders.test.ts +++ b/apps/api/test/orders.test.ts @@ -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 }); + }); +}); diff --git a/apps/api/test/setup.ts b/apps/api/test/setup.ts index 2d6c356..1a3f858 100644 --- a/apps/api/test/setup.ts +++ b/apps/api/test/setup.ts @@ -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' }); diff --git a/apps/web/src/app/(app)/admin/produkte/[id]/page.tsx b/apps/web/src/app/(app)/admin/produkte/[id]/page.tsx index 459d694..a25d70b 100644 --- a/apps/web/src/app/(app)/admin/produkte/[id]/page.tsx +++ b/apps/web/src/app/(app)/admin/produkte/[id]/page.tsx @@ -7,6 +7,7 @@ import { useSession } from '@/lib/session'; import { Alert, eur, Field, fmt, INTERVAL, Status, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui'; import type { Prod } from '../page'; +const TERMS = [1, 3, 6, 12, 24]; type D = Prod & { versions: NonNullable[] }; export default function Produkt() { const { id } = useParams<{ id: string }>(); const { can } = useSession(); const w = can('products.write'); @@ -18,8 +19,9 @@ export default function Produkt() { async function patch(b: object, ok: string) { try { await api('PATCH', `/admin/products/${id}`, b); setMsg({ k: 'ok', t: ok }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } } async function newVersion(e: FormEvent) { e.preventDefault(); const f = new FormData(e.currentTarget); const once = cur.billingInterval === 'once'; + const termPrices = once ? [] : TERMS.map((m) => ({ termMonths: m, recurringCents: f.get(`term${m}`) === '' || f.get(`term${m}`) === null ? null : Math.round(Number(f.get(`term${m}`)) * 100) })).filter((t): t is { termMonths: number; recurringCents: number } => t.recurringCents !== null); try { await api('POST', `/admin/products/${id}/versions`, { name: f.get('name'), taxRateId: f.get('tax'), priceBasis: cur.priceBasis, setupCents: Math.round(Number(f.get('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(f.get('recurring') || 0) * 100), billingInterval: cur.billingInterval, - termMonths: cur.termMonths, renewal: cur.renewal, renewalTermMonths: cur.renewalTermMonths || undefined, noticeDays: Number(f.get('notice') || cur.noticeDays), provisioning: d!.provisioning }); + termMonths: cur.termMonths, renewal: cur.renewal, renewalTermMonths: cur.renewalTermMonths || undefined, noticeDays: Number(f.get('notice') || cur.noticeDays), provisioning: d!.provisioning, termPrices }); setOpen(false); setMsg({ k: 'ok', t: 'Neue Version angelegt. Bestehende Verträge behalten ihren Preis.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } } return (<> @@ -29,6 +31,7 @@ export default function Produkt() {

Aktuelle Konditionen (Version {cur.version})

{cur.setupCents > 0 && <>Einrichtung {eur(cur.setupCents)} {cur.priceBasis === 'gross' ? 'brutto' : 'netto'}
}{cur.billingInterval === 'once' ? 'Einmalpreis' : `${INTERVAL[cur.billingInterval]} ${eur(cur.recurringCents)} ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'}`} · Steuer {cur.taxBp / 100} %

{cur.termMonths > 0 ? `Mindestlaufzeit ${cur.termMonths} Monate` : 'Keine Mindestlaufzeit'}{cur.renewal === 'auto' ? `, Verlängerung um ${cur.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {cur.noticeDays} Tage · Bereitstellung: {d.connectorName ?? 'manuell'}{Object.keys(d.provisioning).length ? ` (${JSON.stringify(d.provisioning)})` : ''}

+ {d.termPrices.length > 0 &&

Laufzeit-Staffelpreise: {d.termPrices.map((t) => `${t.termMonths} Mon. ${eur(t.recurringCents)}`).join(' · ')}

} {w &&
{d.status !== 'active' && } {d.status === 'active' && }
}
@@ -38,6 +41,11 @@ export default function Produkt() { {cur.billingInterval !== 'once' && } + {cur.billingInterval !== 'once' && <> +

Staffelpreise nach Laufzeit (optional)

+

Preis jeweils für die gesamte Laufzeit, nicht pro Monat. Leer lassen, wenn diese Laufzeit nicht angeboten wird.

+
{TERMS.map((m) => 1 ? 'e' : ''} (€ ${cur.priceBasis === 'gross' ? 'brutto' : 'netto'}, gesamt)`}> t.termMonths === m) ? d.termPrices.find((t) => t.termMonths === m)!.recurringCents / 100 : ''} />)}
+ }
}

Verkaufsregeln

diff --git a/apps/web/src/app/(app)/admin/produkte/hosting-tarif/page.tsx b/apps/web/src/app/(app)/admin/produkte/hosting-tarif/page.tsx index 4ae304c..11dbb06 100644 --- a/apps/web/src/app/(app)/admin/produkte/hosting-tarif/page.tsx +++ b/apps/web/src/app/(app)/admin/produkte/hosting-tarif/page.tsx @@ -8,6 +8,7 @@ import { Alert, Field, ACTION_LABEL, CUSTOMER_ACTIONS, INTERVAL } from '@/compon interface Conn { id: string; name: string; connector: string; capabilities: string[] } const LIMITS: [string, string, string][] = [['diskSpaceGb', 'Speicher (GB)', '10'], ['trafficGb', 'Traffic pro Monat (GB)', '100'], ['domains', 'Domains', '3'], ['subdomains', 'Subdomains', '10'], ['emailAccounts', 'Postfächer', '10'], ['emailAddresses', 'E-Mail-Adressen', '20'], ['emailForwardings', 'Weiterleitungen', '10'], ['databases', 'Datenbanken', '3'], ['ftpUsers', 'FTP-Benutzer', '3'], ['scheduledTasks', 'Cronjobs', '2']]; const PERMS: [string, string][] = [['panel_access', 'Zugang zum Kundenpanel'], ['ftp', 'FTP'], ['php', 'PHP'], ['ssh', 'SSH'], ['backup', 'Backups'], ['file_manager', 'Dateimanager'], ['dns_editor', 'DNS-Editor'], ['certificate_management', 'SSL-Zertifikate verwalten'], ['domain_email', 'E-Mail-Domains'], ['email_catchall', 'Catch-all'], ['database_remote_access', 'Datenbank-Fernzugriff'], ['delete_main_domain', 'Hauptdomain löschen']]; +const TERMS = [1, 3, 6, 12, 24]; export default function HostingTarif() { const { can } = useSession(); @@ -21,11 +22,12 @@ export default function HostingTarif() { const limits = Object.fromEntries(LIMITS.map(([k]) => [k, unl[k] ? null : (g(k) === '' ? undefined : Number(g(k)))]).filter(([, v]) => v !== undefined)); const permissions = Object.fromEntries(PERMS.map(([k]) => [k, f.get(`perm-${k}`) === 'on'])); const once = interval === 'once'; + const termPrices = once ? [] : TERMS.map((m) => ({ termMonths: m, recurringCents: g(`term${m}`) === '' ? null : Math.round(Number(g(`term${m}`)) * 100) })).filter((t): t is { termMonths: number; recurringCents: number } => t.recurringCents !== null); try { const r = await api<{ productId: string; plan: { name: string; features: string[] } }>('POST', `/admin/connectors/${connId}/hosting-plans`, { plan: { name: g('planName'), limits, permissions }, product: { sku: g('sku'), status: f.get('activate') === 'on' ? 'active' : 'draft', orderableByCustomer: f.get('orderable') === 'on', requiresApproval: f.get('approval') === 'on', customerActions: CUSTOMER_ACTIONS.filter((a) => f.get(`act-${a}`) === 'on'), - version: { name: g('name') || g('planName'), description: g('desc') || undefined, taxRateId: g('tax'), priceBasis: basis, setupCents: Math.round(Number(g('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(g('recurring') || 0) * 100), billingInterval: interval, termMonths: once ? 0 : Number(g('term') || 0), renewal: !once && f.get('renewal') === 'on' ? 'auto' : 'none', noticeDays: Number(g('notice') || 30) } } }); + version: { name: g('name') || g('planName'), description: g('desc') || undefined, taxRateId: g('tax'), priceBasis: basis, setupCents: Math.round(Number(g('setup') || 0) * 100), recurringCents: once ? 0 : Math.round(Number(g('recurring') || 0) * 100), billingInterval: interval, termMonths: once ? 0 : Number(g('term') || 0), renewal: !once && f.get('renewal') === 'on' ? 'auto' : 'none', noticeDays: Number(g('notice') || 30), termPrices } } }); setOk({ id: r.productId, name: r.plan.name, features: r.plan.features }); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } } @@ -54,6 +56,11 @@ export default function HostingTarif() { {interval !== 'once' && <>}
{interval !== 'once' && } + {interval !== 'once' && <> +

Staffelpreise nach Laufzeit (optional)

+

Lässt Kunden bei der Bestellung eine Laufzeit wählen, z. B. günstiger bei 12 oder 24 Monaten. Preis jeweils für die gesamte Laufzeit, nicht pro Monat. Leer lassen, wenn diese Laufzeit nicht angeboten wird.

+
{TERMS.map((m) => 1 ? 'e' : ''} (€ ${basis === 'gross' ? 'brutto' : 'netto'}, gesamt)`}>)}
+ }

Verkauf

Was Kunden am Hosting-Konto selbst tun dürfen
{CUSTOMER_ACTIONS.map((a) => )}
diff --git a/apps/web/src/app/(app)/admin/produkte/page.tsx b/apps/web/src/app/(app)/admin/produkte/page.tsx index 3a06e92..da79c76 100644 --- a/apps/web/src/app/(app)/admin/produkte/page.tsx +++ b/apps/web/src/app/(app)/admin/produkte/page.tsx @@ -6,7 +6,8 @@ import { useSession } from '@/lib/session'; import { Alert, Empty, eur, Field, INTERVAL, Status, CUSTOMER_ACTIONS, ACTION_LABEL } from '@/components/ui'; export interface Prod { id: string; sku: string; category: string; status: string; connectorInstanceId: string | null; connectorName: string | null; orderableByCustomer: boolean; requiresApproval: boolean; customerActions: string[]; provisioning: Record; - current: { id: string; version: number; name: string; taxBp: number; priceBasis: 'net' | 'gross'; setupCents: number; recurringCents: number; billingInterval: string; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number } | null } + current: { id: string; version: number; name: string; taxBp: number; priceBasis: 'net' | 'gross'; setupCents: number; recurringCents: number; billingInterval: string; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number } | null; + termPrices: { termMonths: number; recurringCents: number }[] } export const CAT: Record = { hosting: 'Hosting', license: 'Lizenz', addon: 'Zusatzleistung', service: 'Dienstleistung' }; const PSTATUS: Record = { active: 'active', draft: 'invited', retired: 'closed' }; export default function Produkte() { diff --git a/apps/web/src/app/(app)/bestellen/page.tsx b/apps/web/src/app/(app)/bestellen/page.tsx index c2e96f0..37ef405 100644 --- a/apps/web/src/app/(app)/bestellen/page.tsx +++ b/apps/web/src/app/(app)/bestellen/page.tsx @@ -3,19 +3,20 @@ import { useEffect, useState, type FormEvent } from 'react'; import { useRouter } from 'next/navigation'; import { api, errMsg } from '@/lib/api'; import { useSession } from '@/lib/session'; -import { Alert, Empty, Field, type Price, PriceText } from '@/components/ui'; +import { Alert, Empty, eur, Field, type Price, PriceText } from '@/components/ui'; -interface P { id: string; sku: string; category: string; name: string; description: string | null; requiresApproval: boolean; price: Price; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number } +interface P { id: string; sku: string; category: string; name: string; description: string | null; requiresApproval: boolean; price: Price; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number; termPrices: { termMonths: number; price: { net: number; tax: number; gross: number } }[] } interface Cust { id: string; name: string; customerNumber: string; customerType: 'private' | 'business' } export default function Bestellen() { const { me, can } = useSession(); const r = useRouter(); const staff = can('orders.write'); - const [custs, setCusts] = useState([]); const [org, setOrg] = useState(''); const [cat, setCat] = useState(null); const [sel, setSel] = useState

(null); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); + const [custs, setCusts] = useState([]); const [org, setOrg] = useState(''); const [cat, setCat] = useState(null); const [sel, setSel] = useState

(null); const [term, setTerm] = useState(''); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); useEffect(() => { if (staff) api('GET', '/admin/customers').then((l) => { setCusts(l); setOrg(l[0]?.id ?? ''); }).catch((e) => setErr(errMsg(e))); else if (me?.organizations[0]) setOrg(me.organizations[0].id); }, [staff, me]); useEffect(() => { if (org) api('GET', `/catalog?org=${org}`).then(setCat).catch((e) => setErr(errMsg(e))); }, [org]); const priv = (cat?.[0] as (P & { customerType?: string }) | undefined)?.customerType === 'private'; + const selPrice: Price | null = sel && (term === '' ? sel.price : { ...sel.price, recurring: sel.termPrices.find((t) => t.termMonths === term)!.price }); async function order(e: FormEvent) { e.preventDefault(); if (!sel) return; setBusy(true); setErr(''); const f = new FormData(e.currentTarget); - try { const o = await api<{ status: string; number: string }>('POST', '/orders', { orgId: org, note: (f.get('note') as string) || undefined, items: [{ productId: sel.id, quantity: 1, ...(staff ? { discountBp: Math.round(Number(f.get('discount') || 0) * 100) } : {}) }] }); r.push(`/bestellungen?neu=${o.number}`); } + try { const o = await api<{ status: string; number: string }>('POST', '/orders', { orgId: org, note: (f.get('note') as string) || undefined, items: [{ productId: sel.id, quantity: 1, ...(term !== '' ? { termMonths: term } : {}), ...(staff ? { discountBp: Math.round(Number(f.get('discount') || 0) * 100) } : {}) }] }); r.push(`/bestellungen?neu=${o.number}`); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } } return (<> @@ -25,8 +26,15 @@ export default function Bestellen() {

{cat.map((p) =>

{p.name}

{p.description &&

{p.description}

}

{p.termMonths > 0 ? `Mindestlaufzeit ${p.termMonths} Monate` : 'Keine Mindestlaufzeit'}{p.renewal === 'auto' ? `, Verlängerung um ${p.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {p.noticeDays} Tage

-
)}
- {sel &&

Bestellung prüfen

{sel.name}

+
)} + {sel && selPrice &&

Bestellung prüfen

{sel.name}

+ {sel.termPrices.length > 0 && } +
+ {term === '' ? : (<> + {selPrice.setup.net > 0 &&
Einrichtung: {priv ? `${eur(selPrice.setup.gross)} inkl. USt.` : `${eur(selPrice.setup.net)} netto (${eur(selPrice.setup.gross)} brutto)`}
} +
Laufzeit {term} Monate, gesamt: {priv ? `${eur(selPrice.recurring.gross)} inkl. USt.` : `${eur(selPrice.recurring.net)} netto (${eur(selPrice.recurring.gross)} brutto)`}
+ )} +
{staff && }

{staff ? 'Die Bestellung wird sofort freigegeben und bereitgestellt.' : sel.requiresApproval ? 'Ihre Bestellung wird von uns geprüft und freigegeben. Sie sehen den Stand unter „Bestellungen“.' : 'Die Bereitstellung startet sofort.'}

diff --git a/apps/web/src/app/(app)/rechnungen/[id]/page.tsx b/apps/web/src/app/(app)/rechnungen/[id]/page.tsx index 201adef..c689917 100644 --- a/apps/web/src/app/(app)/rechnungen/[id]/page.tsx +++ b/apps/web/src/app/(app)/rechnungen/[id]/page.tsx @@ -12,7 +12,7 @@ type Row = { description: string; quantity: string; unitPrice: string; taxBp: st const emptyRow = (): Row => ({ description: '', quantity: '1', unitPrice: '', taxBp: '19' }); interface Suggested { description: string; quantity: number; unitPriceNetCents: number; taxBp: number } const toRow = (it: Suggested): Row => ({ description: it.description, quantity: String(it.quantity), unitPrice: (it.unitPriceNetCents / 100).toFixed(2).replace('.', ','), taxBp: String(it.taxBp / 100) }); -interface Prod { id: string; status: string; current: { name: string } | null } +interface Prod { id: string; status: string; current: { name: string } | null; termPrices: { termMonths: number }[] } interface Contract { id: string; number: string; productName: string | null } interface Dom { id: string; domain: string; sellNetCents: number | null } @@ -32,9 +32,9 @@ export default function RechnungDetail() { const run = async (fn: () => Promise, msg?: string) => { setErr(''); setOk(''); setBusy(true); try { await fn(); if (msg) setOk(msg); void load(); } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } }; function appendSuggested(items: Suggested[]) { setRows((rs) => [...rs.filter((r) => r.description.trim() || r.unitPrice.trim()), ...items.map(toRow)]); } - async function takeFrom(kind: 'product' | 'contract' | 'domain', pickId: string) { + async function takeFrom(kind: 'product' | 'contract' | 'domain', pickId: string, termMonths?: string) { if (!pickId) return; setErr(''); - try { const r = await api<{ items: Suggested[] }>('GET', `/invoices/suggest-from-${kind}/${pickId}`); appendSuggested(r.items); } catch (x) { setErr(errMsg(x)); } + try { const r = await api<{ items: Suggested[] }>('GET', `/invoices/suggest-from-${kind}/${pickId}${termMonths ? `?termMonths=${termMonths}` : ''}`); appendSuggested(r.items); } catch (x) { setErr(errMsg(x)); } } async function saveItems(e: FormEvent) { e.preventDefault(); @@ -55,7 +55,11 @@ export default function RechnungDetail() { {isDraft && inv.canWrite ? (

Positionen

{(products.length > 0 || contracts.length > 0 || domains.length > 0) &&
- {products.length > 0 && } + {products.length > 0 && } {contracts.length > 0 && } {domains.length > 0 && }
} diff --git a/migrations/018_product_term_prices.sql b/migrations/018_product_term_prices.sql new file mode 100644 index 0000000..359cee4 --- /dev/null +++ b/migrations/018_product_term_prices.sql @@ -0,0 +1,9 @@ +-- Staffelpreise nach Laufzeit (z. B. 1/3/6/12/24 Monate), zusätzlich zum Basispreis der Version. +-- Optional: ohne Einträge verhält sich ein Produkt genau wie bisher (ein fester Preis). +CREATE TABLE product_term_prices ( + id CHAR(36) PRIMARY KEY, + product_version_id CHAR(36) NOT NULL REFERENCES product_versions(id), + term_months INT NOT NULL, + recurring_cents INT NOT NULL, -- Preis für die gesamte gewählte Laufzeit (nicht pro Monat) + UNIQUE KEY uq_ptp (product_version_id, term_months) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;