From e0fd57532d48ce076b3e49cac31a60181be9a85b Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Sun, 27 Sep 2026 18:23:31 +0200 Subject: [PATCH] =?UTF-8?q?Rechnungen:=20Positionen=20auch=20aus=20der=20D?= =?UTF-8?q?omain-Aufstellung=20=C3=BCbernehmen=20(Verkaufspreis)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/modules/invoices/index.ts | 10 +++++++++ apps/api/test/invoices.test.ts | 21 +++++++++++++++++++ .../src/app/(app)/rechnungen/[id]/page.tsx | 11 ++++++---- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts index 22830d2..01745df 100644 --- a/apps/api/src/modules/invoices/index.ts +++ b/apps/api/src/modules/invoices/index.ts @@ -156,6 +156,16 @@ export const invoicesModule: KcModule = { if (items.length === 0) items.push({ description: p.name, 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. */ + app.get('/invoices/suggest-from-domain/:domainRecordId', async (req) => { + const a = requirePermission(req, 'invoices.write'); const { domainRecordId } = z.object({ domainRecordId: z.string().uuid() }).parse(req.params); + const d = await one('SELECT * FROM domain_records WHERE id = ?', [domainRecordId]); if (!d) throw notFound(); + if (d.sell_net_cents === null) throw badRequest('Für diese Domain ist noch kein Verkaufspreis hinterlegt (Aufschlag fehlt unter Domains → Preisliste). Bitte dort ergänzen oder die Position von Hand erfassen.', 'NO_SELL_PRICE'); + const items = []; + if (d.setup_cost_cents > 0) { const setupNet = Math.round((d.setup_cost_cents * 10000) / (10000 + d.tax_bp)); items.push({ description: `Einrichtung ${d.domain}`, quantity: 1, unitPriceNetCents: setupNet, taxBp: d.tax_bp }); } + items.push({ description: d.term_months ? `${d.domain} (${d.term_months} Monate)` : d.domain, quantity: 1, unitPriceNetCents: d.sell_net_cents, taxBp: d.tax_bp }); + return { orgId: d.org_id, items }; + }); app.post('/invoices/:id/issue', async (req) => { const a = requirePermission(req, 'invoices.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); diff --git a/apps/api/test/invoices.test.ts b/apps/api/test/invoices.test.ts index 0480c8a..2c66b6a 100644 --- a/apps/api/test/invoices.test.ts +++ b/apps/api/test/invoices.test.ts @@ -92,3 +92,24 @@ describe('Rechnungspositionen aus Produkt/Vertrag übernehmen', () => { expect((await call(app, acc, 'GET', `/invoices/${draft.id}`)).json().totalNetCents).toBe(3500); }); }); + +describe('Rechnungspositionen aus der Domain-Aufstellung', () => { + it('Verkaufspreis (nie Einkauf) wird übernommen, inkl. Einrichtung; ohne Aufschlag klare Fehlermeldung', async () => { + const admin = await staff('domsug-admin@example.com', 'superadmin'); const acc = await staff('domsug-acc@example.com', 'accounting'); + const A = await customer(admin, 'Kunde A', 'domsug-a@example.com'); + await call(app, admin, 'POST', '/admin/domain-tlds/import', { text: 'com\t12\t12,50\t12,00\t11,00\t10,50\t7,50 €' }); + + const unpriced = (await call(app, admin, 'POST', '/admin/domain-records', { domain: 'domsug-x.com', orgId: A.org })).json(); + expect((await call(app, acc, 'GET', `/invoices/suggest-from-domain/${unpriced.id}`)).statusCode).toBe(400); + + await call(app, admin, 'PUT', '/admin/domain-settings', { tier: 1, rounding: false, basis: 'gross', margin: { type: 'percent', value: 2000 } }); + const priced = (await call(app, admin, 'POST', '/admin/domain-records', { domain: 'domsug-y.com', orgId: A.org })).json(); + const sugg = (await call(app, acc, 'GET', `/invoices/suggest-from-domain/${priced.id}`)).json(); + expect(sugg.orgId).toBe(A.org); + expect(sugg.items).toEqual([{ description: 'Einrichtung domsug-y.com', quantity: 1, unitPriceNetCents: 630, taxBp: 1900 }, { description: 'domsug-y.com (12 Monate)', quantity: 1, unitPriceNetCents: 1261, taxBp: 1900 }]); + + const draft = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json(); + await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items: sugg.items }); + expect((await call(app, acc, 'GET', `/invoices/${draft.id}`)).json().totalNetCents).toBe(1891); + }); +}); diff --git a/apps/web/src/app/(app)/rechnungen/[id]/page.tsx b/apps/web/src/app/(app)/rechnungen/[id]/page.tsx index cae0614..201adef 100644 --- a/apps/web/src/app/(app)/rechnungen/[id]/page.tsx +++ b/apps/web/src/app/(app)/rechnungen/[id]/page.tsx @@ -14,23 +14,25 @@ interface Suggested { description: string; quantity: number; unitPriceNetCents: 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 } +interface Dom { id: string; domain: string; sellNetCents: number | 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 [products, setProducts] = useState([]); const [contracts, setContracts] = useState([]); + const [products, setProducts] = useState([]); const [contracts, setContracts] = useState([]); const [domains, setDomains] = 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]); + if (can('domains.read')) api('GET', `/admin/domain-records?orgId=${inv.orgId}`).then(setDomains).catch(() => undefined); + }, [inv, can]); 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) { + async function takeFrom(kind: 'product' | 'contract' | 'domain', 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)); } } @@ -52,9 +54,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 || domains.length > 0) &&
{products.length > 0 && } {contracts.length > 0 && } + {domains.length > 0 && }
} {rows.map((r, i) => (
setRow(i, 'description', e.target.value)} />