kundencenter/apps/api/test/invoices.test.ts

276 lines
23 KiB
TypeScript
Raw Permalink Normal View History

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
});
});
2026-09-28 11:01:19 +02:00
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 });
});
});