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

+26 -6
View File
@@ -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
+131 -31
View File
@@ -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()
}