import { beforeAll, describe, expect, it } from 'vitest'; import type { FastifyInstance } from 'fastify'; import { buildApp } from '../src/server.js'; import { call, code, login, makeUser } from './helpers.js'; let app: FastifyInstance; beforeAll(async () => { app = await buildApp(); await app.ready(); }); 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); }); });