From 6f9efb91dc847e2d61f27bfeaa7b002dc2f92bb3 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Sun, 27 Sep 2026 16:51:19 +0200 Subject: [PATCH] =?UTF-8?q?Rechnungen:=20Positionen=20direkt=20aus=20Produ?= =?UTF-8?q?kt=20oder=20Vertrag=20=C3=BCbernehmen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/modules/invoices/index.ts | 18 +++++++++++-- apps/api/test/invoices.test.ts | 25 +++++++++++++++++++ .../src/app/(app)/rechnungen/[id]/page.tsx | 21 +++++++++++++++- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts index 1151c43..22830d2 100644 --- a/apps/api/src/modules/invoices/index.ts +++ b/apps/api/src/modules/invoices/index.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { randomUUID } from 'node:crypto'; import type { PoolConnection } from 'mysql2/promise'; +import { calculatePrice } from '@kc/platform/pricing'; import { one, query, run, tx } from '../../core/db.js'; import { audit } from '../../core/audit.js'; import { enqueue } from '../../core/jobs.js'; @@ -136,11 +137,24 @@ export const invoicesModule: KcModule = { }); /** Aus einem Vertrag die letzte Preisangabe als Positionsvorschlag übernehmen (nichts wird automatisch gespeichert). */ + /** Vorschlag für eine Rechnungsposition aus dem eingefrorenen Preis-Snapshot eines Vertrags (die laufende Periode). */ app.get('/invoices/suggest-from-contract/:contractId', async (req) => { const a = requirePermission(req, 'invoices.write'); const { contractId } = z.object({ contractId: z.string().uuid() }).parse(req.params); const c = await one('SELECT * FROM contracts WHERE id = ?', [contractId]); if (!c) throw notFound(); - const snap = JSON.parse(c.price_snapshot_json); - return { orgId: c.org_id, description: snap.name, quantity: 1, unitPriceNetCents: snap.recurring?.net ?? 0, taxBp: snap.taxBp ?? 1900, contractId }; + const snap = typeof c.price_snapshot_json === 'string' ? JSON.parse(c.price_snapshot_json) : c.price_snapshot_json; + return { orgId: c.org_id, items: [{ description: snap.name, quantity: 1, unitPriceNetCents: snap.recurring?.net ?? 0, taxBp: snap.taxBp ?? 1900, contractId }] }; + }); + /** 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]); + 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 }); + 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 }); + return { items }; }); app.post('/invoices/:id/issue', async (req) => { diff --git a/apps/api/test/invoices.test.ts b/apps/api/test/invoices.test.ts index 68a6410..0480c8a 100644 --- a/apps/api/test/invoices.test.ts +++ b/apps/api/test/invoices.test.ts @@ -67,3 +67,28 @@ describe('Rechnungen', () => { const staffOpenB = (await call(app, acc, 'GET', `/invoices?org=${B.org}`)).json(); expect(staffOpenB).toEqual([]); }); }); + +describe('Rechnungspositionen aus Produkt/Vertrag übernehmen', () => { + it('Vorschlag aus einem Produkt (Einrichtung + wiederkehrend als eigene Positionen) und aus einem Vertrag (laufende Periode)', async () => { + const admin = await staff('sugg-admin@example.com', 'superadmin'); const acc = await staff('sugg-acc@example.com', 'accounting'); + const A = await customer(admin, 'Kunde A', 'sugg-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-M', category: 'hosting', orderableByCustomer: true, requiresApproval: false, customerActions: [], status: 'active', + version: { name: 'Hosting Paket M', taxRateId: t19, setupCents: 1000, recurringCents: 2500, billingInterval: 'monthly', termMonths: 12, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {} }, + }); + const suggProd = (await call(app, acc, 'GET', `/invoices/suggest-from-product/${prod.json().id}`)).json(); + expect(suggProd.items).toEqual([{ description: 'Einrichtung: Hosting Paket M', quantity: 1, unitPriceNetCents: 1000, taxBp: 1900 }, { description: 'Hosting Paket M', quantity: 1, unitPriceNetCents: 2500, taxBp: 1900 }]); + + const order = (await call(app, admin, 'POST', '/orders', { orgId: A.org, items: [{ productId: prod.json().id }] })).json(); + const contractId = (await call(app, admin, 'GET', `/orders/${order.id}`)).json().items[0].contractId; + const suggContract = (await call(app, acc, 'GET', `/invoices/suggest-from-contract/${contractId}`)).json(); + expect(suggContract.orgId).toBe(A.org); expect(suggContract.items).toEqual([{ description: 'Hosting Paket M', quantity: 1, unitPriceNetCents: 2500, taxBp: 1900, contractId }]); + + // Die Vorschläge lassen sich als Positionen übernehmen + const draft = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json(); + expect((await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items: suggProd.items })).statusCode).toBe(200); + expect((await call(app, acc, 'GET', `/invoices/${draft.id}`)).json().totalNetCents).toBe(3500); + }); +}); diff --git a/apps/web/src/app/(app)/rechnungen/[id]/page.tsx b/apps/web/src/app/(app)/rechnungen/[id]/page.tsx index 187f731..cae0614 100644 --- a/apps/web/src/app/(app)/rechnungen/[id]/page.tsx +++ b/apps/web/src/app/(app)/rechnungen/[id]/page.tsx @@ -10,15 +10,30 @@ interface Item { id: string; description: string; quantity: number; unitPriceNet interface Inv { id: string; number: string | null; orgId: string; orgName: string; status: string; overdue: boolean; issueDate: string | null; dueDate: string | null; paymentMethod: string | null; note: string | null; totalNetCents: number; totalTaxCents: number; totalGrossCents: number; cancelsInvoiceId: string | null; cancelledByInvoiceId: string | null; items: Item[]; canWrite: boolean } type Row = { description: string; quantity: string; unitPrice: string; taxBp: string }; 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 Contract { id: string; number: string; productName: string | null } export default function RechnungDetail() { const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('invoices.read'); const [inv, setInv] = useState(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [rows, setRows] = useState([emptyRow()]); const [busy, setBusy] = useState(false); - const load = useCallback(() => api('GET', `/invoices/${id}`).then((v) => { setInv(v); if (v.status === 'draft') setRows(v.items.length ? v.items.map((it) => ({ description: it.description, quantity: String(it.quantity), unitPrice: (it.unitPriceNetCents / 100).toFixed(2).replace('.', ','), taxBp: String(it.taxBp / 100) })) : [emptyRow()]); }).catch((e) => setErr(errMsg(e))), [id]); + const [products, setProducts] = useState([]); const [contracts, setContracts] = useState([]); + const load = useCallback(() => api('GET', `/invoices/${id}`).then((v) => { setInv(v); if (v.status === 'draft') setRows(v.items.length ? v.items.map((it) => toRow(it)) : [emptyRow()]); }).catch((e) => setErr(errMsg(e))), [id]); useEffect(() => { void load(); }, [load]); + useEffect(() => { + if (!inv || inv.status !== 'draft' || !inv.canWrite) return; + api('GET', '/admin/products').then((l) => setProducts(l.filter((p) => p.status === 'active' && p.current))).catch(() => undefined); + api('GET', `/contracts?org=${inv.orgId}&status=active`).then(setContracts).catch(() => undefined); + }, [inv]); 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', pickId: 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)); } + } async function saveItems(e: FormEvent) { e.preventDefault(); const items = rows.filter((r) => r.description.trim() && r.unitPrice.trim()).map((r) => ({ description: r.description.trim(), quantity: Number(r.quantity.replace(',', '.')), unitPriceNetCents: Math.round(Number(r.unitPrice.replace(',', '.')) * 100), taxBp: Math.round(Number(r.taxBp.replace(',', '.')) * 100) })); @@ -37,6 +52,10 @@ export default function RechnungDetail() { {err && {err}}{ok && {ok}} {isDraft && inv.canWrite ? (

Positionen

+ {(products.length > 0 || contracts.length > 0) &&
+ {products.length > 0 && } + {contracts.length > 0 && } +
} {rows.map((r, i) => (
setRow(i, 'description', e.target.value)} /> setRow(i, 'quantity', e.target.value)} />