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
+460
-32
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('$')))
|
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). */
|
/** 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) => {
|
router.get('/uebersicht', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -148,6 +149,8 @@ const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsW
|
|||||||
*/
|
*/
|
||||||
function sendFinanceError(res, what, err) {
|
function sendFinanceError(res, what, err) {
|
||||||
if (err instanceof AssignError || err instanceof InvoiceError) {
|
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 }
|
const body = { error: err.message }
|
||||||
if (err.invoiceId) body.invoiceId = err.invoiceId
|
if (err.invoiceId) body.invoiceId = err.invoiceId
|
||||||
if (err.invoiceNumber !== undefined) body.invoiceNumber = err.invoiceNumber
|
if (err.invoiceNumber !== undefined) body.invoiceNumber = err.invoiceNumber
|
||||||
@@ -205,7 +208,7 @@ router.get('/rechnungen', async (req, res) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.post('/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 {
|
try {
|
||||||
const rechnung = await createProjectInvoice({
|
const rechnung = await createProjectInvoice({
|
||||||
customerId,
|
customerId,
|
||||||
@@ -214,6 +217,8 @@ router.post('/rechnungen', async (req, res) => {
|
|||||||
memo,
|
memo,
|
||||||
// Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf
|
// Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf
|
||||||
senden: senden === true || senden === 'true',
|
senden: senden === true || senden === 'true',
|
||||||
|
// Optional: ein Schluessel je beabsichtigter Rechnung schuetzt vor doppeltem Anlegen (siehe createProjectInvoice)
|
||||||
|
idempotencyKey,
|
||||||
createdBy: req.appwriteUser?.email,
|
createdBy: req.appwriteUser?.email,
|
||||||
})
|
})
|
||||||
return res.json({ rechnung })
|
return res.json({ rechnung })
|
||||||
|
|||||||
@@ -39,9 +39,28 @@ function berlinDayOf(iso) {
|
|||||||
|
|
||||||
// --- Stripe -------------------------------------------------------------------------------------------------
|
// --- Stripe -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Rechnungen und Auszahlungen; was nicht kommt, ist eine leere Liste und macht ok false. */
|
// So lange wartet die Uebersicht hoechstens auf Stripe (die Anfragen selbst haben eigene Zeitgrenzen, siehe stripeFinance)
|
||||||
async function loadStripe(now) {
|
const STRIPE_WAIT_MS = 20_000
|
||||||
const [invoices, payouts] = await Promise.allSettled([listInvoices(), listPayouts()])
|
|
||||||
|
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 (invoices.status === 'rejected') logFailure('Rechnungen laden', invoices.reason)
|
||||||
if (payouts.status === 'rejected') logFailure('Auszahlungen laden', payouts.reason)
|
if (payouts.status === 'rejected') logFailure('Auszahlungen laden', payouts.reason)
|
||||||
const ok = invoices.status === 'fulfilled' && payouts.status === 'fulfilled'
|
const ok = invoices.status === 'fulfilled' && payouts.status === 'fulfilled'
|
||||||
@@ -50,7 +69,7 @@ async function loadStripe(now) {
|
|||||||
invoices: invoices.status === 'fulfilled' ? invoices.value : [],
|
invoices: invoices.status === 'fulfilled' ? invoices.value : [],
|
||||||
payouts: payouts.status === 'fulfilled' ? payouts.value : [],
|
payouts: payouts.status === 'fulfilled' ? payouts.value : [],
|
||||||
// abgerufenUm: wann der Stand geholt wurde; bei einem Fehler gibt es keinen
|
// 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 {object} [args]
|
||||||
* @param {Date} [args.now] Zeitpunkt; "heute" ist der Berliner Tag dazu
|
* @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 }>}
|
* @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 today = berlinDate(now)
|
||||||
const [connection, transactions, settings, fixedCosts, subscriptions, stripe] = await Promise.all([
|
const [connection, transactions, settings, fixedCosts, subscriptions, stripe] = await Promise.all([
|
||||||
getConnection(),
|
getConnection(),
|
||||||
@@ -161,7 +181,7 @@ export async function buildOverview({ now = new Date() } = {}) {
|
|||||||
getSettings(),
|
getSettings(),
|
||||||
listFixedCosts(),
|
listFixedCosts(),
|
||||||
listDocuments(config.collections.subscriptions, [Query.limit(SUBSCRIPTION_LIMIT)]),
|
listDocuments(config.collections.subscriptions, [Query.limit(SUBSCRIPTION_LIMIT)]),
|
||||||
loadStripe(now),
|
loadStripe(now, stripeWaitMs),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Kundennamen nur für die Abos der Liste
|
// Kundennamen nur für die Abos der Liste
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
import { config } from '../../config.js'
|
import { config } from '../../config.js'
|
||||||
import { getStripe, getOrCreateStripeCustomer, subscriptionIdOfInvoice } from '../stripeBilling.js'
|
import { getStripe, getOrCreateStripeCustomer, subscriptionIdOfInvoice } from '../stripeBilling.js'
|
||||||
import { getDocument } from '../appwriteAdmin.js'
|
import { getDocument } from '../appwriteAdmin.js'
|
||||||
@@ -18,6 +19,16 @@ const CACHE_MS = 60_000
|
|||||||
const LOOKBACK_DAYS = 400
|
const LOOKBACK_DAYS = 400
|
||||||
const MAX_INVOICES = 1000
|
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). */
|
/** Meldung an die Seite, wenn Stripe nicht antwortet (Übersicht, Rechnungsliste, Schreibzugriffe). */
|
||||||
export const STRIPE_UNAVAILABLE_MESSAGE = 'Stripe ist gerade nicht erreichbar.'
|
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. */
|
/** Rechnungen der letzten 400 Tage (hoechstens 1000, neueste zuerst), mit Kunde, auf Deutsch gemappt. */
|
||||||
export async function listInvoices({ force = false } = {}) {
|
export async function listInvoices({ force = false } = {}) {
|
||||||
const raw = await cached('invoices', force, () =>
|
const raw = await cached('invoices', force, async () => {
|
||||||
getStripe()
|
const invoices = await getStripe()
|
||||||
.invoices.list({
|
.invoices.list(
|
||||||
|
{
|
||||||
limit: 100,
|
limit: 100,
|
||||||
created: { gte: Math.floor(Date.now() / 1000) - LOOKBACK_DAYS * 86_400 },
|
created: { gte: Math.floor(Date.now() / 1000) - LOOKBACK_DAYS * 86_400 },
|
||||||
expand: ['data.customer'],
|
expand: ['data.customer'],
|
||||||
})
|
},
|
||||||
.autoPagingToArray({ limit: MAX_INVOICES })
|
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()
|
const today = berlinDate()
|
||||||
return raw.map((invoice) => mapInvoice(invoice, { today }))
|
return raw.map((invoice) => mapInvoice(invoice, { today }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Die letzten 50 Stripe-Auszahlungen: { id, amountCents, arrivalDate, status }. */
|
/** Die letzten 50 Stripe-Auszahlungen: { id, amountCents, arrivalDate, status }. */
|
||||||
export async function listPayouts({ force = false } = {}) {
|
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) => ({
|
return raw.map((payout) => ({
|
||||||
id: payout.id,
|
id: payout.id,
|
||||||
amountCents: payout.amount,
|
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 PAYMENT_REFERENCE_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnummer als Verwendungszweck an.'
|
||||||
const ABO_MESSAGE = 'Abo-Rechnungen verwaltet Stripe.'
|
const ABO_MESSAGE = 'Abo-Rechnungen verwaltet Stripe.'
|
||||||
const NO_EMAIL_MESSAGE = 'Für den Versand fehlt die E-Mail-Adresse des Kunden.'
|
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
|
* 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.
|
// Aufrufer sie erneut senden kann (sendInvoiceNow), statt eine zweite anzulegen.
|
||||||
async function finalizeAndSend(stripe, invoiceId) {
|
async function finalizeAndSend(stripe, invoiceId) {
|
||||||
const finalized = await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false })
|
const finalized = await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false })
|
||||||
|
const number = finalized.number || ''
|
||||||
try {
|
try {
|
||||||
await stripe.invoices.sendInvoice(invoiceId)
|
await stripe.invoices.sendInvoice(invoiceId)
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
const number = finalized.number || ''
|
throw invoiceExistsError(`Rechnung ${number} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`, invoiceId, number, cause)
|
||||||
const err = httpError(502, `Rechnung ${number} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`)
|
}
|
||||||
|
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.invoiceId = invoiceId
|
||||||
err.invoiceNumber = number
|
err.invoiceNumber = number
|
||||||
err.cause = cause
|
err.cause = cause
|
||||||
throw err
|
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,25 +279,57 @@ async function finalizeAndSend(stripe, invoiceId) {
|
|||||||
* Entwurf. Fehler mit status 400 bei ungueltigen Eingaben (auch Summe ueber 99.999.999 Cent), einem Kunden, der noch
|
* 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
|
* 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
|
* 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)
|
const lines = validateItems(items)
|
||||||
if (!isWholeNumberBetween(daysUntilDue, 0, MAX_DAYS_UNTIL_DUE)) {
|
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.`)
|
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() : ''
|
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.`)
|
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)
|
const customer = await loadBillableCustomer(customerId)
|
||||||
// Ohne Adresse lehnt Stripe das Senden erst nach dem Festschreiben ab: lieber vorab pruefen
|
// 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 { bankDetailsText } = await getSettings()
|
||||||
const stripe = getStripe()
|
const stripe = getStripe()
|
||||||
const stripeCustomerId = await getOrCreateStripeCustomer(customer)
|
const stripeCustomerId = await getOrCreateStripeCustomer(customer)
|
||||||
let invoiceId = null
|
let invoiceId = null
|
||||||
try {
|
try {
|
||||||
const draft = await stripe.invoices.create({
|
const draft = await stripe.invoices.create(
|
||||||
|
{
|
||||||
customer: stripeCustomerId,
|
customer: stripeCustomerId,
|
||||||
collection_method: 'send_invoice',
|
collection_method: 'send_invoice',
|
||||||
days_until_due: daysUntilDue,
|
days_until_due: daysUntilDue,
|
||||||
@@ -247,7 +339,9 @@ export async function createProjectInvoice({ customerId, items, daysUntilDue = D
|
|||||||
footer: invoiceFooter(bankDetailsText),
|
footer: invoiceFooter(bankDetailsText),
|
||||||
description: note || undefined,
|
description: note || undefined,
|
||||||
metadata: { webklarCustomerId: customer.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500) },
|
metadata: { webklarCustomerId: customer.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500) },
|
||||||
})
|
},
|
||||||
|
key ? { idempotencyKey: key } : undefined
|
||||||
|
)
|
||||||
invoiceId = draft.id
|
invoiceId = draft.id
|
||||||
// der Reihe nach, damit die Positionen in der eingegebenen Reihenfolge auf der Rechnung stehen
|
// der Reihe nach, damit die Positionen in der eingegebenen Reihenfolge auf der Rechnung stehen
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
@@ -268,8 +362,14 @@ export async function createProjectInvoice({ customerId, items, daysUntilDue = D
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (senden === true) await finalizeAndSend(stripe, invoiceId)
|
const number = senden ? await finalizeAndSend(stripe, invoiceId) : ''
|
||||||
|
try {
|
||||||
return await readInvoice(invoiceId)
|
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 {
|
} finally {
|
||||||
invalidateFinanceCache()
|
invalidateFinanceCache()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const { config } = await import('../server/config.js')
|
|||||||
const { seedFixtures, customers } = await import('./support/fixtures.js')
|
const { seedFixtures, customers } = await import('./support/fixtures.js')
|
||||||
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
|
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
|
||||||
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.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 { getStripe } = await import('../server/services/stripeBilling.js')
|
||||||
const { addDays, berlinDate } = await import('../server/services/finance/dates.js')
|
const { addDays, berlinDate } = await import('../server/services/finance/dates.js')
|
||||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.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() {
|
async function reset() {
|
||||||
mock.restoreAll()
|
mock.restoreAll()
|
||||||
invalidateFinanceCache()
|
invalidateFinanceCache()
|
||||||
|
resetInvoiceCreateGuardForTests()
|
||||||
st.calls.length = 0
|
st.calls.length = 0
|
||||||
st.invoices.clear()
|
st.invoices.clear()
|
||||||
st.customers.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)
|
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 () => {
|
test('GET /uebersicht: nur die Auszahlungen scheitern -> Rechnungen bleiben, Stripe-Block meldet den Fehler', async () => {
|
||||||
seedAll()
|
seedAll()
|
||||||
st.failNext({ method: 'GET', path: '/v1/payouts', status: 400, message: 'Geheimer Stripe-Text' })
|
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')
|
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 () => {
|
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' })
|
st.failNext({ method: 'POST', path: '/v1/invoices', status: 400, message: 'Geheimer Stripe-Text' })
|
||||||
const rejected = await postInvoice({ customerId: 'c1', items: ITEMS })
|
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 { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
|
||||||
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
|
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
|
||||||
const settings = await import('../server/services/finance/settings.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 { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||||
|
|
||||||
const app = await startTestApp({ '/api/admin/finanzen': financeRoutes })
|
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() {
|
async function reset() {
|
||||||
mock.restoreAll()
|
mock.restoreAll()
|
||||||
invalidateFinanceCache()
|
invalidateFinanceCache()
|
||||||
|
resetInvoiceCreateGuardForTests()
|
||||||
st.calls.length = 0
|
st.calls.length = 0
|
||||||
st.invoices.clear()
|
st.invoices.clear()
|
||||||
st.customers.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}`
|
`${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)
|
assert.equal((await patchSettings({ bankDetailsText: '' })).status, 200)
|
||||||
|
resetInvoiceCreateGuardForTests()
|
||||||
await createProjectInvoice({ customerId: 'c1', items: ITEMS, createdBy: 'kenso@example.invalid' })
|
await createProjectInvoice({ customerId: 'c1', items: ITEMS, createdBy: 'kenso@example.invalid' })
|
||||||
assert.equal(footerOfLastDraft(), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
|
assert.equal(footerOfLastDraft(), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,13 +10,18 @@ const aw = await startAppwriteMock({ schema: {} })
|
|||||||
const st = await startFakeStripe()
|
const st = await startFakeStripe()
|
||||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||||
const { euroDe } = await import('../server/services/finance/format.js')
|
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(() => {
|
after(() => {
|
||||||
mock.timers.reset()
|
mock.timers.reset()
|
||||||
return Promise.all([aw.close(), st.close()])
|
return Promise.all([aw.close(), st.close()])
|
||||||
})
|
})
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mock.restoreAll()
|
||||||
|
setStripeReadOptionsForTests(null)
|
||||||
mock.timers.reset()
|
mock.timers.reset()
|
||||||
invalidateFinanceCache()
|
invalidateFinanceCache()
|
||||||
st.calls.length = 0
|
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)
|
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}` }))
|
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()
|
const list = await listInvoices()
|
||||||
assert.equal(list.length, 1000)
|
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 () => {
|
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 assert from 'node:assert/strict'
|
||||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||||
import { startFakeStripe } from './support/fakeStripe.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 { berlinDate } = await import('../server/services/finance/dates.js')
|
||||||
const { getStripe } = await import('../server/services/stripeBilling.js')
|
const { getStripe } = await import('../server/services/stripeBilling.js')
|
||||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.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'
|
'../server/services/finance/stripeFinance.js'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,7 +37,9 @@ const BASE_ITEMS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mock.restoreAll()
|
||||||
invalidateFinanceCache()
|
invalidateFinanceCache()
|
||||||
|
resetInvoiceCreateGuardForTests()
|
||||||
st.calls.length = 0
|
st.calls.length = 0
|
||||||
st.invoices.clear()
|
st.invoices.clear()
|
||||||
st.customers.clear()
|
st.customers.clear()
|
||||||
@@ -418,6 +420,8 @@ test('Schreibzugriffe leeren den Zwischenspeicher: die Liste zeigt neue, gesende
|
|||||||
await voidInvoice(draft.id)
|
await voidInvoice(draft.id)
|
||||||
assert.equal((await listInvoices())[0].status, 'storniert')
|
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' })
|
const second = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' })
|
||||||
assert.equal((await listInvoices()).length, 2)
|
assert.equal((await listInvoices()).length, 2)
|
||||||
await deleteDraft(second.id)
|
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(removed.deleted, true)
|
||||||
assert.equal(st.invoices.has(third.id), false)
|
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
|
* .../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
|
* 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
|
* 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/
|
// Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/
|
||||||
@@ -58,6 +59,8 @@ export async function startFakeStripe() {
|
|||||||
let invoicePreview = null
|
let invoicePreview = null
|
||||||
// Eingeplanter Fehler (failNext, F41d): der naechste passende Aufruf scheitert einmalig
|
// Eingeplanter Fehler (failNext, F41d): der naechste passende Aufruf scheitert einmalig
|
||||||
let pendingFailure = null
|
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 nextId = (prefix) => `${prefix}_test_${(seq += 1)}`
|
||||||
|
|
||||||
const matchesPath = (path, pathname) => (path instanceof RegExp ? path.test(pathname) : path === pathname)
|
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}` } }]
|
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 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 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 } }]
|
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 server = http.createServer(async (req, res) => {
|
||||||
const url = new URL(req.url, 'http://127.0.0.1')
|
const url = new URL(req.url, 'http://127.0.0.1')
|
||||||
const body = await readBody(req)
|
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)
|
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.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` })
|
||||||
res.end(JSON.stringify(payload))
|
res.end(JSON.stringify(payload))
|
||||||
@@ -633,6 +653,13 @@ export async function startFakeStripe() {
|
|||||||
failNext: ({ method = null, path = null, status = 500, message = '', after = 0 }) => {
|
failNext: ({ method = null, path = null, status = 500, message = '', after = 0 }) => {
|
||||||
pendingFailure = { method, path, status, message, after }
|
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)),
|
close: () => new Promise((resolve) => server.close(resolve)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user