2026-09-27 09:05:13 +02:00
import { beforeAll , describe , expect , it } from 'vitest' ;
import type { FastifyInstance } from 'fastify' ;
import { buildApp } from '../src/server.js' ;
2026-09-27 21:19:48 +02:00
import { run } from '../src/core/db.js' ;
import { runOnce } from '../../worker/src/jobs.js' ;
2026-09-27 09:05:13 +02:00
import { call , code , login , makeUser } from './helpers.js' ;
let app : FastifyInstance ;
beforeAll ( async ( ) = > { app = await buildApp ( ) ; await app . ready ( ) ; } ) ;
2026-09-27 21:19:48 +02:00
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 ; } } ;
2026-09-27 09:05:13 +02:00
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 ( [ ] ) ;
} ) ;
} ) ;
2026-09-27 16:51:19 +02:00
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 ) ;
} ) ;
} ) ;
2026-09-27 18:23:31 +02:00
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 ) ;
} ) ;
} ) ;
2026-09-27 18:35:34 +02:00
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 ) ;
} ) ;
} ) ;
2026-09-27 21:19:48 +02:00
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
} ) ;
} ) ;