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
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user