From daa451094a400bed1c7d13b7c2aaeb8152ceca48 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 11:18:00 +0000 Subject: [PATCH] Finanzen: Zeitgrenzen fuer Stripe-Lesen, Schutz vor doppelt angelegten Rechnungen I2: Leseanfragen an Stripe mit timeout 15 s und einer Wiederholung (auch Folgeseiten), die Uebersicht wartet hoechstens 20 s auf Stripe und rechnet sonst ohne Stripe weiter. I3: idempotencyKey vom Aufrufer an stripe.invoices.create, Schutz im Prozess gegen gleichzeitiges und 30 s spaeteres Doppelanlegen (409), 502 mit invoiceId/Nummer wenn die fertige Rechnung nicht lesbar ist. M9: Warnung, wenn die Rechnungsliste bei 1000 abgeschnitten wird. M11: Ursache eines 502 an einer bestehenden Rechnung (Art, Status, Code) im Protokoll. Co-Authored-By: Claude Sonnet 5.5 --- server/routes/admin/finanzen.js | 9 +- server/services/finance/overview.js | 32 ++++- server/services/finance/stripeFinance.js | 162 ++++++++++++++++++----- test/financeOverview.test.js | 89 ++++++++++++- test/financeSettings.test.js | 6 +- test/financeStripeRead.test.js | 65 ++++++++- test/financeStripeWrite.test.js | 130 +++++++++++++++++- test/support/fakeStripe.js | 31 ++++- 8 files changed, 476 insertions(+), 48 deletions(-) diff --git a/server/routes/admin/finanzen.js b/server/routes/admin/finanzen.js index 30994a8..99b2445 100644 --- a/server/routes/admin/finanzen.js +++ b/server/routes/admin/finanzen.js @@ -46,7 +46,8 @@ const BANK_UNAVAILABLE_MESSAGE = 'Die Bank hat die Freigabe nicht bestätigt. Bi const publicDoc = (doc) => Object.fromEntries(Object.entries(doc).filter(([key]) => key === '$id' || !key.startsWith('$'))) /** Fehlerart fuers Protokoll: nie die Meldung (sie koennte Kennungen der Bank oder von Appwrite enthalten). */ -const logFailure = (what, err) => console.error(`[finanzen] ${what} fehlgeschlagen:`, err?.name || 'Error', err?.status ?? '', err?.code ?? '') +const logFailure = (what, err) => + console.error(`[finanzen] ${what} fehlgeschlagen:`, err?.type || err?.name || 'Error', err?.status ?? err?.statusCode ?? '', err?.code ?? '') router.get('/uebersicht', async (_req, res) => { try { @@ -148,6 +149,8 @@ const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsW */ function sendFinanceError(res, what, err) { if (err instanceof AssignError || err instanceof InvoiceError) { + // Ein 502 an einer bestehenden Rechnung (nicht gesendet, nicht lesbar) hat seine Ursache bei Stripe: nur Art, Status und Code ins Protokoll + if (err.status === 502 && err.cause) logFailure(what, err.cause) const body = { error: err.message } if (err.invoiceId) body.invoiceId = err.invoiceId if (err.invoiceNumber !== undefined) body.invoiceNumber = err.invoiceNumber @@ -205,7 +208,7 @@ router.get('/rechnungen', async (req, res) => { }) router.post('/rechnungen', async (req, res) => { - const { customerId, items, daysUntilDue, memo, senden } = req.body || {} + const { customerId, items, daysUntilDue, memo, senden, idempotencyKey } = req.body || {} try { const rechnung = await createProjectInvoice({ customerId, @@ -214,6 +217,8 @@ router.post('/rechnungen', async (req, res) => { memo, // Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf senden: senden === true || senden === 'true', + // Optional: ein Schluessel je beabsichtigter Rechnung schuetzt vor doppeltem Anlegen (siehe createProjectInvoice) + idempotencyKey, createdBy: req.appwriteUser?.email, }) return res.json({ rechnung }) diff --git a/server/services/finance/overview.js b/server/services/finance/overview.js index 7f2a8f6..9b428b8 100644 --- a/server/services/finance/overview.js +++ b/server/services/finance/overview.js @@ -39,9 +39,28 @@ function berlinDayOf(iso) { // --- Stripe ------------------------------------------------------------------------------------------------- -/** Rechnungen und Auszahlungen; was nicht kommt, ist eine leere Liste und macht ok false. */ -async function loadStripe(now) { - const [invoices, payouts] = await Promise.allSettled([listInvoices(), listPayouts()]) +// So lange wartet die Uebersicht hoechstens auf Stripe (die Anfragen selbst haben eigene Zeitgrenzen, siehe stripeFinance) +const STRIPE_WAIT_MS = 20_000 + +const failedStatus = () => ({ ok: false, fehler: STRIPE_UNAVAILABLE_MESSAGE, abgerufenUm: null }) + +/** + * Rechnungen und Auszahlungen; was nicht kommt, ist eine leere Liste und macht ok false. Antwortet Stripe nicht binnen + * waitMs, rechnet die Uebersicht ohne Stripe weiter (wie bei einem Fehler); die Anfragen laufen im Hintergrund aus. + */ +async function loadStripe(now, waitMs) { + let timer + const timedOut = new Promise((resolve) => { + timer = setTimeout(resolve, waitMs, null) + timer.unref?.() + }) + const settled = await Promise.race([Promise.allSettled([listInvoices(), listPayouts()]), timedOut]) + clearTimeout(timer) + if (!settled) { + console.error(`[finanzen] Stripe laden fehlgeschlagen: keine Antwort nach ${waitMs} ms`) + return { ok: false, invoices: [], payouts: [], status: failedStatus() } + } + const [invoices, payouts] = settled if (invoices.status === 'rejected') logFailure('Rechnungen laden', invoices.reason) if (payouts.status === 'rejected') logFailure('Auszahlungen laden', payouts.reason) const ok = invoices.status === 'fulfilled' && payouts.status === 'fulfilled' @@ -50,7 +69,7 @@ async function loadStripe(now) { invoices: invoices.status === 'fulfilled' ? invoices.value : [], payouts: payouts.status === 'fulfilled' ? payouts.value : [], // abgerufenUm: wann der Stand geholt wurde; bei einem Fehler gibt es keinen - status: ok ? { ok: true, abgerufenUm: now.toISOString() } : { ok: false, fehler: STRIPE_UNAVAILABLE_MESSAGE, abgerufenUm: null }, + status: ok ? { ok: true, abgerufenUm: now.toISOString() } : failedStatus(), } } @@ -151,9 +170,10 @@ const isOpenIncoming = (tx) => tx.status === 'BOOK' && isCents(tx.amountCents) & /** * @param {object} [args] * @param {Date} [args.now] Zeitpunkt; "heute" ist der Berliner Tag dazu + * @param {number} [args.stripeWaitMs] so lange wartet die Uebersicht hoechstens auf Stripe (Voreinstellung 20 s) * @returns {Promise<{ stand, kopf, aufgaben, vorschau, steuer, abos, rechnungen }>} */ -export async function buildOverview({ now = new Date() } = {}) { +export async function buildOverview({ now = new Date(), stripeWaitMs = STRIPE_WAIT_MS } = {}) { const today = berlinDate(now) const [connection, transactions, settings, fixedCosts, subscriptions, stripe] = await Promise.all([ getConnection(), @@ -161,7 +181,7 @@ export async function buildOverview({ now = new Date() } = {}) { getSettings(), listFixedCosts(), listDocuments(config.collections.subscriptions, [Query.limit(SUBSCRIPTION_LIMIT)]), - loadStripe(now), + loadStripe(now, stripeWaitMs), ]) // Kundennamen nur für die Abos der Liste diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index d1269a1..afa1334 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto' import { config } from '../../config.js' import { getStripe, getOrCreateStripeCustomer, subscriptionIdOfInvoice } from '../stripeBilling.js' import { getDocument } from '../appwriteAdmin.js' @@ -18,6 +19,16 @@ const CACHE_MS = 60_000 const LOOKBACK_DAYS = 400 const MAX_INVOICES = 1000 +// Zeitgrenze der Leseanfragen an Stripe: das SDK wartet sonst je Anfrage bis zu 80 Sekunden (und wiederholt), bei bis +// zu zehn Seiten haengt die Seite minutenlang. Die Grenze gilt je Anfrage, auch fuer die Folgeseiten. +const DEFAULT_READ_OPTIONS = { timeout: 15_000, maxNetworkRetries: 1 } +let readOptions = DEFAULT_READ_OPTIONS + +/** Nur fuer Tests: andere Optionen fuer die Leseanfragen (null stellt die Voreinstellung wieder her). */ +export function setStripeReadOptionsForTests(options) { + readOptions = options ?? DEFAULT_READ_OPTIONS +} + /** Meldung an die Seite, wenn Stripe nicht antwortet (Übersicht, Rechnungsliste, Schreibzugriffe). */ export const STRIPE_UNAVAILABLE_MESSAGE = 'Stripe ist gerade nicht erreichbar.' @@ -81,22 +92,28 @@ export function mapInvoice(raw, { today }) { /** 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'], - }) + const raw = await cached('invoices', force, async () => { + const invoices = await getStripe() + .invoices.list( + { + limit: 100, + created: { gte: Math.floor(Date.now() / 1000) - LOOKBACK_DAYS * 86_400 }, + expand: ['data.customer'], + }, + readOptions + ) .autoPagingToArray({ limit: MAX_INVOICES }) - ) + // Bei der Obergrenze fehlen aeltere Rechnungen: Zaehler und Altersgruppen waeren zu niedrig, ohne dass es auffaellt + if (invoices.length >= MAX_INVOICES) console.warn(`[finanzen] Rechnungsliste bei ${MAX_INVOICES} abgeschnitten`) + return 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)) + const raw = await cached('payouts', force, () => getStripe().payouts.list({ limit: 50 }, readOptions).then((page) => page.data)) return raw.map((payout) => ({ id: payout.id, amountCents: payout.amount, @@ -120,6 +137,10 @@ const MAX_MEMO = 1000 const PAYMENT_REFERENCE_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnummer als Verwendungszweck an.' const ABO_MESSAGE = 'Abo-Rechnungen verwaltet Stripe.' const NO_EMAIL_MESSAGE = 'Für den Versand fehlt die E-Mail-Adresse des Kunden.' +const MAX_IDEMPOTENCY_KEY = 100 +const IN_PROGRESS_MESSAGE = 'Diese Rechnung wird gerade schon erstellt.' +// So lange nach einem Erfolg nimmt der Server dieselbe Rechnung (gleicher Inhalt) nicht noch einmal an +const DUPLICATE_WINDOW_MS = 30_000 /** * Fehler dieses Moduls mit HTTP-Status und einer Meldung, die die Seite anzeigen darf (nur diese gibt die Route @@ -200,16 +221,55 @@ async function readInvoice(invoiceId) { // Aufrufer sie erneut senden kann (sendInvoiceNow), statt eine zweite anzulegen. async function finalizeAndSend(stripe, invoiceId) { const finalized = await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false }) + const number = finalized.number || '' try { await stripe.invoices.sendInvoice(invoiceId) } catch (cause) { - const number = finalized.number || '' - const err = httpError(502, `Rechnung ${number} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`) - err.invoiceId = invoiceId - err.invoiceNumber = number - err.cause = cause - throw err + throw invoiceExistsError(`Rechnung ${number} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`, invoiceId, number, cause) } + return number +} + +/** Fehler 502 an einer Rechnung, die bei Stripe schon besteht: traegt invoiceId und invoiceNumber (und die Ursache) fuer die Seite. */ +function invoiceExistsError(message, invoiceId, number, cause) { + const err = httpError(502, message) + err.invoiceId = invoiceId + err.invoiceNumber = number + err.cause = cause + return err +} + +// Schutz vor doppeltem Anlegen (Doppelklick, Wiederholung nach Zeitueberschreitung): je Inhalt der Rechnung ein Eintrag +// { at, running }. Er blockiert, solange die Anlage laeuft und DUPLICATE_WINDOW_MS nach einem Erfolg; scheitert die +// Anlage, ohne dass eine Rechnung bei Stripe bleibt, wird er freigegeben. +const createGuard = new Map() + +/** Nur fuer Tests: den Schutz vor doppeltem Anlegen leeren. */ +export function resetInvoiceCreateGuardForTests() { + createGuard.clear() +} + +function claimCreate(fingerprint) { + const now = Date.now() + for (const [key, entry] of createGuard) { + if (!entry.running && now - entry.at >= DUPLICATE_WINDOW_MS) createGuard.delete(key) + } + if (createGuard.has(fingerprint)) throw httpError(409, IN_PROGRESS_MESSAGE) + createGuard.set(fingerprint, { at: now, running: true }) +} + +function releaseCreate(fingerprint, invoiceKept) { + if (invoiceKept) createGuard.set(fingerprint, { at: Date.now(), running: false }) + else createGuard.delete(fingerprint) +} + +/** Wiederholungsschluessel des Aufrufers: leer = keiner; sonst druckbare Zeichen ohne Leerzeichen, hoechstens 100. */ +function validateIdempotencyKey(key) { + if (key === undefined || key === null || key === '') return undefined + if (typeof key !== 'string' || key.length > MAX_IDEMPOTENCY_KEY || !/^[\x21-\x7e]+$/.test(key)) { + throw httpError(400, `Der Wiederholungsschlüssel ist ungültig (1 bis ${MAX_IDEMPOTENCY_KEY} Zeichen ohne Leerzeichen).`) + } + return key } /** @@ -219,35 +279,69 @@ async function finalizeAndSend(stripe, invoiceId) { * Entwurf. Fehler mit status 400 bei ungueltigen Eingaben (auch Summe ueber 99.999.999 Cent), einem Kunden, der noch * Lead/verloren ist, oder fehlender E-Mail-Adresse bei senden=true (alles vor dem ersten Stripe-Aufruf, ebenso ein * Fehler beim Lesen der Einstellungen). Scheitert nur das Senden nach dem Festschreiben: status 502 mit invoiceId und - * invoiceNumber (die offene Rechnung bleibt, erneut senden mit sendInvoiceNow). + * invoiceNumber (die offene Rechnung bleibt, erneut senden mit sendInvoiceNow); ebenso, wenn die fertige Rechnung nicht + * mehr gelesen werden kann (sie besteht dann trotzdem). + * + * Doppeltes Anlegen: idempotencyKey (optional, vom Aufrufer je beabsichtigter Rechnung, hoechstens 100 Zeichen) geht als + * Idempotency-Key an stripe.invoices.create, eine Wiederholung nach einer Zeitueberschreitung liefert dann denselben + * Entwurf. Nach einem Fehler, der den Entwurf wieder geloescht hat, braucht ein neuer Versuch einen neuen Schluessel. + * Zusaetzlich nimmt dieser Server dieselbe Rechnung (Kunde, Positionen, Vermerk, Zahlungsziel, senden) nicht an, solange + * sie angelegt wird und 30 Sekunden danach: status 409. */ -export async function createProjectInvoice({ customerId, items, daysUntilDue = DEFAULT_DAYS_UNTIL_DUE, memo = '', senden = false, createdBy }) { +export async function createProjectInvoice({ + customerId, + items, + daysUntilDue = DEFAULT_DAYS_UNTIL_DUE, + memo = '', + senden = false, + idempotencyKey, + createdBy, +}) { const lines = validateItems(items) if (!isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) { throw httpError(400, `Das Zahlungsziel muss eine ganze Zahl von 0 bis ${MAX_DAYS_UNTIL_DUE} Tagen sein.`) } const note = typeof memo === 'string' ? memo.trim() : '' if (note.length > MAX_MEMO) throw httpError(400, `Der Vermerk darf höchstens ${MAX_MEMO} Zeichen lang sein.`) + const key = validateIdempotencyKey(idempotencyKey) + + const fingerprint = createHash('sha256').update(JSON.stringify([customerId, lines, note, daysUntilDue, senden === true])).digest('hex') + claimCreate(fingerprint) + try { + const invoice = await createInvoice({ customerId, lines, daysUntilDue, note, senden: senden === true, key, createdBy }) + releaseCreate(fingerprint, true) + return invoice + } catch (err) { + // Eine Rechnung, die bei Stripe bleibt (festgeschrieben, aber nicht gesendet oder nicht lesbar), haelt den Schutz + releaseCreate(fingerprint, Boolean(err?.invoiceId)) + throw err + } +} + +async function createInvoice({ customerId, lines, daysUntilDue, note, senden, key, createdBy }) { const customer = await loadBillableCustomer(customerId) // Ohne Adresse lehnt Stripe das Senden erst nach dem Festschreiben ab: lieber vorab pruefen - if (senden === true && !String(customer.email || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE) + if (senden && !String(customer.email || '').trim()) throw httpError(400, NO_EMAIL_MESSAGE) const { bankDetailsText } = await getSettings() const stripe = getStripe() const stripeCustomerId = await getOrCreateStripeCustomer(customer) let invoiceId = null try { - const draft = await stripe.invoices.create({ - customer: stripeCustomerId, - collection_method: 'send_invoice', - days_until_due: daysUntilDue, - auto_advance: false, - pending_invoice_items_behavior: 'exclude', - currency: 'eur', - footer: invoiceFooter(bankDetailsText), - description: note || undefined, - metadata: { webklarCustomerId: customer.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500) }, - }) + const draft = await stripe.invoices.create( + { + customer: stripeCustomerId, + collection_method: 'send_invoice', + days_until_due: daysUntilDue, + auto_advance: false, + pending_invoice_items_behavior: 'exclude', + currency: 'eur', + footer: invoiceFooter(bankDetailsText), + description: note || undefined, + metadata: { webklarCustomerId: customer.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500) }, + }, + key ? { idempotencyKey: key } : undefined + ) invoiceId = draft.id // der Reihe nach, damit die Positionen in der eingegebenen Reihenfolge auf der Rechnung stehen for (const line of lines) { @@ -268,8 +362,14 @@ export async function createProjectInvoice({ customerId, items, daysUntilDue = D } try { - if (senden === true) await finalizeAndSend(stripe, invoiceId) - return await readInvoice(invoiceId) + const number = senden ? await finalizeAndSend(stripe, invoiceId) : '' + try { + return await readInvoice(invoiceId) + } catch (cause) { + // Die Rechnung besteht (und ist bei senden gesendet): die Seite bekommt ihre Kennung statt eines blanken Fehlers + const what = senden ? `Rechnung ${number} wurde gesendet` : 'Der Entwurf wurde angelegt' + throw invoiceExistsError(`${what}, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.`, invoiceId, number, cause) + } } finally { invalidateFinanceCache() } diff --git a/test/financeOverview.test.js b/test/financeOverview.test.js index e350b19..7441bdb 100644 --- a/test/financeOverview.test.js +++ b/test/financeOverview.test.js @@ -23,7 +23,7 @@ const { config } = await import('../server/config.js') const { seedFixtures, customers } = await import('./support/fixtures.js') const { startTestApp, get, post, rawGet } = await import('./support/testApp.js') const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js') -const { invalidateFinanceCache } = await import('../server/services/finance/stripeFinance.js') +const { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js') const { getStripe } = await import('../server/services/stripeBilling.js') const { addDays, berlinDate } = await import('../server/services/finance/dates.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') @@ -58,6 +58,7 @@ const unix = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1 async function reset() { mock.restoreAll() invalidateFinanceCache() + resetInvoiceCreateGuardForTests() st.calls.length = 0 st.invoices.clear() st.customers.clear() @@ -459,6 +460,33 @@ test('GET /uebersicht: Stripe 500 -> 200, Bankteil, Kopf und Aufgaben aus Bank u assert.equal(body.abos.monatlichCents, 7000) }) +test('buildOverview: antwortet Stripe nicht binnen der Wartezeit, rechnet die Uebersicht ohne Stripe weiter (I2)', async () => { + seedAll() + const error = mock.method(console, 'error', () => {}) + st.delayNext({ method: 'GET', path: '/v1/invoices', ms: 800 }) + const started = Date.now() + const body = await buildOverview({ stripeWaitMs: 100 }) + assert.ok(Date.now() - started < 600, `Uebersicht brauchte ${Date.now() - started} ms`) + assert.deepEqual(body.stand.stripe, { ok: false, fehler: 'Stripe ist gerade nicht erreichbar.', abgerufenUm: null }) + assert.equal(body.stand.bank.balanceBookedCents, 10893) + assert.equal(body.kopf.kontostandCents, 10893) + assert.deepEqual(body.rechnungen.altersgruppen.map((g) => g.anzahl), [0, 0, 0, 0]) + assert.ok(ART(body).includes('abo-zahlung')) + assert.equal(error.mock.callCount(), 1) + assert.match(error.mock.calls[0].arguments[0], /Stripe laden fehlgeschlagen: keine Antwort nach 100 ms/) + // spaeter antwortet Stripe wieder: die naechste Uebersicht ist vollstaendig + await new Promise((resolve) => setTimeout(resolve, 800)) + invalidateFinanceCache() + const later = await buildOverview({ stripeWaitMs: 5000 }) + assert.equal(later.stand.stripe.ok, true) +}) + +test('buildOverview: Stripe antwortet rechtzeitig -> ok, die Wartezeit haelt den Prozess nicht auf (I2)', async () => { + seedAll() + const body = await buildOverview({ stripeWaitMs: 5000 }) + assert.equal(body.stand.stripe.ok, true) +}) + test('GET /uebersicht: nur die Auszahlungen scheitern -> Rechnungen bleiben, Stripe-Block meldet den Fehler', async () => { seedAll() st.failNext({ method: 'GET', path: '/v1/payouts', status: 400, message: 'Geheimer Stripe-Text' }) @@ -706,6 +734,65 @@ test('POST /rechnungen: Senden scheitert nach dem Festschreiben -> 502 mit invoi assert.equal(again.body.rechnung.status, 'offen') }) +test('POST /rechnungen: idempotencyKey geht als Idempotency-Key an Stripe, ein ungueltiger -> 400 (I3)', async () => { + const res = await postInvoice({ customerId: 'c1', items: ITEMS, idempotencyKey: 'woms-formular-4711' }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(callsTo('POST', '/v1/invoices')[0].idempotencyKey, 'woms-formular-4711') + + for (const idempotencyKey of ['x'.repeat(101), 'mit Leerzeichen', 7]) { + const bad = await postInvoice({ customerId: 'c1', items: ITEMS, memo: 'anders', idempotencyKey }) + assert.equal(bad.status, 400, String(idempotencyKey)) + assert.equal(bad.body.error, 'Der Wiederholungsschlüssel ist ungültig (1 bis 100 Zeichen ohne Leerzeichen).') + } + assert.equal(callsTo('POST', '/v1/invoices').length, 1) +}) + +test('POST /rechnungen: derselbe Inhalt gleich noch einmal -> 409 "wird gerade schon erstellt", keine zweite Rechnung (I3)', async () => { + const body = { customerId: 'c1', items: ITEMS, senden: true } + const results = await Promise.all([postInvoice(body), postInvoice(body)]) + assert.deepEqual(results.map((r) => r.status).sort(), [200, 409]) + const conflict = results.find((r) => r.status === 409) + assert.deepEqual(conflict.body, { error: 'Diese Rechnung wird gerade schon erstellt.' }) + assert.equal(st.invoices.size, 1) + assert.equal(callsTo('POST', /\/send$/).length, 1) + + // auch einen Moment spaeter (Nutzer klickt noch einmal), aber nicht bei anderem Inhalt + assert.equal((await postInvoice(body)).status, 409) + assert.equal((await postInvoice({ ...body, memo: 'anderer Vermerk' })).status, 200) + assert.equal(st.invoices.size, 2) +}) + +test('POST /rechnungen: Rechnung nach dem Anlegen nicht lesbar -> 502 mit invoiceId und Nummer, Ursache im Protokoll (I3, M11)', async () => { + const error = mock.method(console, 'error', () => {}) + st.failNext({ method: 'GET', path: /^\/v1\/invoices\/in_/, status: 400, message: 'Geheimer Lesetext sk_live_abc' }) + const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden: true }) + assert.equal(res.status, 502) + assert.match(res.body.invoiceId, /^in_/) + assert.match(res.body.invoiceNumber, /^WK-\d{4}$/) + assert.equal(res.body.error, `Rechnung ${res.body.invoiceNumber} wurde gesendet, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.`) + assert.deepEqual(Object.keys(res.body).sort(), ['error', 'invoiceId', 'invoiceNumber']) + assert.ok(!JSON.stringify(res.body).includes('Geheimer')) + assert.equal(st.invoices.get(res.body.invoiceId).status, 'open') + assert.equal(error.mock.callCount(), 1) + assert.deepEqual(error.mock.calls[0].arguments, ['[finanzen] Rechnung anlegen fehlgeschlagen:', 'StripeInvalidRequestError', 400, '']) +}) + +test('POST /rechnungen: Senden scheitert (502) -> Art, Status und Code der Stripe-Ursache im Protokoll, nie deren Text (M11)', async () => { + const error = mock.method(console, 'error', () => {}) + st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Geheimer Sendetext sk_live_abc' }) + const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden: true }) + assert.equal(res.status, 502) + assert.equal(error.mock.callCount(), 1) + assert.deepEqual(error.mock.calls[0].arguments, ['[finanzen] Rechnung anlegen fehlgeschlagen:', 'StripeInvalidRequestError', 400, '']) + assert.ok(!JSON.stringify(error.mock.calls).includes('Geheimer')) +}) + +test('POST /rechnungen: eine Anlage ohne Rechnung bei Stripe (400) schreibt keine zweite Ursache ins Protokoll (M11)', async () => { + const error = mock.method(console, 'error', () => {}) + assert.equal((await postInvoice({ customerId: 'gibtsnicht', items: ITEMS })).status, 400) + assert.equal(error.mock.callCount(), 0) +}) + test('POST /rechnungen: Stripe lehnt ab (4xx) -> 409, ueberspringt Stripe-Text; Stripe nicht erreichbar -> 502', async () => { st.failNext({ method: 'POST', path: '/v1/invoices', status: 400, message: 'Geheimer Stripe-Text' }) const rejected = await postInvoice({ customerId: 'c1', items: ITEMS }) diff --git a/test/financeSettings.test.js b/test/financeSettings.test.js index ba74670..11eef04 100644 --- a/test/financeSettings.test.js +++ b/test/financeSettings.test.js @@ -21,7 +21,7 @@ const { seedFixtures } = await import('./support/fixtures.js') const { startTestApp, get, post, rawGet } = await import('./support/testApp.js') const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js') const settings = await import('../server/services/finance/settings.js') -const { createProjectInvoice, invalidateFinanceCache } = await import('../server/services/finance/stripeFinance.js') +const { createProjectInvoice, invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const app = await startTestApp({ '/api/admin/finanzen': financeRoutes }) @@ -49,6 +49,7 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ async function reset() { mock.restoreAll() invalidateFinanceCache() + resetInvoiceCreateGuardForTests() st.calls.length = 0 st.invoices.clear() st.customers.clear() @@ -568,8 +569,9 @@ test('Rechnung: nach PATCH { bankDetailsText } steht die IBAN-Zeile im Rechnungs `${config.stripe.invoiceFooter}\nIBAN DE00 0000 0000 0000 0000 00\nBIC TESTDEFF\n${PAYMENT_NOTE}` ) - // wieder leer: der Fuss hat keine Bankzeile mehr + // wieder leer: der Fuss hat keine Bankzeile mehr (derselbe Inhalt wie eben: den Schutz vor doppeltem Anlegen zuruecksetzen) assert.equal((await patchSettings({ bankDetailsText: '' })).status, 200) + resetInvoiceCreateGuardForTests() await createProjectInvoice({ customerId: 'c1', items: ITEMS, createdBy: 'kenso@example.invalid' }) assert.equal(footerOfLastDraft(), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`) }) diff --git a/test/financeStripeRead.test.js b/test/financeStripeRead.test.js index 12c3cd7..f0996f5 100644 --- a/test/financeStripeRead.test.js +++ b/test/financeStripeRead.test.js @@ -10,13 +10,18 @@ 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') +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 @@ -212,10 +217,66 @@ test('listInvoices: blaettert ueber alle Seiten', async () => { assert.equal(st.calls.filter((c) => c.path === '/v1/invoices').length, 3) }) -test('listInvoices: hoechstens 1000 Rechnungen', async () => { +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 () => { diff --git a/test/financeStripeWrite.test.js b/test/financeStripeWrite.test.js index 6462e10..a5e0065 100644 --- a/test/financeStripeWrite.test.js +++ b/test/financeStripeWrite.test.js @@ -1,4 +1,4 @@ -import { test, after, beforeEach } from 'node:test' +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' @@ -14,7 +14,7 @@ const { seedFixtures } = await import('./support/fixtures.js') const { berlinDate } = await import('../server/services/finance/dates.js') const { getStripe } = await import('../server/services/stripeBilling.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') -const { invoiceFooter, createProjectInvoice, sendInvoiceNow, voidInvoice, deleteDraft, listInvoices, invalidateFinanceCache } = await import( +const { invoiceFooter, createProjectInvoice, sendInvoiceNow, voidInvoice, deleteDraft, listInvoices, invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import( '../server/services/finance/stripeFinance.js' ) @@ -37,7 +37,9 @@ const BASE_ITEMS = [ ] beforeEach(() => { + mock.restoreAll() invalidateFinanceCache() + resetInvoiceCreateGuardForTests() st.calls.length = 0 st.invoices.clear() st.customers.clear() @@ -418,6 +420,8 @@ test('Schreibzugriffe leeren den Zwischenspeicher: die Liste zeigt neue, gesende await voidInvoice(draft.id) assert.equal((await listInvoices())[0].status, 'storniert') + // gleicher Inhalt wie der erste Entwurf: der Schutz vor doppeltem Anlegen waere noch aktiv + resetInvoiceCreateGuardForTests() const second = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' }) assert.equal((await listInvoices()).length, 2) await deleteDraft(second.id) @@ -480,3 +484,125 @@ test('Stripe-Nachbau: Statusuebergaenge wie Stripe (finalize nur Entwurf, send/v assert.equal(removed.deleted, true) assert.equal(st.invoices.has(third.id), false) }) + +// ---------- Doppeltes Anlegen (I3) ---------- + +const createdCalls = () => callsTo('POST', '/v1/invoices') + +test('createProjectInvoice: idempotencyKey geht als Idempotency-Key nur an die Anlage der Rechnung (I3)', async () => { + await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey: 'woms-formular-4711', createdBy: 'k@example.invalid' }) + const [create] = createdCalls() + assert.equal(create.idempotencyKey, 'woms-formular-4711') + // die Posten tragen nicht den Schluessel des Aufrufers (stripe-node vergibt sonst eigene, zufaellige) + assert.ok(callsTo('POST', '/v1/invoiceitems').every((call) => call.idempotencyKey !== 'woms-formular-4711')) + + // ohne Schluessel (oder leer / null) nimmt stripe-node einen eigenen, zufaelligen: nie den des Aufrufers + st.calls.length = 0 + for (const idempotencyKey of [undefined, null, '']) { + resetInvoiceCreateGuardForTests() + await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey }) + } + assert.equal(createdCalls().length, 3) + assert.equal(new Set(createdCalls().map((call) => call.idempotencyKey)).size, 3, 'jedes Mal ein anderer') +}) + +test('createProjectInvoice: ungueltiger Wiederholungsschluessel -> 400, nichts bei Stripe (I3)', async () => { + for (const idempotencyKey of ['x'.repeat(101), 'mit Leerzeichen', 'Zeilen\numbruch', 42, {}]) { + await rejectsWith( + createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey }), + 400, + 'Der Wiederholungsschlüssel ist ungültig (1 bis 100 Zeichen ohne Leerzeichen).' + ) + } + assert.equal(st.calls.length, 0) + // genau 100 Zeichen sind erlaubt + await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, idempotencyKey: 'k'.repeat(100) }) + assert.equal(createdCalls()[0].idempotencyKey, 'k'.repeat(100)) +}) + +test('createProjectInvoice: zwei gleichzeitige gleiche Aufrufe legen nur eine Rechnung an, der zweite bekommt 409 (I3)', async () => { + const args = { customerId: 'c1', items: BASE_ITEMS, memo: 'Doppelklick', senden: true } + const results = await Promise.allSettled([createProjectInvoice(args), createProjectInvoice(args)]) + assert.deepEqual(results.map((r) => r.status), ['fulfilled', 'rejected']) + await rejectsWith(Promise.reject(results[1].reason), 409, 'Diese Rechnung wird gerade schon erstellt.') + assert.equal(st.invoices.size, 1) + assert.equal(createdCalls().length, 1) + assert.equal(callsTo('POST', /\/send$/).length, 1, 'nur eine Mail') +}) + +test('createProjectInvoice: 30 Sekunden nach einem Erfolg 409, danach wieder erlaubt; anderer Inhalt ist nie betroffen (I3)', async () => { + const args = { customerId: 'c1', items: BASE_ITEMS } + await createProjectInvoice(args) + await rejectsWith(createProjectInvoice(args), 409, 'Diese Rechnung wird gerade schon erstellt.') + assert.equal(st.invoices.size, 1) + + // anderer Inhalt: anderer Vermerk, anderes Zahlungsziel, senden, anderer Kunde, andere Positionen + await createProjectInvoice({ ...args, memo: 'anders' }) + await createProjectInvoice({ ...args, daysUntilDue: 30 }) + await createProjectInvoice({ ...args, senden: true }) + await createProjectInvoice({ customerId: 'act1', items: BASE_ITEMS }) + await createProjectInvoice({ customerId: 'c1', items: [{ ...BASE_ITEMS[0], quantity: 3 }] }) + assert.equal(st.invoices.size, 6) + + // nach 30 Sekunden gilt dieselbe Rechnung wieder als neu + const real = Date.now + mock.method(Date, 'now', () => real() + 29_000) + await rejectsWith(createProjectInvoice(args), 409) + mock.method(Date, 'now', () => real() + 31_000) + await createProjectInvoice(args) + assert.equal(st.invoices.size, 7) +}) + +test('createProjectInvoice: scheitert die Anlage und bleibt keine Rechnung, ist der Schutz frei (I3)', async () => { + const args = { customerId: 'c1', items: BASE_ITEMS } + st.failNext({ method: 'POST', path: '/v1/invoices', status: 400, message: 'abgelehnt' }) + await assert.rejects(createProjectInvoice(args)) + // Ungueltiger Kunde zaehlt ebenso als gescheitert + await rejectsWith(createProjectInvoice({ customerId: 'lead1', items: BASE_ITEMS }), 400) + await rejectsWith(createProjectInvoice({ customerId: 'lead1', items: BASE_ITEMS }), 400) + // Posten scheitert: Entwurf wird geloescht, der Schutz freigegeben + st.failNext({ method: 'POST', path: '/v1/invoiceitems', status: 400, message: 'abgelehnt' }) + await assert.rejects(createProjectInvoice(args)) + assert.equal(st.invoices.size, 0) + await createProjectInvoice(args) + assert.equal(st.invoices.size, 1) +}) + +test('createProjectInvoice: Senden scheitert nach dem Festschreiben -> Rechnung besteht, der Schutz bleibt (I3)', async () => { + const args = { customerId: 'c1', items: BASE_ITEMS, senden: true } + st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Mailversand abgelehnt' }) + await assert.rejects(createProjectInvoice(args), (err) => err.status === 502 && /^in_/.test(err.invoiceId)) + await rejectsWith(createProjectInvoice(args), 409, 'Diese Rechnung wird gerade schon erstellt.') + assert.equal(st.invoices.size, 1) +}) + +test('createProjectInvoice: Rechnung nach dem Senden nicht lesbar -> 502 mit invoiceId und Nummer statt blankem Fehler (I3)', async () => { + const args = { customerId: 'c1', items: BASE_ITEMS, senden: true } + st.failNext({ method: 'GET', path: /^\/v1\/invoices\/in_/, status: 400, message: 'Lesefehler 10.0.0.7' }) + let caught + await assert.rejects(createProjectInvoice(args), (err) => { + caught = err + return true + }) + assert.equal(caught.status, 502) + assert.equal(caught.name, 'InvoiceError') + assert.match(caught.invoiceId, /^in_/) + assert.match(caught.invoiceNumber, /^WK-\d{4}$/) + assert.equal(caught.message, `Rechnung ${caught.invoiceNumber} wurde gesendet, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.`) + assert.ok(!caught.message.includes('10.0.0.7')) + assert.ok(caught.cause, 'die Ursache bleibt fuers Protokoll') + assert.equal(st.invoices.get(caught.invoiceId).status, 'open') + assert.equal(callsTo('POST', /\/send$/).length, 1) + // die Rechnung besteht: ein zweiter Versuch mit gleichem Inhalt wird abgewiesen + await rejectsWith(createProjectInvoice(args), 409) + + // Entwurf: ohne Nummer, andere Meldung + resetInvoiceCreateGuardForTests() + st.failNext({ method: 'GET', path: /^\/v1\/invoices\/in_/, status: 400 }) + await assert.rejects(createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS }), (err) => { + assert.equal(err.status, 502) + assert.equal(err.invoiceNumber, '') + assert.equal(err.message, 'Der Entwurf wurde angelegt, konnte aber nicht gelesen werden. Bitte die Rechnungsliste prüfen.') + return true + }) +}) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index 34a20d1..2acc8bf 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -25,7 +25,8 @@ import http from 'node:http' * .../send (nur open), .../void (nur open -> void), .../pay (nur open -> paid, auch paid_out_of_band; wie die echte API * liefert die Rechnung danach kein Feld paid_out_of_band, nur der Aufruf in calls zeigt es) und * DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400. Der Kunde einer neuen - * Rechnung muss nicht in st.customers stehen (dann bleiben Name und E-Mail leer). + * Rechnung muss nicht in st.customers stehen (dann bleiben Name und E-Mail leer). Traegt ein Aufruf die Kopfzeile + * Idempotency-Key, steht sie als idempotencyKey in calls; delayNext verzoegert eine Antwort (Zeitgrenzen testen). */ // Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/ @@ -58,6 +59,8 @@ export async function startFakeStripe() { let invoicePreview = null // Eingeplanter Fehler (failNext, F41d): der naechste passende Aufruf scheitert einmalig let pendingFailure = null + // Eingeplante Verzoegerung (delayNext): die Antwort des naechsten passenden Aufrufs kommt erst nach ms + let pendingDelay = null const nextId = (prefix) => `${prefix}_test_${(seq += 1)}` const matchesPath = (path, pathname) => (path instanceof RegExp ? path.test(pathname) : path === pathname) @@ -74,6 +77,19 @@ export async function startFakeStripe() { return [status, { error: { type: status >= 500 ? 'api_error' : 'invalid_request_error', message: message || `Nachbau: eingeplanter Fehler ${status}` } }] } + const plannedDelay = (method, pathname) => { + if (!pendingDelay) return 0 + if (pendingDelay.method && pendingDelay.method !== method) return 0 + if (pendingDelay.path && !matchesPath(pendingDelay.path, pathname)) return 0 + if (pendingDelay.after > 0) { + pendingDelay.after -= 1 + return 0 + } + const { ms } = pendingDelay + pendingDelay = null + return ms + } + const flat = (params) => Object.fromEntries(params.entries()) const noSuch = (what, id) => [404, { error: { type: 'invalid_request_error', code: 'resource_missing', message: `No such ${what}: '${id}'` } }] const invalid = (param, message) => [400, { error: { type: 'invalid_request_error', param, message } }] @@ -572,7 +588,11 @@ export async function startFakeStripe() { const server = http.createServer(async (req, res) => { const url = new URL(req.url, 'http://127.0.0.1') const body = await readBody(req) - calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body) }) + // idempotencyKey (Kopfzeile Idempotency-Key) steht nur an Aufrufen, die einen mitschicken + const idempotencyKey = req.headers['idempotency-key'] + calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body), ...(idempotencyKey ? { idempotencyKey } : {}) }) + const delay = plannedDelay(req.method, url.pathname) + if (delay) await new Promise((resolve) => setTimeout(resolve, delay)) const [status, payload] = plannedFailure(req.method, url.pathname) || route(req.method, url.pathname, url.searchParams, body) res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` }) res.end(JSON.stringify(payload)) @@ -633,6 +653,13 @@ export async function startFakeStripe() { failNext: ({ method = null, path = null, status = 500, message = '', after = 0 }) => { pendingFailure = { method, path, status, message, after } }, + /** + * Die Antwort auf den naechsten passenden Aufruf (method, path als String oder RegExp) kommt erst nach ms + * Millisekunden; after passende Aufrufe gehen vorher ohne Verzoegerung durch. Der Aufruf steht sofort in calls. + */ + delayNext: ({ method = null, path = null, ms, after = 0 }) => { + pendingDelay = { method, path, ms, after } + }, close: () => new Promise((resolve) => server.close(resolve)), } }