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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:42:06 +00:00
1 parent ef2ce49669
commit daa451094a
8 files changed
+476 -48

No files matched your search

+88 -1
View File
@@ -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 })