Finanzen: Stripe-Rechnungen und Auszahlungen lesen (deutsch gemappt, 60-s-Cache)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6af9834293
commit
db243f50bb
4 files changed
+441
No files matched your search
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Anzeigeformate fuer die Finanzen. Betraege sind immer ganze Cent (Ganzzahl), nie Gleitkomma-Euro.
|
||||
*/
|
||||
|
||||
// Einmal anlegen (neu anlegen ist teuer); de-DE liefert "1.234,56 €" mit geschuetztem Leerzeichen
|
||||
const euroFormat = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })
|
||||
|
||||
/** Cent als deutscher Euro-Betrag, z. B. 123456 -> "1.234,56 €", -449 -> "−4,49 €" (echtes Minuszeichen U+2212). */
|
||||
export function euroDe(cents) {
|
||||
if (!Number.isSafeInteger(cents)) throw new Error('Betrag muss eine ganze Zahl in Cent sein.')
|
||||
const text = euroFormat.format(Math.abs(cents) / 100)
|
||||
return cents < 0 ? `−${text}` : text
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { getStripe, subscriptionIdOfInvoice } from '../stripeBilling.js'
|
||||
import { addDays, berlinDate, daysBetween } from './dates.js'
|
||||
|
||||
/**
|
||||
* Stripe-Rechnungen und -Auszahlungen fuer die Finanzen (nur lesen). Rechnungen und Auszahlungen werden nicht
|
||||
* gespiegelt: sie kommen bei jeder Anfrage von Stripe, hoechstens alle 60 Sekunden (Zwischenspeicher je Art).
|
||||
* Die Rohdaten liegen im Zwischenspeicher, gemappt wird bei jedem Aufruf neu: so stimmt "ueberfaellig" auch,
|
||||
* wenn um Mitternacht ein neuer Tag beginnt, und ein Aufrufer kann den Zwischenspeicher nicht veraendern.
|
||||
*/
|
||||
|
||||
const CACHE_MS = 60_000
|
||||
// Rueckblick: Rechnungen der letzten 400 Tage (reicht fuer ein volles Jahr samt Nachzuegler)
|
||||
const LOOKBACK_DAYS = 400
|
||||
const MAX_INVOICES = 1000
|
||||
|
||||
// Stripe-Status -> Status nach aussen; "open" wird je nach Faelligkeit zu "offen" oder "ueberfaellig"
|
||||
const STATUS_DE = { draft: 'entwurf', open: 'offen', paid: 'bezahlt', void: 'storniert', uncollectible: 'uneinbringlich' }
|
||||
|
||||
// Je Art ein Eintrag { at, promise }; die Zusage (statt der Daten) wird gemerkt, damit gleichzeitige Aufrufe
|
||||
// dieselbe Stripe-Anfrage teilen. Ein Fehler wird nicht zwischengespeichert.
|
||||
const caches = { invoices: null, payouts: null }
|
||||
|
||||
function cached(kind, force, load) {
|
||||
const entry = caches[kind]
|
||||
if (!force && entry && Date.now() - entry.at < CACHE_MS) return entry.promise
|
||||
const fresh = { at: Date.now(), promise: null }
|
||||
fresh.promise = load().catch((err) => {
|
||||
if (caches[kind] === fresh) caches[kind] = null
|
||||
throw err
|
||||
})
|
||||
caches[kind] = fresh
|
||||
return fresh.promise
|
||||
}
|
||||
|
||||
/** Zwischenspeicher leeren (nach jeder Aenderung an Rechnungen oder Auszahlungen, damit die Ansicht stimmt). */
|
||||
export function invalidateFinanceCache() {
|
||||
caches.invoices = null
|
||||
caches.payouts = null
|
||||
}
|
||||
|
||||
// Stripe liefert Zeitpunkte als Unix-Sekunden; die Finanzen rechnen mit Berliner Kalendertagen
|
||||
const ymdOfUnix = (seconds) => (Number.isFinite(seconds) ? berlinDate(new Date(seconds * 1000)) : null)
|
||||
|
||||
/** Eine rohe Stripe-Rechnung (Kunde moeglichst expandiert) in die deutsche Form der Finanzen; today = YYYY-MM-DD. */
|
||||
export function mapInvoice(raw, { today }) {
|
||||
const customer = raw.customer && typeof raw.customer === 'object' ? raw.customer : null
|
||||
const metadata = raw.metadata || {}
|
||||
const dueDate = ymdOfUnix(raw.due_date)
|
||||
let status = STATUS_DE[raw.status] || raw.status
|
||||
if (raw.status === 'open' && dueDate && dueDate < today) status = 'ueberfaellig'
|
||||
return {
|
||||
id: raw.id,
|
||||
number: raw.number || '',
|
||||
status,
|
||||
customerName: raw.customer_name || customer?.name || '',
|
||||
customerEmail: raw.customer_email || customer?.email || '',
|
||||
webklarCustomerId: metadata.webklarCustomerId || customer?.metadata?.webklarCustomerId || '',
|
||||
createdDate: ymdOfUnix(raw.created),
|
||||
dueDate,
|
||||
totalCents: raw.total ?? 0,
|
||||
remainingCents: raw.amount_remaining ?? 0,
|
||||
paidCents: raw.amount_paid ?? 0,
|
||||
// Bei einer Bankzuordnung zaehlt der Tag, an dem das Geld auf dem Konto ankam, nicht der der Stripe-Buchung
|
||||
paidDate: metadata.bankBookingDate || ymdOfUnix(raw.status_transitions?.paid_at),
|
||||
overdueDays: status === 'ueberfaellig' ? daysBetween(dueDate, today) : 0,
|
||||
source: subscriptionIdOfInvoice(raw) ? 'abo' : 'projekt',
|
||||
hostedUrl: raw.hosted_invoice_url || null,
|
||||
pdfUrl: raw.invoice_pdf || null,
|
||||
outOfBand: Boolean(metadata.bankTransactionId),
|
||||
bankTransactionId: metadata.bankTransactionId || '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 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'],
|
||||
})
|
||||
.autoPagingToArray({ limit: MAX_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))
|
||||
return raw.map((payout) => ({
|
||||
id: payout.id,
|
||||
amountCents: payout.amount,
|
||||
arrivalDate: ymdOfUnix(payout.arrival_date),
|
||||
status: payout.status,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
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'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
|
||||
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber stripeBilling) geladen wird.
|
||||
// Der Appwrite-Nachbau wird nur fuer die Umgebung gebraucht.
|
||||
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')
|
||||
|
||||
after(() => {
|
||||
mock.timers.reset()
|
||||
return Promise.all([aw.close(), st.close()])
|
||||
})
|
||||
beforeEach(() => {
|
||||
mock.timers.reset()
|
||||
invalidateFinanceCache()
|
||||
st.calls.length = 0
|
||||
st.invoices.clear()
|
||||
st.customers.clear()
|
||||
st.payouts.clear()
|
||||
})
|
||||
|
||||
const TODAY = '2026-10-01'
|
||||
// Unix-Sekunden eines Berliner Mittags (MESZ = UTC+2), damit das Berliner Datum eindeutig ist
|
||||
const noonBerlin = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1000)
|
||||
|
||||
const rawInvoice = (over = {}) => ({
|
||||
id: 'in_1',
|
||||
object: 'invoice',
|
||||
number: 'WK-0001',
|
||||
status: 'open',
|
||||
customer: 'cus_1',
|
||||
customer_name: 'Muster GmbH',
|
||||
customer_email: 'muster@example.invalid',
|
||||
created: noonBerlin('2026-09-06'),
|
||||
due_date: noonBerlin('2026-10-20'),
|
||||
total: 12000,
|
||||
amount_due: 12000,
|
||||
amount_paid: 0,
|
||||
amount_remaining: 12000,
|
||||
status_transitions: { paid_at: null },
|
||||
hosted_invoice_url: 'https://invoice.stripe.test/i/in_1',
|
||||
invoice_pdf: 'https://invoice.stripe.test/i/in_1/pdf',
|
||||
metadata: {},
|
||||
lines: { object: 'list', data: [], has_more: false },
|
||||
...over,
|
||||
})
|
||||
|
||||
test('euroDe: deutsches Euro-Format mit Tausenderpunkt, Komma und echtem Minuszeichen', () => {
|
||||
// geschuetztes Leerzeichen (U+00A0) vor dem Euro-Zeichen wie Intl, Minus ist U+2212
|
||||
assert.equal(euroDe(123456), '1.234,56\u00a0€')
|
||||
assert.equal(euroDe(-449), '\u22124,49\u00a0€')
|
||||
assert.equal(euroDe(0), '0,00\u00a0€')
|
||||
assert.equal(euroDe(5), '0,05\u00a0€')
|
||||
assert.equal(euroDe(100000000), '1.000.000,00\u00a0€')
|
||||
assert.equal(euroDe(-100), '\u22121,00\u00a0€')
|
||||
assert.throws(() => euroDe(12.5), /Cent/)
|
||||
assert.throws(() => euroDe(undefined), /Cent/)
|
||||
})
|
||||
|
||||
test('mapInvoice: Stripe-Status werden deutsch gemappt', () => {
|
||||
const draft = mapInvoice(rawInvoice({ status: 'draft', number: null, due_date: null }), { today: TODAY })
|
||||
assert.equal(draft.status, 'entwurf')
|
||||
assert.equal(draft.number, '')
|
||||
assert.equal(draft.dueDate, null)
|
||||
|
||||
assert.equal(mapInvoice(rawInvoice({ status: 'open' }), { today: TODAY }).status, 'offen')
|
||||
|
||||
const paid = mapInvoice(
|
||||
rawInvoice({ status: 'paid', amount_paid: 12000, amount_remaining: 0, status_transitions: { paid_at: noonBerlin('2026-09-15') } }),
|
||||
{ today: TODAY }
|
||||
)
|
||||
assert.equal(paid.status, 'bezahlt')
|
||||
assert.equal(paid.paidCents, 12000)
|
||||
assert.equal(paid.remainingCents, 0)
|
||||
assert.equal(paid.paidDate, '2026-09-15')
|
||||
|
||||
assert.equal(mapInvoice(rawInvoice({ status: 'void' }), { today: TODAY }).status, 'storniert')
|
||||
assert.equal(mapInvoice(rawInvoice({ status: 'uncollectible' }), { today: TODAY }).status, 'uneinbringlich')
|
||||
})
|
||||
|
||||
test('mapInvoice: Felder, Betraege in Cent und Berliner Kalendertage', () => {
|
||||
const inv = mapInvoice(
|
||||
// 22:30 UTC am 05.09. ist in Berlin schon der 06.09.
|
||||
rawInvoice({ created: Math.floor(new Date('2026-09-05T22:30:00Z').getTime() / 1000) }),
|
||||
{ today: TODAY }
|
||||
)
|
||||
assert.deepEqual(inv, {
|
||||
id: 'in_1',
|
||||
number: 'WK-0001',
|
||||
status: 'offen',
|
||||
customerName: 'Muster GmbH',
|
||||
customerEmail: 'muster@example.invalid',
|
||||
webklarCustomerId: '',
|
||||
createdDate: '2026-09-06',
|
||||
dueDate: '2026-10-20',
|
||||
totalCents: 12000,
|
||||
remainingCents: 12000,
|
||||
paidCents: 0,
|
||||
paidDate: null,
|
||||
overdueDays: 0,
|
||||
source: 'projekt',
|
||||
hostedUrl: 'https://invoice.stripe.test/i/in_1',
|
||||
pdfUrl: 'https://invoice.stripe.test/i/in_1/pdf',
|
||||
outOfBand: false,
|
||||
bankTransactionId: '',
|
||||
})
|
||||
})
|
||||
|
||||
test('mapInvoice: offen und faellig vor heute -> ueberfaellig mit Verzugstagen', () => {
|
||||
const inv = mapInvoice(rawInvoice({ due_date: noonBerlin('2026-09-20') }), { today: TODAY })
|
||||
assert.equal(inv.status, 'ueberfaellig')
|
||||
assert.equal(inv.overdueDays, 11)
|
||||
})
|
||||
|
||||
test('mapInvoice: am Faelligkeitstag selbst noch nicht ueberfaellig, ohne Faelligkeit nie', () => {
|
||||
const same = mapInvoice(rawInvoice({ due_date: noonBerlin(TODAY) }), { today: TODAY })
|
||||
assert.equal(same.status, 'offen')
|
||||
assert.equal(same.overdueDays, 0)
|
||||
const none = mapInvoice(rawInvoice({ due_date: null }), { today: TODAY })
|
||||
assert.equal(none.status, 'offen')
|
||||
assert.equal(none.overdueDays, 0)
|
||||
})
|
||||
|
||||
test('mapInvoice: nur offene Rechnungen werden ueberfaellig, bezahlte mit alter Faelligkeit nicht', () => {
|
||||
const paid = mapInvoice(
|
||||
rawInvoice({ status: 'paid', due_date: noonBerlin('2026-08-01'), amount_paid: 12000, amount_remaining: 0, status_transitions: { paid_at: noonBerlin('2026-08-02') } }),
|
||||
{ today: TODAY }
|
||||
)
|
||||
assert.equal(paid.status, 'bezahlt')
|
||||
assert.equal(paid.overdueDays, 0)
|
||||
const voided = mapInvoice(rawInvoice({ status: 'void', due_date: noonBerlin('2026-08-01') }), { today: TODAY })
|
||||
assert.equal(voided.status, 'storniert')
|
||||
assert.equal(voided.overdueDays, 0)
|
||||
})
|
||||
|
||||
test('mapInvoice: Abo-Rechnung (parent.subscription_details) -> source abo, sonst projekt', () => {
|
||||
const abo = mapInvoice(rawInvoice({ parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_1' } } }), { today: TODAY })
|
||||
assert.equal(abo.source, 'abo')
|
||||
const alt = mapInvoice(rawInvoice({ subscription: 'sub_2' }), { today: TODAY })
|
||||
assert.equal(alt.source, 'abo')
|
||||
const projekt = mapInvoice(rawInvoice({ parent: null, metadata: { quelle: 'woms' } }), { today: TODAY })
|
||||
assert.equal(projekt.source, 'projekt')
|
||||
})
|
||||
|
||||
test('mapInvoice: Bankzuordnung -> outOfBand, Buchungsdatum als paidDate', () => {
|
||||
const inv = mapInvoice(
|
||||
rawInvoice({
|
||||
status: 'paid',
|
||||
amount_paid: 12000,
|
||||
amount_remaining: 0,
|
||||
// Stripe verbucht erst am 07.08. (paid_out_of_band), das Geld kam aber am 05.08. auf dem Konto an
|
||||
status_transitions: { paid_at: noonBerlin('2026-08-07') },
|
||||
metadata: { bankTransactionId: 'tx_abc', bankBookingDate: '2026-08-05' },
|
||||
}),
|
||||
{ today: TODAY }
|
||||
)
|
||||
assert.equal(inv.paidDate, '2026-08-05')
|
||||
assert.equal(inv.outOfBand, true)
|
||||
assert.equal(inv.bankTransactionId, 'tx_abc')
|
||||
})
|
||||
|
||||
test('mapInvoice: webklarCustomerId aus der Rechnung, sonst aus dem expandierten Kunden', () => {
|
||||
const direkt = mapInvoice(rawInvoice({ metadata: { webklarCustomerId: 'c1' }, customer: { id: 'cus_1', metadata: { webklarCustomerId: 'c9' } } }), { today: TODAY })
|
||||
assert.equal(direkt.webklarCustomerId, 'c1')
|
||||
const expandiert = mapInvoice(rawInvoice({ customer: { id: 'cus_1', name: 'Kunde', email: 'k@example.invalid', metadata: { webklarCustomerId: 'c9' } } }), { today: TODAY })
|
||||
assert.equal(expandiert.webklarCustomerId, 'c9')
|
||||
// nicht expandiert (nur ID): keine Zuordnung, aber auch kein Fehler
|
||||
assert.equal(mapInvoice(rawInvoice(), { today: TODAY }).webklarCustomerId, '')
|
||||
})
|
||||
|
||||
test('mapInvoice: Name und E-Mail fallen auf den expandierten Kunden zurueck', () => {
|
||||
const inv = mapInvoice(
|
||||
rawInvoice({ customer_name: null, customer_email: null, customer: { id: 'cus_1', name: 'Kunde AG', email: 'k@example.invalid', metadata: {} } }),
|
||||
{ today: TODAY }
|
||||
)
|
||||
assert.equal(inv.customerName, 'Kunde AG')
|
||||
assert.equal(inv.customerEmail, 'k@example.invalid')
|
||||
})
|
||||
|
||||
test('listInvoices: fragt mit limit, created und expand an, bettet den Kunden ein und mappt', async () => {
|
||||
st.customers.set('cus_1', { id: 'cus_1', object: 'customer', name: 'Muster GmbH', email: 'm@example.invalid', metadata: { webklarCustomerId: 'c1' } })
|
||||
st.invoices.set('in_1', rawInvoice({ id: 'in_1', due_date: noonBerlin('2026-09-20') }))
|
||||
const before = Math.floor(Date.now() / 1000)
|
||||
const list = await listInvoices()
|
||||
assert.equal(list.length, 1)
|
||||
assert.equal(list[0].id, 'in_1')
|
||||
assert.equal(list[0].webklarCustomerId, 'c1')
|
||||
assert.equal(list[0].status, 'ueberfaellig')
|
||||
assert.ok(list[0].overdueDays > 0)
|
||||
|
||||
const calls = st.calls.filter((c) => c.path === '/v1/invoices')
|
||||
assert.equal(calls.length, 1)
|
||||
assert.equal(calls[0].method, 'GET')
|
||||
assert.equal(calls[0].query.limit, '100')
|
||||
assert.equal(calls[0].query['expand[0]'], 'data.customer')
|
||||
// 400 Tage Rueckblick
|
||||
const gte = Number(calls[0].query['created[gte]'])
|
||||
assert.ok(Math.abs(gte - (before - 400 * 86400)) < 60, `created[gte] ${gte}`)
|
||||
})
|
||||
|
||||
test('listInvoices: blaettert ueber alle Seiten', async () => {
|
||||
for (let i = 1; i <= 230; i += 1) st.invoices.set(`in_${i}`, rawInvoice({ id: `in_${i}`, number: `WK-${i}` }))
|
||||
const list = await listInvoices()
|
||||
assert.equal(list.length, 230)
|
||||
assert.equal(new Set(list.map((inv) => inv.id)).size, 230)
|
||||
assert.equal(st.calls.filter((c) => c.path === '/v1/invoices').length, 3)
|
||||
})
|
||||
|
||||
test('listInvoices: hoechstens 1000 Rechnungen', async () => {
|
||||
for (let i = 1; i <= 1100; i += 1) st.invoices.set(`in_${i}`, rawInvoice({ id: `in_${i}` }))
|
||||
const list = await listInvoices()
|
||||
assert.equal(list.length, 1000)
|
||||
})
|
||||
|
||||
test('listInvoices: zweimal innerhalb von 60 s -> ein Stripe-Aufruf, danach invalidateFinanceCache und force', async () => {
|
||||
st.invoices.set('in_1', rawInvoice())
|
||||
const countCalls = () => st.calls.filter((c) => c.path === '/v1/invoices').length
|
||||
await listInvoices()
|
||||
await listInvoices()
|
||||
assert.equal(countCalls(), 1)
|
||||
invalidateFinanceCache()
|
||||
await listInvoices()
|
||||
assert.equal(countCalls(), 2)
|
||||
await listInvoices({ force: true })
|
||||
assert.equal(countCalls(), 3)
|
||||
// force frischt auch den Zwischenspeicher auf
|
||||
await listInvoices()
|
||||
assert.equal(countCalls(), 3)
|
||||
})
|
||||
|
||||
test('listInvoices: gleichzeitige Aufrufe teilen sich eine Stripe-Anfrage', async () => {
|
||||
st.invoices.set('in_1', rawInvoice())
|
||||
const [a, b] = await Promise.all([listInvoices(), listInvoices()])
|
||||
assert.equal(st.calls.filter((c) => c.path === '/v1/invoices').length, 1)
|
||||
assert.equal(a.length, 1)
|
||||
assert.equal(b.length, 1)
|
||||
})
|
||||
|
||||
test('listInvoices: nach 60 s wird neu geladen', async () => {
|
||||
mock.timers.enable({ apis: ['Date'], now: Date.now() })
|
||||
st.invoices.set('in_1', rawInvoice())
|
||||
const countCalls = () => st.calls.filter((c) => c.path === '/v1/invoices').length
|
||||
await listInvoices()
|
||||
mock.timers.tick(59_000)
|
||||
await listInvoices()
|
||||
assert.equal(countCalls(), 1)
|
||||
mock.timers.tick(2_000)
|
||||
await listInvoices()
|
||||
assert.equal(countCalls(), 2)
|
||||
})
|
||||
|
||||
test('listInvoices: Aenderungen am Ergebnis vergiften den Zwischenspeicher nicht', async () => {
|
||||
st.invoices.set('in_1', rawInvoice())
|
||||
const first = await listInvoices()
|
||||
first[0].status = 'bezahlt'
|
||||
first.pop()
|
||||
const second = await listInvoices()
|
||||
assert.equal(second.length, 1)
|
||||
assert.equal(second[0].status, 'offen')
|
||||
})
|
||||
|
||||
test('listInvoices: Stripe-Fehler wird nicht zwischengespeichert', async () => {
|
||||
st.invoices.set('in_1', rawInvoice())
|
||||
st.failNext({ path: '/v1/invoices', status: 400, message: 'kaputt' })
|
||||
await assert.rejects(() => listInvoices(), /kaputt/)
|
||||
const list = await listInvoices()
|
||||
assert.equal(list.length, 1)
|
||||
})
|
||||
|
||||
test('listPayouts: Betrag, Ankunftstag (Berlin) und Status, 60-s-Zwischenspeicher', async () => {
|
||||
st.payouts.set('po_1', { id: 'po_1', object: 'payout', amount: 45000, arrival_date: noonBerlin('2026-09-12'), status: 'paid', currency: 'eur' })
|
||||
st.payouts.set('po_2', { id: 'po_2', object: 'payout', amount: 1999, arrival_date: Math.floor(new Date('2026-09-30T22:30:00Z').getTime() / 1000), status: 'in_transit', currency: 'eur' })
|
||||
const list = await listPayouts()
|
||||
assert.deepEqual(list, [
|
||||
{ id: 'po_1', amountCents: 45000, arrivalDate: '2026-09-12', status: 'paid' },
|
||||
{ id: 'po_2', amountCents: 1999, arrivalDate: '2026-10-01', status: 'in_transit' },
|
||||
])
|
||||
const calls = () => st.calls.filter((c) => c.path === '/v1/payouts')
|
||||
assert.equal(calls().length, 1)
|
||||
assert.equal(calls()[0].query.limit, '50')
|
||||
await listPayouts()
|
||||
assert.equal(calls().length, 1)
|
||||
invalidateFinanceCache()
|
||||
await listPayouts()
|
||||
assert.equal(calls().length, 2)
|
||||
await listPayouts({ force: true })
|
||||
assert.equal(calls().length, 3)
|
||||
})
|
||||
|
||||
test('invalidateFinanceCache leert Rechnungen und Auszahlungen', async () => {
|
||||
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' })
|
||||
await listInvoices()
|
||||
await listPayouts()
|
||||
invalidateFinanceCache()
|
||||
await listInvoices()
|
||||
await listPayouts()
|
||||
assert.equal(st.calls.filter((c) => c.path === '/v1/invoices').length, 2)
|
||||
assert.equal(st.calls.filter((c) => c.path === '/v1/payouts').length, 2)
|
||||
})
|
||||
@@ -14,6 +14,9 @@ import http from 'node:http'
|
||||
* und Werte höchstens 500 Zeichen. Rechnungen: GET /v1/invoices/:id bettet wie Stripe höchstens 10 Zeilen ein,
|
||||
* GET /v1/invoices/:id/lines blättert (limit, starting_after);
|
||||
* POST /v1/invoices/create_preview liefert st.invoicePreview (Objekt, oder Funktion des Aufruf-Bodys).
|
||||
* Listen (Finanzen): GET /v1/invoices (expand data.customer ersetzt customer durch das Objekt aus st.customers,
|
||||
* sofern vorhanden) und GET /v1/payouts (st.payouts) blaettern wie Stripe mit limit (hoechstens 100) und
|
||||
* starting_after samt has_more; die Reihenfolge ist die Einfuegereihenfolge der Map.
|
||||
*/
|
||||
|
||||
// Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/
|
||||
@@ -32,6 +35,7 @@ export async function startFakeStripe() {
|
||||
const coupons = new Map()
|
||||
const billingPortalConfigurations = new Map()
|
||||
const customers = new Map()
|
||||
const payouts = new Map()
|
||||
const checkoutSessions = new Map()
|
||||
// Abo-Aenderungen, die Stripe bis zur Zahlung zurueckhaelt (pending_update), je Abo-ID
|
||||
const pendingUpdates = new Map()
|
||||
@@ -65,6 +69,15 @@ export async function startFakeStripe() {
|
||||
const invoiceLinesOf = (invoiceId) =>
|
||||
(invoices.get(invoiceId)?.lines?.data || []).map((line, i) => ({ id: `li_${invoiceId}_${i + 1}`, object: 'line_item', ...line }))
|
||||
const EMBEDDED_LINES = 10
|
||||
// Listenantwort wie Stripe: limit (Standard 10, hoechstens 100) und starting_after (ID des letzten Eintrags der Vorseite)
|
||||
const pageOf = (items, query, url) => {
|
||||
const limit = Math.min(Number(query.get('limit')) || 10, 100)
|
||||
const after = query.get('starting_after')
|
||||
const afterIndex = after ? items.findIndex((item) => item.id === after) : -1
|
||||
if (after && afterIndex < 0) return invalid('starting_after', `No such object: '${after}'`)
|
||||
const start = afterIndex + 1
|
||||
return [200, { object: 'list', data: items.slice(start, start + limit), has_more: start + limit < items.length, url }]
|
||||
}
|
||||
|
||||
const readBody = (req) =>
|
||||
new Promise((resolve) => {
|
||||
@@ -203,6 +216,16 @@ export async function startFakeStripe() {
|
||||
prices.set(obj.id, obj)
|
||||
return [200, obj]
|
||||
}
|
||||
if (method === 'GET' && path === '/v1/invoices') {
|
||||
const expandCustomer = [...query.entries()].some(([k, v]) => k.startsWith('expand') && v === 'data.customer')
|
||||
const all = [...invoices.values()].map((inv) =>
|
||||
expandCustomer && typeof inv.customer === 'string' && customers.has(inv.customer) ? { ...inv, customer: customers.get(inv.customer) } : inv
|
||||
)
|
||||
return pageOf(all, query, '/v1/invoices')
|
||||
}
|
||||
if (method === 'GET' && path === '/v1/payouts') {
|
||||
return pageOf([...payouts.values()], query, '/v1/payouts')
|
||||
}
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) {
|
||||
if (!invoices.has(m[1])) return noSuch('invoice', m[1])
|
||||
// Wie Stripe: hoechstens 10 Zeilen eingebettet, weitere ueber GET /v1/invoices/:id/lines (has_more)
|
||||
@@ -388,6 +411,7 @@ export async function startFakeStripe() {
|
||||
coupons,
|
||||
billingPortalConfigurations,
|
||||
customers,
|
||||
payouts,
|
||||
checkoutSessions,
|
||||
/**
|
||||
* Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. latest_invoice). Mit
|
||||
|
||||
Reference in new issue
Block a user