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

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