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:
1 parent
ef2ce49669
commit
daa451094a
8 files changed
+476
-48
No files matched your search
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user