Rechnungen: Positionen direkt aus Produkt oder Vertrag übernehmen

This commit is contained in:
Kundencenter 2026-09-27 16:51:19 +02:00
parent a1472617b9
commit 6f9efb91dc
3 changed files with 61 additions and 3 deletions

View file

@ -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) => {

View file

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