import { test, after, beforeEach, mock } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' // Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber stripeBilling) geladen wird. // Der Appwrite-Nachbau wird nur fuer die Umgebung gebraucht. const aw = await startAppwriteMock({ schema: {} }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { euroDe } = await import('../server/services/finance/format.js') const { mapInvoice, listInvoices, listPayouts, invalidateFinanceCache, setStripeReadOptionsForTests } = await import( '../server/services/finance/stripeFinance.js' ) const { getStripe } = await import('../server/services/stripeBilling.js') after(() => { mock.timers.reset() return Promise.all([aw.close(), st.close()]) }) beforeEach(() => { mock.restoreAll() setStripeReadOptionsForTests(null) mock.timers.reset() invalidateFinanceCache() st.calls.length = 0 st.invoices.clear() st.customers.clear() st.payouts.clear() }) const TODAY = '2026-10-01' // Unix-Sekunden eines Berliner Mittags (MESZ = UTC+2), damit das Berliner Datum eindeutig ist const noonBerlin = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1000) const rawInvoice = (over = {}) => ({ id: 'in_1', object: 'invoice', number: 'WK-0001', status: 'open', customer: 'cus_1', customer_name: 'Muster GmbH', customer_email: 'muster@example.invalid', created: noonBerlin('2026-09-06'), due_date: noonBerlin('2026-10-20'), total: 12000, amount_due: 12000, amount_paid: 0, amount_remaining: 12000, status_transitions: { paid_at: null }, hosted_invoice_url: 'https://invoice.stripe.test/i/in_1', invoice_pdf: 'https://invoice.stripe.test/i/in_1/pdf', metadata: {}, lines: { object: 'list', data: [], has_more: false }, ...over, }) test('euroDe: deutsches Euro-Format mit Tausenderpunkt, Komma und echtem Minuszeichen', () => { // geschuetztes Leerzeichen (U+00A0) vor dem Euro-Zeichen wie Intl, Minus ist U+2212 assert.equal(euroDe(123456), '1.234,56\u00a0€') assert.equal(euroDe(-449), '\u22124,49\u00a0€') assert.equal(euroDe(0), '0,00\u00a0€') assert.equal(euroDe(5), '0,05\u00a0€') assert.equal(euroDe(100000000), '1.000.000,00\u00a0€') assert.equal(euroDe(-100), '\u22121,00\u00a0€') assert.throws(() => euroDe(12.5), /Cent/) assert.throws(() => euroDe(undefined), /Cent/) }) test('mapInvoice: Stripe-Status werden deutsch gemappt', () => { const draft = mapInvoice(rawInvoice({ status: 'draft', number: null, due_date: null }), { today: TODAY }) assert.equal(draft.status, 'entwurf') assert.equal(draft.number, '') assert.equal(draft.dueDate, null) assert.equal(mapInvoice(rawInvoice({ status: 'open' }), { today: TODAY }).status, 'offen') const paid = mapInvoice( rawInvoice({ status: 'paid', amount_paid: 12000, amount_remaining: 0, status_transitions: { paid_at: noonBerlin('2026-09-15') } }), { today: TODAY } ) assert.equal(paid.status, 'bezahlt') assert.equal(paid.paidCents, 12000) assert.equal(paid.remainingCents, 0) assert.equal(paid.paidDate, '2026-09-15') assert.equal(mapInvoice(rawInvoice({ status: 'void' }), { today: TODAY }).status, 'storniert') assert.equal(mapInvoice(rawInvoice({ status: 'uncollectible' }), { today: TODAY }).status, 'uneinbringlich') }) test('mapInvoice: Felder, Betraege in Cent und Berliner Kalendertage', () => { const inv = mapInvoice( // 22:30 UTC am 05.09. ist in Berlin schon der 06.09. rawInvoice({ created: Math.floor(new Date('2026-09-05T22:30:00Z').getTime() / 1000) }), { today: TODAY } ) assert.deepEqual(inv, { id: 'in_1', number: 'WK-0001', status: 'offen', customerName: 'Muster GmbH', customerEmail: 'muster@example.invalid', webklarCustomerId: '', createdDate: '2026-09-06', dueDate: '2026-10-20', totalCents: 12000, remainingCents: 12000, paidCents: 0, paidDate: null, overdueDays: 0, source: 'projekt', hostedUrl: 'https://invoice.stripe.test/i/in_1', pdfUrl: 'https://invoice.stripe.test/i/in_1/pdf', outOfBand: false, bankTransactionId: '', }) }) test('mapInvoice: offen und faellig vor heute -> ueberfaellig mit Verzugstagen', () => { const inv = mapInvoice(rawInvoice({ due_date: noonBerlin('2026-09-20') }), { today: TODAY }) assert.equal(inv.status, 'ueberfaellig') assert.equal(inv.overdueDays, 11) }) test('mapInvoice: am Faelligkeitstag selbst noch nicht ueberfaellig, ohne Faelligkeit nie', () => { const same = mapInvoice(rawInvoice({ due_date: noonBerlin(TODAY) }), { today: TODAY }) assert.equal(same.status, 'offen') assert.equal(same.overdueDays, 0) const none = mapInvoice(rawInvoice({ due_date: null }), { today: TODAY }) assert.equal(none.status, 'offen') assert.equal(none.overdueDays, 0) }) test('mapInvoice: nur offene Rechnungen werden ueberfaellig, bezahlte mit alter Faelligkeit nicht', () => { const paid = mapInvoice( rawInvoice({ status: 'paid', due_date: noonBerlin('2026-08-01'), amount_paid: 12000, amount_remaining: 0, status_transitions: { paid_at: noonBerlin('2026-08-02') } }), { today: TODAY } ) assert.equal(paid.status, 'bezahlt') assert.equal(paid.overdueDays, 0) const voided = mapInvoice(rawInvoice({ status: 'void', due_date: noonBerlin('2026-08-01') }), { today: TODAY }) assert.equal(voided.status, 'storniert') assert.equal(voided.overdueDays, 0) }) test('mapInvoice: Abo-Rechnung (parent.subscription_details) -> source abo, sonst projekt', () => { const abo = mapInvoice(rawInvoice({ parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_1' } } }), { today: TODAY }) assert.equal(abo.source, 'abo') const alt = mapInvoice(rawInvoice({ subscription: 'sub_2' }), { today: TODAY }) assert.equal(alt.source, 'abo') const projekt = mapInvoice(rawInvoice({ parent: null, metadata: { quelle: 'woms' } }), { today: TODAY }) assert.equal(projekt.source, 'projekt') }) test('mapInvoice: Bankzuordnung -> outOfBand, Buchungsdatum als paidDate', () => { const inv = mapInvoice( rawInvoice({ status: 'paid', amount_paid: 12000, amount_remaining: 0, // Stripe verbucht erst am 07.08. (paid_out_of_band), das Geld kam aber am 05.08. auf dem Konto an status_transitions: { paid_at: noonBerlin('2026-08-07') }, metadata: { bankTransactionId: 'tx_abc', bankBookingDate: '2026-08-05' }, }), { today: TODAY } ) assert.equal(inv.paidDate, '2026-08-05') assert.equal(inv.outOfBand, true) assert.equal(inv.bankTransactionId, 'tx_abc') }) test('mapInvoice: webklarCustomerId aus der Rechnung, sonst aus dem expandierten Kunden', () => { const direkt = mapInvoice(rawInvoice({ metadata: { webklarCustomerId: 'c1' }, customer: { id: 'cus_1', metadata: { webklarCustomerId: 'c9' } } }), { today: TODAY }) assert.equal(direkt.webklarCustomerId, 'c1') const expandiert = mapInvoice(rawInvoice({ customer: { id: 'cus_1', name: 'Kunde', email: 'k@example.invalid', metadata: { webklarCustomerId: 'c9' } } }), { today: TODAY }) assert.equal(expandiert.webklarCustomerId, 'c9') // nicht expandiert (nur ID): keine Zuordnung, aber auch kein Fehler assert.equal(mapInvoice(rawInvoice(), { today: TODAY }).webklarCustomerId, '') }) test('mapInvoice: Name und E-Mail fallen auf den expandierten Kunden zurueck', () => { const inv = mapInvoice( rawInvoice({ customer_name: null, customer_email: null, customer: { id: 'cus_1', name: 'Kunde AG', email: 'k@example.invalid', metadata: {} } }), { today: TODAY } ) assert.equal(inv.customerName, 'Kunde AG') assert.equal(inv.customerEmail, 'k@example.invalid') }) test('listInvoices: fragt mit limit, created und expand an, bettet den Kunden ein und mappt', async () => { st.customers.set('cus_1', { id: 'cus_1', object: 'customer', name: 'Muster GmbH', email: 'm@example.invalid', metadata: { webklarCustomerId: 'c1' } }) st.invoices.set('in_1', rawInvoice({ id: 'in_1', due_date: noonBerlin('2026-09-20') })) const before = Math.floor(Date.now() / 1000) const list = await listInvoices() assert.equal(list.length, 1) assert.equal(list[0].id, 'in_1') assert.equal(list[0].webklarCustomerId, 'c1') assert.equal(list[0].status, 'ueberfaellig') assert.ok(list[0].overdueDays > 0) const calls = st.calls.filter((c) => c.path === '/v1/invoices') assert.equal(calls.length, 1) assert.equal(calls[0].method, 'GET') assert.equal(calls[0].query.limit, '100') assert.equal(calls[0].query['expand[0]'], 'data.customer') // 400 Tage Rueckblick const gte = Number(calls[0].query['created[gte]']) assert.ok(Math.abs(gte - (before - 400 * 86400)) < 60, `created[gte] ${gte}`) }) test('listInvoices: blaettert ueber alle Seiten', async () => { for (let i = 1; i <= 230; i += 1) st.invoices.set(`in_${i}`, rawInvoice({ id: `in_${i}`, number: `WK-${i}` })) const list = await listInvoices() assert.equal(list.length, 230) assert.equal(new Set(list.map((inv) => inv.id)).size, 230) assert.equal(st.calls.filter((c) => c.path === '/v1/invoices').length, 3) }) test('listInvoices: hoechstens 1000 Rechnungen, bei der Obergrenze genau eine Warnung je Laden (M9)', async () => { for (let i = 1; i <= 1100; i += 1) st.invoices.set(`in_${i}`, rawInvoice({ id: `in_${i}` })) const warn = mock.method(console, 'warn', () => {}) const list = await listInvoices() assert.equal(list.length, 1000) assert.deepEqual(warn.mock.calls.map((c) => c.arguments), [['[finanzen] Rechnungsliste bei 1000 abgeschnitten']]) // aus dem Zwischenspeicher: kein neues Laden, keine zweite Warnung; ein neues Laden warnt wieder await listInvoices() assert.equal(warn.mock.callCount(), 1) await listInvoices({ force: true }) assert.equal(warn.mock.callCount(), 2) }) test('listInvoices: unter der Obergrenze keine Warnung (M9)', async () => { for (let i = 1; i <= 999; i += 1) st.invoices.set(`in_${i}`, rawInvoice({ id: `in_${i}` })) const warn = mock.method(console, 'warn', () => {}) assert.equal((await listInvoices()).length, 999) assert.equal(warn.mock.callCount(), 0) }) test('Lesen: Voreinstellung 15 s Zeitgrenze und eine Wiederholung je Anfrage an Stripe (I2)', async () => { const stripe = getStripe() const invoicesList = mock.method(stripe.invoices, 'list', () => ({ autoPagingToArray: async () => [] })) const payoutsList = mock.method(stripe.payouts, 'list', async () => ({ data: [] })) await listInvoices() await listPayouts() assert.deepEqual(invoicesList.mock.calls[0].arguments[1], { timeout: 15_000, maxNetworkRetries: 1 }) assert.deepEqual(payoutsList.mock.calls[0].arguments[1], { timeout: 15_000, maxNetworkRetries: 1 }) }) test('Lesen: antwortet Stripe nicht, endet die Anfrage an der Zeitgrenze statt nach 80 Sekunden (I2)', async () => { setStripeReadOptionsForTests({ timeout: 80, maxNetworkRetries: 0 }) st.invoices.set('in_1', rawInvoice()) st.payouts.set('po_1', { id: 'po_1', object: 'payout', amount: 100, arrival_date: noonBerlin('2026-09-12'), status: 'paid' }) const isTimeout = (err) => err.type === 'StripeConnectionError' && /timeout/i.test(err.message) st.delayNext({ method: 'GET', path: '/v1/invoices', ms: 700 }) let started = Date.now() await assert.rejects(listInvoices(), isTimeout) assert.ok(Date.now() - started < 500, `Rechnungen: ${Date.now() - started} ms`) st.delayNext({ method: 'GET', path: '/v1/payouts', ms: 700 }) started = Date.now() await assert.rejects(listPayouts(), isTimeout) assert.ok(Date.now() - started < 500, `Auszahlungen: ${Date.now() - started} ms`) // der Fehler wird nicht zwischengespeichert: der naechste Aufruf klappt assert.equal((await listInvoices()).length, 1) assert.equal((await listPayouts()).length, 1) }) test('Lesen: die Zeitgrenze gilt auch fuer die Folgeseiten der Rechnungsliste (I2)', async () => { for (let i = 1; i <= 150; i += 1) st.invoices.set(`in_${i}`, rawInvoice({ id: `in_${i}` })) setStripeReadOptionsForTests({ timeout: 80, maxNetworkRetries: 0 }) // die erste Seite kommt sofort, die zweite haengt st.delayNext({ method: 'GET', path: '/v1/invoices', ms: 700, after: 1 }) const started = Date.now() await assert.rejects(listInvoices(), (err) => err.type === 'StripeConnectionError') assert.ok(Date.now() - started < 500) assert.equal(st.calls.filter((c) => c.path === '/v1/invoices').length, 2) }) test('listInvoices: zweimal innerhalb von 60 s -> ein Stripe-Aufruf, danach invalidateFinanceCache und force', async () => { st.invoices.set('in_1', rawInvoice()) const countCalls = () => st.calls.filter((c) => c.path === '/v1/invoices').length await listInvoices() await listInvoices() assert.equal(countCalls(), 1) invalidateFinanceCache() await listInvoices() assert.equal(countCalls(), 2) await listInvoices({ force: true }) assert.equal(countCalls(), 3) // force frischt auch den Zwischenspeicher auf await listInvoices() assert.equal(countCalls(), 3) }) test('listInvoices: gleichzeitige Aufrufe teilen sich eine Stripe-Anfrage', async () => { st.invoices.set('in_1', rawInvoice()) const [a, b] = await Promise.all([listInvoices(), listInvoices()]) assert.equal(st.calls.filter((c) => c.path === '/v1/invoices').length, 1) assert.equal(a.length, 1) assert.equal(b.length, 1) }) test('listInvoices: nach 60 s wird neu geladen', async () => { mock.timers.enable({ apis: ['Date'], now: Date.now() }) st.invoices.set('in_1', rawInvoice()) const countCalls = () => st.calls.filter((c) => c.path === '/v1/invoices').length await listInvoices() mock.timers.tick(59_000) await listInvoices() assert.equal(countCalls(), 1) mock.timers.tick(2_000) await listInvoices() assert.equal(countCalls(), 2) }) test('listInvoices: Aenderungen am Ergebnis vergiften den Zwischenspeicher nicht', async () => { st.invoices.set('in_1', rawInvoice()) const first = await listInvoices() first[0].status = 'bezahlt' first.pop() const second = await listInvoices() assert.equal(second.length, 1) assert.equal(second[0].status, 'offen') }) test('listInvoices: Stripe-Fehler wird nicht zwischengespeichert', async () => { st.invoices.set('in_1', rawInvoice()) st.failNext({ path: '/v1/invoices', status: 400, message: 'kaputt' }) await assert.rejects(() => listInvoices(), /kaputt/) const list = await listInvoices() assert.equal(list.length, 1) }) test('listPayouts: Betrag, Ankunftstag (Berlin) und Status, 60-s-Zwischenspeicher', async () => { st.payouts.set('po_1', { id: 'po_1', object: 'payout', amount: 45000, arrival_date: noonBerlin('2026-09-12'), status: 'paid', currency: 'eur' }) st.payouts.set('po_2', { id: 'po_2', object: 'payout', amount: 1999, arrival_date: Math.floor(new Date('2026-09-30T22:30:00Z').getTime() / 1000), status: 'in_transit', currency: 'eur' }) const list = await listPayouts() assert.deepEqual(list, [ { id: 'po_1', amountCents: 45000, arrivalDate: '2026-09-12', status: 'paid' }, { id: 'po_2', amountCents: 1999, arrivalDate: '2026-10-01', status: 'in_transit' }, ]) const calls = () => st.calls.filter((c) => c.path === '/v1/payouts') assert.equal(calls().length, 1) assert.equal(calls()[0].query.limit, '50') await listPayouts() assert.equal(calls().length, 1) invalidateFinanceCache() await listPayouts() assert.equal(calls().length, 2) await listPayouts({ force: true }) assert.equal(calls().length, 3) }) test('invalidateFinanceCache leert Rechnungen und Auszahlungen', async () => { st.invoices.set('in_1', rawInvoice()) st.payouts.set('po_1', { id: 'po_1', object: 'payout', amount: 100, arrival_date: noonBerlin('2026-09-12'), status: 'paid' }) await listInvoices() await listPayouts() invalidateFinanceCache() await listInvoices() await listPayouts() assert.equal(st.calls.filter((c) => c.path === '/v1/invoices').length, 2) assert.equal(st.calls.filter((c) => c.path === '/v1/payouts').length, 2) })