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

+29 -2
View File
@@ -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)),
}
}