kundencenter/apps/api/test/invoices.test.ts
2026-09-28 11:01:19 +02:00

275 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { buildApp } from '../src/server.js';
import { one, run } from '../src/core/db.js';
import { runOnce } from '../../worker/src/jobs.js';
import { call, code, login, makeUser } from './helpers.js';
let app: FastifyInstance;
beforeAll(async () => { app = await buildApp(); await app.ready(); });
const drain = async () => { for (let i = 0; i < 30; i++) { await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE status IN ('scheduled','retrying')"); if (!(await runOnce(() => undefined))) break; } };
async function staff(email: string, role: string) {
await makeUser({ email, kind: 'staff', staffRole: role }); const { client } = await login(app, email);
const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client;
}
async function customer(admin: any, name: string, mail: string) {
const c = (await call(app, admin, 'POST', '/admin/customers', { type: 'business', name, owner: { email: mail, name } })).json();
await app.inject({ method: 'POST', url: '/v1/auth/invite/accept', payload: { token: new URL(c.inviteLink).searchParams.get('token'), password: 'passwort-kunde-123', repeat: 'passwort-kunde-123' } });
return { org: c.id as string, client: (await login(app, mail, 'passwort-kunde-123')).client };
}
const COMPANY = { name: 'Testfirma GmbH', street: 'Musterstr. 1', zip: '12345', city: 'Musterstadt', taxNumber: '12/345/67890' };
describe('Rechnungen', () => {
it('Entwurf, Positionen, unvollständige Firmendaten blockieren das Ausstellen, danach ausstellen/PDF/bezahlt/Storno, Unveränderlichkeit, Mandantentrennung', async () => {
const admin = await staff('inv-admin@example.com', 'superadmin'); const acc = await staff('inv-acc@example.com', 'accounting'); const support = await staff('inv-support@example.com', 'support');
const A = await customer(admin, 'Kunde A', 'inv-a@example.com'); const B = await customer(admin, 'Kunde B', 'inv-b@example.com');
expect((await call(app, support, 'POST', '/invoices', { orgId: A.org })).statusCode).toBe(403); // Support nur lesend
const draft = (await call(app, acc, 'POST', '/invoices', { orgId: A.org, paymentMethod: 'Überweisung' })).json();
expect((await call(app, A.client, 'GET', `/invoices/${draft.id}`)).statusCode).toBe(404); // Kunde sieht Entwurf nicht
expect((await call(app, acc, 'GET', `/invoices/${draft.id}`)).json().status).toBe('draft');
const items = [{ description: 'Hosting Paket M, September', quantity: 1, unitPriceNetCents: 10000, taxBp: 1900 }, { description: 'Domain-Aufschlag', quantity: 2, unitPriceNetCents: 500, taxBp: 1900 }];
expect((await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items })).statusCode).toBe(200);
const withItems = (await call(app, acc, 'GET', `/invoices/${draft.id}`)).json();
expect(withItems.totalNetCents).toBe(11000); expect(withItems.totalTaxCents).toBe(2090); expect(withItems.totalGrossCents).toBe(13090);
// ohne vollständige Firmendaten kein Ausstellen
const blocked = await call(app, acc, 'POST', `/invoices/${draft.id}/issue`); expect(blocked.statusCode).toBe(400); expect(blocked.json().error.code).toBe('COMPANY_SETTINGS_INCOMPLETE');
expect((await call(app, acc, 'PUT', '/admin/company-settings', COMPANY)).statusCode).toBe(403); // nur superadmin
expect((await call(app, admin, 'PUT', '/admin/company-settings', COMPANY)).statusCode).toBe(200);
expect((await call(app, acc, 'GET', '/admin/company-settings')).json().complete).toBe(true);
const issued = await call(app, acc, 'POST', `/invoices/${draft.id}/issue`); expect(issued.statusCode).toBe(200); const number = issued.json().number; expect(number).toMatch(/^RE-\d+$/);
// ab jetzt unveränderlich
expect((await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items })).statusCode).toBe(409);
expect((await call(app, acc, 'DELETE', `/invoices/${draft.id}`)).statusCode).toBe(403);
// Kunde sieht sie jetzt, PDF ist ladbar; fremder Kunde nicht
const seen = (await call(app, A.client, 'GET', `/invoices/${draft.id}`)).json(); expect(seen.number).toBe(number); expect(seen.status).toBe('open');
expect((await call(app, B.client, 'GET', `/invoices/${draft.id}`)).statusCode).toBe(404);
const pdf = await app.inject({ method: 'GET', url: `/v1/invoices/${draft.id}/pdf`, headers: { cookie: A.client.cookie } });
expect(pdf.statusCode).toBe(200); expect(pdf.headers['content-type']).toBe('application/pdf'); expect(pdf.rawPayload.subarray(0, 4).toString()).toBe('%PDF');
// Storno: neue Rechnung mit umgekehrten Vorzeichen, Original unveränderlich als storniert markiert
const credit = await call(app, acc, 'POST', `/invoices/${draft.id}/cancel`, { reason: 'Testkorrektur' }); expect(credit.statusCode).toBe(200);
const orig = (await call(app, acc, 'GET', `/invoices/${draft.id}`)).json(); expect(orig.status).toBe('cancelled'); expect(orig.cancelledByInvoiceId).toBe(credit.json().creditInvoiceId);
const cr = (await call(app, acc, 'GET', `/invoices/${credit.json().creditInvoiceId}`)).json(); expect(cr.totalGrossCents).toBe(-13090); expect(cr.cancelsInvoiceId).toBe(draft.id);
expect((await call(app, acc, 'POST', `/invoices/${draft.id}/cancel`)).statusCode).toBe(409); // nicht doppelt stornierbar
expect((await call(app, acc, 'POST', `/invoices/${draft.id}/mark-paid`)).statusCode).toBe(409); // stornierte Rechnung nicht mehr "bezahlbar"
// zweite, normal bezahlte Rechnung
const d2 = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json();
await call(app, acc, 'PUT', `/invoices/${d2.id}/items`, { items: [{ description: 'Setup', quantity: 1, unitPriceNetCents: 2000, taxBp: 1900 }] });
await call(app, acc, 'POST', `/invoices/${d2.id}/issue`);
expect((await call(app, acc, 'POST', `/invoices/${d2.id}/mark-paid`)).statusCode).toBe(200);
expect((await call(app, acc, 'GET', `/invoices/${d2.id}`)).json().status).toBe('paid');
// Listen: Kunde sieht nur eigene, keine Entwürfe
const custList = (await call(app, A.client, 'GET', '/invoices')).json(); expect(custList.every((i: any) => i.status !== 'draft')).toBe(true); expect(custList.length).toBe(3);
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);
});
});
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);
});
});
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);
});
});
describe('Mahnwesen: überfällige Rechnungen und fällige Verträge', () => {
it('Überfällige offene Rechnungen und Verträge ohne aktuelle Rechnung werden gefunden; bezahlte/aktuelle/inaktive nicht', async () => {
const admin = await staff('rem-admin@example.com', 'superadmin'); const acc = await staff('rem-acc@example.com', 'accounting'); const support = await staff('rem-support@example.com', 'support');
await call(app, admin, 'PUT', '/admin/company-settings', COMPANY);
const A = await customer(admin, 'Kunde A', 'rem-a@example.com'); const B = await customer(admin, 'Kunde B', 'rem-b@example.com'); const C = await customer(admin, 'Kunde C', 'rem-c@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-REM', category: 'hosting', orderableByCustomer: true, requiresApproval: false, customerActions: [], status: 'active',
version: { name: 'Hosting Monatlich', taxRateId: t19, setupCents: 0, recurringCents: 999, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {}, termPrices: [] },
})).json();
// A: Vertrag seit 40 Tagen, nie in Rechnung gestellt -> fällig
const orderA = (await call(app, admin, 'POST', '/orders', { orgId: A.org, items: [{ productId: prod.id }] })).json(); await drain();
const contractA = (await call(app, admin, 'GET', `/orders/${orderA.id}`)).json().items[0].contractId;
await run('UPDATE contracts SET started_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 40 DAY) WHERE id = ?', [contractA]);
// B: Vertrag seit 40 Tagen, aber vor 5 Tagen bereits in Rechnung gestellt -> nicht fällig
const orderB = (await call(app, admin, 'POST', '/orders', { orgId: B.org, items: [{ productId: prod.id }] })).json(); await drain();
const contractB = (await call(app, admin, 'GET', `/orders/${orderB.id}`)).json().items[0].contractId;
await run('UPDATE contracts SET started_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 40 DAY) WHERE id = ?', [contractB]);
const invB = (await call(app, acc, 'POST', '/invoices', { orgId: B.org })).json();
await call(app, acc, 'PUT', `/invoices/${invB.id}/items`, { items: [{ description: 'x', quantity: 1, unitPriceNetCents: 999, taxBp: 1900, contractId: contractB }] });
await call(app, acc, 'POST', `/invoices/${invB.id}/issue`);
await run('UPDATE invoices SET issue_date = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 5 DAY) WHERE id = ?', [invB.id]);
// C: Vertrag erst seit 2 Tagen -> nicht fällig
const orderC = (await call(app, admin, 'POST', '/orders', { orgId: C.org, items: [{ productId: prod.id }] })).json(); await drain();
// überfällige Rechnung bei A: ausgestellt, Zahlungsziel überschritten, offen
const invA = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json();
await call(app, acc, 'PUT', `/invoices/${invA.id}/items`, { items: [{ description: 'überfällig', quantity: 1, unitPriceNetCents: 5000, taxBp: 1900 }] });
await call(app, acc, 'POST', `/invoices/${invA.id}/issue`, { dueDate: new Date(Date.now() - 3 * 86400000).toISOString().slice(0, 10) });
const r = (await call(app, acc, 'GET', '/invoices/reminders')).json();
expect(r.overdueInvoices.map((i: any) => i.id)).toContain(invA.id);
expect(r.overdueInvoices.map((i: any) => i.id)).not.toContain(invB.id);
const dueOrgs = r.dueContracts.map((c: any) => c.orgId);
expect(dueOrgs).toContain(A.org); expect(dueOrgs).not.toContain(B.org); expect(dueOrgs).not.toContain(C.org);
const entryA = r.dueContracts.find((c: any) => c.orgId === A.org); expect(entryA.productName).toBe('Hosting Monatlich'); expect(entryA.lastInvoicedAt).toBeNull();
expect((await call(app, support, 'GET', '/invoices/reminders')).statusCode).toBe(200); // Support liest mit
expect((await call(app, A.client, 'GET', '/invoices/reminders')).statusCode).toBe(403); // Kunden nicht
});
});
describe('Rechnungen: eingefrorener Absender-/Empfänger-Stand, atomare Statuswechsel', () => {
it('Ausstellen friert Firmen- und Kundendaten ein; spätere Änderungen wirken sich nicht rückwirkend aus', async () => {
const admin = await staff('snap-admin@example.com', 'superadmin'); const acc = await staff('snap-acc@example.com', 'accounting');
await call(app, admin, 'PUT', '/admin/company-settings', { ...COMPANY, iban: 'DE00 ALT 0000 0000 0000 00' });
const A = await customer(admin, 'Kunde Snap', 'snap-a@example.com');
const draft = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json();
await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items: [{ description: 'x', quantity: 1, unitPriceNetCents: 1000, taxBp: 1900 }] });
await call(app, acc, 'POST', `/invoices/${draft.id}/issue`);
const row = await one('SELECT seller_snapshot_json, buyer_snapshot_json FROM invoices WHERE id = ?', [draft.id]);
const seller = typeof row!.seller_snapshot_json === 'string' ? JSON.parse(row!.seller_snapshot_json) : row!.seller_snapshot_json;
expect(seller.iban).toBe('DE00 ALT 0000 0000 0000 00'); expect(seller.name).toBe(COMPANY.name);
// Firmendaten ändern sich NACH dem Ausstellen
await call(app, admin, 'PUT', '/admin/company-settings', { ...COMPANY, name: 'Neue Firma GmbH', iban: 'DE00 NEU 0000 0000 0000 00' });
const rowAfter = await one('SELECT seller_snapshot_json FROM invoices WHERE id = ?', [draft.id]);
const sellerAfter = typeof rowAfter!.seller_snapshot_json === 'string' ? JSON.parse(rowAfter!.seller_snapshot_json) : rowAfter!.seller_snapshot_json;
expect(sellerAfter.iban).toBe('DE00 ALT 0000 0000 0000 00'); expect(sellerAfter.name).toBe(COMPANY.name); // unverändert trotz späterer Änderung
// PDF bleibt trotz geänderter Firmendaten abrufbar und liest den eingefrorenen Stand (kein Absturz, alte Daten)
const pdf = await app.inject({ method: 'GET', url: `/v1/invoices/${draft.id}/pdf`, headers: { cookie: acc.cookie, 'x-csrf-token': acc.csrf } });
expect(pdf.statusCode).toBe(200); expect(pdf.rawPayload.subarray(0, 4).toString()).toBe('%PDF');
// Storno übernimmt den eingefrorenen Stand des Originals, nicht die inzwischen geänderten aktuellen Firmendaten
const credit = (await call(app, acc, 'POST', `/invoices/${draft.id}/cancel`)).json();
const creditRow = await one('SELECT seller_snapshot_json FROM invoices WHERE id = ?', [credit.creditInvoiceId]);
const creditSeller = typeof creditRow!.seller_snapshot_json === 'string' ? JSON.parse(creditRow!.seller_snapshot_json) : creditRow!.seller_snapshot_json;
expect(creditSeller.iban).toBe('DE00 ALT 0000 0000 0000 00');
});
it('Gleichzeitiges Ausstellen derselben Rechnung: genau ein Versuch gewinnt, keine Nummernlücke', async () => {
const admin = await staff('race-admin@example.com', 'superadmin'); const acc = await staff('race-acc@example.com', 'accounting');
await call(app, admin, 'PUT', '/admin/company-settings', COMPANY);
const A = await customer(admin, 'Kunde Race', 'race-a@example.com');
const draft = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json();
await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items: [{ description: 'x', quantity: 1, unitPriceNetCents: 1000, taxBp: 1900 }] });
const [r1, r2] = await Promise.all([call(app, acc, 'POST', `/invoices/${draft.id}/issue`), call(app, acc, 'POST', `/invoices/${draft.id}/issue`)]);
const codes = [r1.statusCode, r2.statusCode].sort(); expect(codes).toEqual([200, 409]);
const winner = r1.statusCode === 200 ? r1.json() : r2.json();
const n1 = Number(winner.number.split('-')[1]);
// die nächste, unabhängige Rechnung bekommt die direkt folgende Nummer – kein Sprung durch den Verlierer
const draft2 = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json();
await call(app, acc, 'PUT', `/invoices/${draft2.id}/items`, { items: [{ description: 'y', quantity: 1, unitPriceNetCents: 500, taxBp: 1900 }] });
const issued2 = (await call(app, acc, 'POST', `/invoices/${draft2.id}/issue`)).json();
expect(Number(issued2.number.split('-')[1])).toBe(n1 + 1);
});
it('Gleichzeitiges Bezahlen/Stornieren: nur ein Versuch gewinnt', async () => {
const admin = await staff('race2-admin@example.com', 'superadmin'); const acc = await staff('race2-acc@example.com', 'accounting');
await call(app, admin, 'PUT', '/admin/company-settings', COMPANY);
const A = await customer(admin, 'Kunde Race2', 'race2-a@example.com');
const draft = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json();
await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items: [{ description: 'x', quantity: 1, unitPriceNetCents: 1000, taxBp: 1900 }] });
await call(app, acc, 'POST', `/invoices/${draft.id}/issue`);
const [p1, p2] = await Promise.all([call(app, acc, 'POST', `/invoices/${draft.id}/mark-paid`), call(app, acc, 'POST', `/invoices/${draft.id}/mark-paid`)]);
expect([p1.statusCode, p2.statusCode].sort()).toEqual([200, 409]);
const draft3 = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json();
await call(app, acc, 'PUT', `/invoices/${draft3.id}/items`, { items: [{ description: 'z', quantity: 1, unitPriceNetCents: 200, taxBp: 1900 }] });
await call(app, acc, 'POST', `/invoices/${draft3.id}/issue`);
const [c1, c2] = await Promise.all([call(app, acc, 'POST', `/invoices/${draft3.id}/cancel`), call(app, acc, 'POST', `/invoices/${draft3.id}/cancel`)]);
expect([c1.statusCode, c2.statusCode].sort()).toEqual([200, 409]);
const invAfter = (await call(app, acc, 'GET', `/invoices/${draft3.id}`)).json();
expect(invAfter.status).toBe('cancelled'); // nicht doppelt storniert, genau ein Storno-Verweis
});
});
describe('Rechnungspositionen: Rabatt je Position', () => {
it('Rabatt mindert Netto/USt/Brutto korrekt, wirkt auf die Summen, wird im PDF ausgeliefert und bei Storno übernommen', async () => {
const admin = await staff('disc-admin@example.com', 'superadmin'); const acc = await staff('disc-acc@example.com', 'accounting');
await call(app, admin, 'PUT', '/admin/company-settings', COMPANY);
const A = await customer(admin, 'Kunde Rabatt', 'disc-a@example.com');
const draft = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json();
await call(app, acc, 'PUT', `/invoices/${draft.id}/items`, { items: [
{ description: 'Mit Rabatt', quantity: 1, unitPriceNetCents: 10000, taxBp: 1900, discountBp: 1000 }, // 10 %
{ description: 'Ohne Rabatt', quantity: 1, unitPriceNetCents: 5000, taxBp: 1900 },
] });
const inv = (await call(app, acc, 'GET', `/invoices/${draft.id}`)).json();
expect(inv.items[0]).toMatchObject({ discountBp: 1000, netCents: 9000, taxCents: 1710, grossCents: 10710 });
expect(inv.items[1]).toMatchObject({ discountBp: 0, netCents: 5000 });
expect(inv.totalNetCents).toBe(14000); expect(inv.totalGrossCents).toBe(16660);
await call(app, acc, 'POST', `/invoices/${draft.id}/issue`);
const pdf = await app.inject({ method: 'GET', url: `/v1/invoices/${draft.id}/pdf`, headers: { cookie: acc.cookie, 'x-csrf-token': acc.csrf } });
expect(pdf.statusCode).toBe(200);
const credit = (await call(app, acc, 'POST', `/invoices/${draft.id}/cancel`)).json();
const creditItems = (await call(app, acc, 'GET', `/invoices/${credit.creditInvoiceId}`)).json().items;
expect(creditItems.find((i: any) => i.description === 'Mit Rabatt')).toMatchObject({ discountBp: 1000, netCents: -9000 });
});
});