diff --git a/server/services/finance/format.js b/server/services/finance/format.js new file mode 100644 index 0000000..acffc97 --- /dev/null +++ b/server/services/finance/format.js @@ -0,0 +1,13 @@ +/** + * Anzeigeformate fuer die Finanzen. Betraege sind immer ganze Cent (Ganzzahl), nie Gleitkomma-Euro. + */ + +// Einmal anlegen (neu anlegen ist teuer); de-DE liefert "1.234,56 €" mit geschuetztem Leerzeichen +const euroFormat = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }) + +/** Cent als deutscher Euro-Betrag, z. B. 123456 -> "1.234,56 €", -449 -> "−4,49 €" (echtes Minuszeichen U+2212). */ +export function euroDe(cents) { + if (!Number.isSafeInteger(cents)) throw new Error('Betrag muss eine ganze Zahl in Cent sein.') + const text = euroFormat.format(Math.abs(cents) / 100) + return cents < 0 ? `−${text}` : text +} diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js new file mode 100644 index 0000000..805ca0c --- /dev/null +++ b/server/services/finance/stripeFinance.js @@ -0,0 +1,98 @@ +import { getStripe, subscriptionIdOfInvoice } from '../stripeBilling.js' +import { addDays, berlinDate, daysBetween } from './dates.js' + +/** + * Stripe-Rechnungen und -Auszahlungen fuer die Finanzen (nur lesen). Rechnungen und Auszahlungen werden nicht + * gespiegelt: sie kommen bei jeder Anfrage von Stripe, hoechstens alle 60 Sekunden (Zwischenspeicher je Art). + * Die Rohdaten liegen im Zwischenspeicher, gemappt wird bei jedem Aufruf neu: so stimmt "ueberfaellig" auch, + * wenn um Mitternacht ein neuer Tag beginnt, und ein Aufrufer kann den Zwischenspeicher nicht veraendern. + */ + +const CACHE_MS = 60_000 +// Rueckblick: Rechnungen der letzten 400 Tage (reicht fuer ein volles Jahr samt Nachzuegler) +const LOOKBACK_DAYS = 400 +const MAX_INVOICES = 1000 + +// Stripe-Status -> Status nach aussen; "open" wird je nach Faelligkeit zu "offen" oder "ueberfaellig" +const STATUS_DE = { draft: 'entwurf', open: 'offen', paid: 'bezahlt', void: 'storniert', uncollectible: 'uneinbringlich' } + +// Je Art ein Eintrag { at, promise }; die Zusage (statt der Daten) wird gemerkt, damit gleichzeitige Aufrufe +// dieselbe Stripe-Anfrage teilen. Ein Fehler wird nicht zwischengespeichert. +const caches = { invoices: null, payouts: null } + +function cached(kind, force, load) { + const entry = caches[kind] + if (!force && entry && Date.now() - entry.at < CACHE_MS) return entry.promise + const fresh = { at: Date.now(), promise: null } + fresh.promise = load().catch((err) => { + if (caches[kind] === fresh) caches[kind] = null + throw err + }) + caches[kind] = fresh + return fresh.promise +} + +/** Zwischenspeicher leeren (nach jeder Aenderung an Rechnungen oder Auszahlungen, damit die Ansicht stimmt). */ +export function invalidateFinanceCache() { + caches.invoices = null + caches.payouts = null +} + +// Stripe liefert Zeitpunkte als Unix-Sekunden; die Finanzen rechnen mit Berliner Kalendertagen +const ymdOfUnix = (seconds) => (Number.isFinite(seconds) ? berlinDate(new Date(seconds * 1000)) : null) + +/** Eine rohe Stripe-Rechnung (Kunde moeglichst expandiert) in die deutsche Form der Finanzen; today = YYYY-MM-DD. */ +export function mapInvoice(raw, { today }) { + const customer = raw.customer && typeof raw.customer === 'object' ? raw.customer : null + const metadata = raw.metadata || {} + const dueDate = ymdOfUnix(raw.due_date) + let status = STATUS_DE[raw.status] || raw.status + if (raw.status === 'open' && dueDate && dueDate < today) status = 'ueberfaellig' + return { + id: raw.id, + number: raw.number || '', + status, + customerName: raw.customer_name || customer?.name || '', + customerEmail: raw.customer_email || customer?.email || '', + webklarCustomerId: metadata.webklarCustomerId || customer?.metadata?.webklarCustomerId || '', + createdDate: ymdOfUnix(raw.created), + dueDate, + totalCents: raw.total ?? 0, + remainingCents: raw.amount_remaining ?? 0, + paidCents: raw.amount_paid ?? 0, + // Bei einer Bankzuordnung zaehlt der Tag, an dem das Geld auf dem Konto ankam, nicht der der Stripe-Buchung + paidDate: metadata.bankBookingDate || ymdOfUnix(raw.status_transitions?.paid_at), + overdueDays: status === 'ueberfaellig' ? daysBetween(dueDate, today) : 0, + source: subscriptionIdOfInvoice(raw) ? 'abo' : 'projekt', + hostedUrl: raw.hosted_invoice_url || null, + pdfUrl: raw.invoice_pdf || null, + outOfBand: Boolean(metadata.bankTransactionId), + bankTransactionId: metadata.bankTransactionId || '', + } +} + +/** Rechnungen der letzten 400 Tage (hoechstens 1000, neueste zuerst), mit Kunde, auf Deutsch gemappt. */ +export async function listInvoices({ force = false } = {}) { + const raw = await cached('invoices', force, () => + getStripe() + .invoices.list({ + limit: 100, + created: { gte: Math.floor(Date.now() / 1000) - LOOKBACK_DAYS * 86_400 }, + expand: ['data.customer'], + }) + .autoPagingToArray({ limit: MAX_INVOICES }) + ) + const today = berlinDate() + return raw.map((invoice) => mapInvoice(invoice, { today })) +} + +/** Die letzten 50 Stripe-Auszahlungen: { id, amountCents, arrivalDate, status }. */ +export async function listPayouts({ force = false } = {}) { + const raw = await cached('payouts', force, () => getStripe().payouts.list({ limit: 50 }).then((page) => page.data)) + return raw.map((payout) => ({ + id: payout.id, + amountCents: payout.amount, + arrivalDate: ymdOfUnix(payout.arrival_date), + status: payout.status, + })) +} diff --git a/test/financeStripeRead.test.js b/test/financeStripeRead.test.js new file mode 100644 index 0000000..12c3cd7 --- /dev/null +++ b/test/financeStripeRead.test.js @@ -0,0 +1,306 @@ +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 } = await import('../server/services/finance/stripeFinance.js') + +after(() => { + mock.timers.reset() + return Promise.all([aw.close(), st.close()]) +}) +beforeEach(() => { + 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', async () => { + for (let i = 1; i <= 1100; i += 1) st.invoices.set(`in_${i}`, rawInvoice({ id: `in_${i}` })) + const list = await listInvoices() + assert.equal(list.length, 1000) +}) + +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) +}) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index 381053d..01ccdfc 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -14,6 +14,9 @@ import http from 'node:http' * und Werte höchstens 500 Zeichen. Rechnungen: GET /v1/invoices/:id bettet wie Stripe höchstens 10 Zeilen ein, * GET /v1/invoices/:id/lines blättert (limit, starting_after); * POST /v1/invoices/create_preview liefert st.invoicePreview (Objekt, oder Funktion des Aufruf-Bodys). + * Listen (Finanzen): GET /v1/invoices (expand data.customer ersetzt customer durch das Objekt aus st.customers, + * sofern vorhanden) und GET /v1/payouts (st.payouts) blaettern wie Stripe mit limit (hoechstens 100) und + * starting_after samt has_more; die Reihenfolge ist die Einfuegereihenfolge der Map. */ // Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/ @@ -32,6 +35,7 @@ export async function startFakeStripe() { const coupons = new Map() const billingPortalConfigurations = new Map() const customers = new Map() + const payouts = new Map() const checkoutSessions = new Map() // Abo-Aenderungen, die Stripe bis zur Zahlung zurueckhaelt (pending_update), je Abo-ID const pendingUpdates = new Map() @@ -65,6 +69,15 @@ export async function startFakeStripe() { const invoiceLinesOf = (invoiceId) => (invoices.get(invoiceId)?.lines?.data || []).map((line, i) => ({ id: `li_${invoiceId}_${i + 1}`, object: 'line_item', ...line })) const EMBEDDED_LINES = 10 + // Listenantwort wie Stripe: limit (Standard 10, hoechstens 100) und starting_after (ID des letzten Eintrags der Vorseite) + const pageOf = (items, query, url) => { + const limit = Math.min(Number(query.get('limit')) || 10, 100) + const after = query.get('starting_after') + const afterIndex = after ? items.findIndex((item) => item.id === after) : -1 + if (after && afterIndex < 0) return invalid('starting_after', `No such object: '${after}'`) + const start = afterIndex + 1 + return [200, { object: 'list', data: items.slice(start, start + limit), has_more: start + limit < items.length, url }] + } const readBody = (req) => new Promise((resolve) => { @@ -203,6 +216,16 @@ export async function startFakeStripe() { prices.set(obj.id, obj) return [200, obj] } + if (method === 'GET' && path === '/v1/invoices') { + const expandCustomer = [...query.entries()].some(([k, v]) => k.startsWith('expand') && v === 'data.customer') + const all = [...invoices.values()].map((inv) => + expandCustomer && typeof inv.customer === 'string' && customers.has(inv.customer) ? { ...inv, customer: customers.get(inv.customer) } : inv + ) + return pageOf(all, query, '/v1/invoices') + } + if (method === 'GET' && path === '/v1/payouts') { + return pageOf([...payouts.values()], query, '/v1/payouts') + } if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) { if (!invoices.has(m[1])) return noSuch('invoice', m[1]) // Wie Stripe: hoechstens 10 Zeilen eingebettet, weitere ueber GET /v1/invoices/:id/lines (has_more) @@ -388,6 +411,7 @@ export async function startFakeStripe() { coupons, billingPortalConfigurations, customers, + payouts, checkoutSessions, /** * Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. latest_invoice). Mit