Finanzen: Projektrechnungen in Stripe anlegen, senden, stornieren, Entwurf loeschen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:42:06 +00:00
1 parent db243f50bb
commit 05e3478229
4 files changed
+748 -4

No files matched your search

+382
View File
@@ -0,0 +1,382 @@
import { test, after, beforeEach } 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'
import { schemaKeys } from './support/schema.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber stripeBilling) geladen wird
const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions') } })
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { seedFixtures } = await import('./support/fixtures.js')
const { berlinDate } = await import('../server/services/finance/dates.js')
const { getStripe } = await import('../server/services/stripeBilling.js')
const { invoiceFooter, createProjectInvoice, sendInvoiceNow, voidInvoice, deleteDraft, listInvoices, invalidateFinanceCache } = await import(
'../server/services/finance/stripeFinance.js'
)
after(() => Promise.all([aw.close(), st.close()]))
seedFixtures(aw)
aw.seed('customers', { $id: 'lead1', email: 'lead@example.invalid', name: 'Interessent', customerStatus: 'lead', status: 'active' })
aw.seed('customers', { $id: 'lost1', email: 'lost@example.invalid', name: 'Verloren', customerStatus: 'Lost', status: 'active' })
aw.seed('customers', { $id: 'act1', email: 'act@example.invalid', name: 'Aktiv', companyName: 'Aktiv GmbH', customerStatus: 'active', status: 'active' })
const PAYMENT_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnummer als Verwendungszweck an.'
const BASE_ITEMS = [
{ description: 'Website-Anpassung', quantity: 2, unitAmountCents: 5000 },
{ description: 'Domain', quantity: 1, unitAmountCents: 1200 },
]
beforeEach(() => {
invalidateFinanceCache()
st.calls.length = 0
st.invoices.clear()
st.customers.clear()
// Das Abo von c1 kennt die Stripe-Kunden-ID cus_c1 (seedFixtures); der Nachbau soll den Kunden ebenfalls kennen
st.customers.set('cus_c1', { id: 'cus_c1', object: 'customer', name: 'Kunde Eins', email: 'k@example.invalid', metadata: { webklarCustomerId: 'c1' } })
})
const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path))
const writes = () => st.calls.filter((c) => c.method !== 'GET')
const rejectsWith = (promise, status, message) =>
assert.rejects(promise, (err) => {
assert.equal(err.status, status, `Status ${err.status}: ${err.message}`)
if (message) assert.equal(err.message, message)
return true
})
const aboInvoice = (over = {}) => ({
id: 'in_abo',
object: 'invoice',
number: 'WK-0100',
status: 'open',
customer: 'cus_c1',
subscription: 'sub_h',
created: Math.floor(Date.now() / 1000),
due_date: null,
total: 2000,
amount_remaining: 2000,
amount_paid: 0,
metadata: {},
lines: { object: 'list', data: [], has_more: false },
...over,
})
const projectInvoice = (over = {}) => ({
id: 'in_proj',
object: 'invoice',
number: null,
status: 'draft',
customer: 'cus_c1',
created: Math.floor(Date.now() / 1000),
due_date: null,
total: 6000,
amount_remaining: 6000,
amount_paid: 0,
metadata: { quelle: 'woms' },
lines: { object: 'list', data: [], has_more: false },
...over,
})
test('invoiceFooter: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Verwendungszweck-Satz', () => {
assert.equal(invoiceFooter(), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
assert.equal(invoiceFooter(''), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
assert.equal(invoiceFooter(' \n '), `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
assert.equal(
invoiceFooter('IBAN DE00 0000 0000 0000 0000 00\nBIC TESTDEFF'),
`${config.stripe.invoiceFooter}\nIBAN DE00 0000 0000 0000 0000 00\nBIC TESTDEFF\n${PAYMENT_NOTE}`
)
})
test('createProjectInvoice: Entwurf mit Positionen, Rechnungsfuss und Metadaten (nicht gesendet)', async () => {
const result = await createProjectInvoice({
customerId: 'c1',
items: BASE_ITEMS,
memo: 'Leistungen im September',
createdBy: 'kenso@example.invalid',
bankDetailsText: 'IBAN DE00 0000 0000 0000 0000 00',
})
const [create] = callsTo('POST', '/v1/invoices')
assert.ok(create, 'POST /v1/invoices fehlt')
assert.equal(create.body.customer, 'cus_c1')
assert.equal(create.body.collection_method, 'send_invoice')
assert.equal(create.body.days_until_due, '14')
assert.equal(create.body.auto_advance, 'false')
assert.equal(create.body.pending_invoice_items_behavior, 'exclude')
assert.equal(create.body.currency, 'eur')
assert.equal(create.body.description, 'Leistungen im September')
assert.equal(create.body['metadata[quelle]'], 'woms')
assert.equal(create.body['metadata[webklarCustomerId]'], 'c1')
assert.equal(create.body['metadata[erstelltVon]'], 'kenso@example.invalid')
assert.ok(create.body.footer.startsWith(config.stripe.invoiceFooter))
assert.ok(create.body.footer.endsWith(PAYMENT_NOTE))
assert.equal(create.body.footer, `${config.stripe.invoiceFooter}\nIBAN DE00 0000 0000 0000 0000 00\n${PAYMENT_NOTE}`)
// Menge und Einzelpreis jeder Position kommen bei Stripe an: im Aufruf ...
const items = callsTo('POST', '/v1/invoiceitems')
assert.equal(items.length, 2)
assert.deepEqual(items[0].body, {
customer: 'cus_c1',
invoice: result.id,
description: 'Website-Anpassung',
quantity: '2',
unit_amount_decimal: '5000',
currency: 'eur',
})
assert.deepEqual(items[1].body, {
customer: 'cus_c1',
invoice: result.id,
description: 'Domain',
quantity: '1',
unit_amount_decimal: '1200',
currency: 'eur',
})
// ... und auf der Rechnung (Zeilen mit Menge und Einzelpreis, Summe aus Menge mal Einzelpreis)
const lines = st.invoices.get(result.id).lines.data
assert.deepEqual(
lines.map((l) => [l.description, l.quantity, l.pricing.unit_amount_decimal, l.amount]),
[
['Website-Anpassung', 2, '5000', 10000],
['Domain', 1, '1200', 1200],
]
)
assert.equal(result.status, 'entwurf')
assert.equal(result.source, 'projekt')
assert.equal(result.totalCents, 11200)
assert.equal(result.remainingCents, 11200)
assert.equal(result.webklarCustomerId, 'c1')
assert.equal(result.customerName, 'Kunde Eins')
assert.equal(result.number, '')
// kein Finalisieren, kein Senden
assert.equal(callsTo('POST', /finalize|send/).length, 0)
})
test('createProjectInvoice: ohne Vermerk keine Beschreibung, ohne Bankdaten kuerzerer Fuss, Zahlungsziel 0 bleibt 0', async () => {
await createProjectInvoice({ customerId: 'c1', items: [BASE_ITEMS[1]], daysUntilDue: 0, createdBy: 'k' })
const [create] = callsTo('POST', '/v1/invoices')
assert.equal(create.body.days_until_due, '0')
assert.equal('description' in create.body, false)
assert.equal(create.body.footer, `${config.stripe.invoiceFooter}\n${PAYMENT_NOTE}`)
})
test('createProjectInvoice: legt fuer Kunden ohne Stripe-Kunde einen an (Firmenname)', async () => {
const result = await createProjectInvoice({ customerId: 'act1', items: [BASE_ITEMS[1]], createdBy: 'k' })
const created = callsTo('POST', '/v1/customers')
assert.equal(created.length, 1)
assert.equal(created[0].body.name, 'Aktiv GmbH')
assert.equal(result.webklarCustomerId, 'act1')
assert.equal(result.customerName, 'Aktiv GmbH')
})
test('createProjectInvoice: senden=true finalisiert (ohne Auto-Advance) und sendet, Rechnung ist offen mit Nummer', async () => {
const result = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, daysUntilDue: 30, senden: true, createdBy: 'k' })
const order = writes().map((c) => c.path.replace(/in_test_\d+/, ':id'))
assert.deepEqual(order.slice(-4), ['/v1/invoiceitems', '/v1/invoiceitems', '/v1/invoices/:id/finalize', '/v1/invoices/:id/send'])
const [finalize] = callsTo('POST', /\/finalize$/)
assert.equal(finalize.body.auto_advance, 'false')
assert.equal(result.status, 'offen')
assert.match(result.number, /^WK-\d{4}$/)
assert.equal(result.totalCents, 11200)
assert.equal(result.dueDate, berlinDate(new Date(Date.now() + 30 * 86_400_000)))
assert.ok(result.hostedUrl)
})
test('createProjectInvoice: Lead-, verlorene und unbekannte Kunden werden mit 400 abgelehnt, Stripe bleibt unberuehrt', async () => {
await rejectsWith(createProjectInvoice({ customerId: 'lead1', items: BASE_ITEMS, createdBy: 'k' }), 400, 'Rechnungen nur für feste Kunden.')
await rejectsWith(createProjectInvoice({ customerId: 'lost1', items: BASE_ITEMS, createdBy: 'k' }), 400, 'Rechnungen nur für feste Kunden.')
await rejectsWith(createProjectInvoice({ customerId: 'gibtEsNicht', items: BASE_ITEMS, createdBy: 'k' }), 400)
await rejectsWith(createProjectInvoice({ items: BASE_ITEMS, createdBy: 'k' }), 400)
assert.equal(writes().length, 0)
})
test('createProjectInvoice: Eingaben werden geprueft (Positionen, Texte, Mengen, Betraege, Zahlungsziel)', async () => {
const ok = BASE_ITEMS[1]
const bad = async (label, input) => {
try {
await createProjectInvoice({ customerId: 'c1', items: [ok], createdBy: 'k', ...input })
} catch (err) {
assert.equal(err.status, 400, `${label}: Status ${err.status} (${err.message})`)
return
}
assert.fail(`${label}: wurde nicht abgelehnt`)
}
const item = (over) => ({ ...ok, ...over })
// 21 Positionen, keine Positionen, keine Liste
await rejectsWith(createProjectInvoice({ customerId: 'c1', items: Array.from({ length: 21 }, () => ok), createdBy: 'k' }), 400)
await bad('leer', { items: [] })
await bad('keine Liste', { items: undefined })
// Beschreibung
await bad('Beschreibung leer', { items: [item({ description: '' })] })
await bad('Beschreibung nur Leerzeichen', { items: [item({ description: ' ' })] })
await bad('Beschreibung 201 Zeichen', { items: [item({ description: 'x'.repeat(201) })] })
await bad('Beschreibung keine Zeichenkette', { items: [item({ description: 5 })] })
// Menge
for (const quantity of [0, 1000, 1.5, '2', -1, null]) await bad(`Menge ${quantity}`, { items: [item({ quantity })] })
// Einzelpreis
for (const unitAmountCents of [0, 10_000_001, 12.5, '5000', -100, null]) await bad(`Preis ${unitAmountCents}`, { items: [item({ unitAmountCents })] })
// Zahlungsziel
for (const daysUntilDue of [-1, 61, 1.5, '14', null]) await bad(`Ziel ${daysUntilDue}`, { daysUntilDue })
assert.equal(writes().length, 0, 'bei ungueltigen Eingaben darf nichts an Stripe gehen')
})
test('createProjectInvoice: Grenzwerte sind erlaubt (20 Positionen, 200 Zeichen, Menge 999, 10.000.000 Cent, Ziel 60)', async () => {
const items = Array.from({ length: 20 }, (_, i) => ({ description: i === 0 ? 'x'.repeat(200) : `Position ${i}`, quantity: 999, unitAmountCents: 10_000_000 }))
const result = await createProjectInvoice({ customerId: 'c1', items, daysUntilDue: 60, createdBy: 'k' })
assert.equal(callsTo('POST', '/v1/invoiceitems').length, 20)
assert.equal(result.totalCents, 20 * 999 * 10_000_000)
assert.equal(callsTo('POST', '/v1/invoices')[0].body.days_until_due, '60')
})
test('createProjectInvoice: scheitert eine Position, wird der halbfertige Entwurf geloescht', async () => {
st.failNext({ method: 'POST', path: '/v1/invoiceitems', status: 400, message: 'Nachbau: Position abgelehnt', after: 1 })
await assert.rejects(createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' }), /Position abgelehnt/)
assert.equal(callsTo('DELETE', /^\/v1\/invoices\/in_/).length, 1)
assert.equal(st.invoices.size, 0)
})
test('sendInvoiceNow: Entwurf wird finalisiert (ohne Auto-Advance) und gesendet', async () => {
const draft = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' })
st.calls.length = 0
const sent = await sendInvoiceNow(draft.id)
assert.deepEqual(
writes().map((c) => c.path.replace(draft.id, ':id')),
['/v1/invoices/:id/finalize', '/v1/invoices/:id/send']
)
assert.equal(callsTo('POST', /\/finalize$/)[0].body.auto_advance, 'false')
assert.equal(sent.status, 'offen')
assert.match(sent.number, /^WK-\d{4}$/)
})
test('sendInvoiceNow: nur Entwuerfe (409), nur Projektrechnungen, unbekannte Rechnung 404', async () => {
st.invoices.set('in_open', projectInvoice({ id: 'in_open', status: 'open', number: 'WK-0001' }))
st.invoices.set('in_abo', aboInvoice({ status: 'draft', number: null }))
await rejectsWith(sendInvoiceNow('in_open'), 409, 'Nur Entwürfe können gesendet werden.')
await rejectsWith(sendInvoiceNow('in_abo'), 409, 'Abo-Rechnungen verwaltet Stripe.')
await rejectsWith(sendInvoiceNow('in_gibtEsNicht'), 404, 'Rechnung nicht gefunden.')
assert.equal(writes().length, 0)
})
test('voidInvoice: offene und ueberfaellige Projektrechnungen werden storniert', async () => {
const open = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, senden: true, createdBy: 'k' })
const voided = await voidInvoice(open.id)
assert.equal(voided.status, 'storniert')
assert.equal(callsTo('POST', `/v1/invoices/${open.id}/void`).length, 1)
// ueberfaellig: offen, Faelligkeit vor heute
st.invoices.set('in_late', projectInvoice({ id: 'in_late', status: 'open', number: 'WK-0007', due_date: Math.floor(Date.now() / 1000) - 10 * 86_400 }))
assert.equal((await voidInvoice('in_late')).status, 'storniert')
})
test('voidInvoice: Entwurf, bezahlte und Abo-Rechnungen werden mit 409 abgelehnt, unbekannte mit 404', async () => {
st.invoices.set('in_draft', projectInvoice({ id: 'in_draft' }))
st.invoices.set('in_paid', projectInvoice({ id: 'in_paid', status: 'paid', number: 'WK-0002' }))
st.invoices.set('in_abo', aboInvoice())
await rejectsWith(voidInvoice('in_draft'), 409)
await rejectsWith(voidInvoice('in_paid'), 409)
await rejectsWith(voidInvoice('in_abo'), 409, 'Abo-Rechnungen verwaltet Stripe.')
await rejectsWith(voidInvoice('in_gibtEsNicht'), 404, 'Rechnung nicht gefunden.')
assert.equal(writes().length, 0)
assert.equal(st.invoices.get('in_draft').status, 'draft')
})
test('deleteDraft: loescht einen Projekt-Entwurf bei Stripe', async () => {
const draft = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' })
const result = await deleteDraft(draft.id)
assert.equal(result, undefined)
assert.equal(callsTo('DELETE', `/v1/invoices/${draft.id}`).length, 1)
assert.equal(st.invoices.has(draft.id), false)
})
test('deleteDraft: Abo-Rechnungen und alles ausser Entwuerfen werden mit 409 abgelehnt', async () => {
st.invoices.set('in_abo', aboInvoice())
st.invoices.set('in_abo_draft', aboInvoice({ id: 'in_abo_draft', status: 'draft', number: null }))
st.invoices.set('in_open', projectInvoice({ id: 'in_open', status: 'open', number: 'WK-0003' }))
await rejectsWith(deleteDraft('in_abo'), 409, 'Abo-Rechnungen verwaltet Stripe.')
await rejectsWith(deleteDraft('in_abo_draft'), 409, 'Abo-Rechnungen verwaltet Stripe.')
await rejectsWith(deleteDraft('in_open'), 409)
await rejectsWith(deleteDraft('in_gibtEsNicht'), 404, 'Rechnung nicht gefunden.')
assert.equal(writes().length, 0)
assert.equal(st.invoices.size, 3)
})
test('Schreibzugriffe leeren den Zwischenspeicher: die Liste zeigt neue, gesendete, stornierte und geloeschte Rechnungen sofort', async () => {
assert.deepEqual(await listInvoices(), [])
const draft = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' })
assert.deepEqual((await listInvoices()).map((i) => [i.id, i.status]), [[draft.id, 'entwurf']])
await sendInvoiceNow(draft.id)
assert.equal((await listInvoices())[0].status, 'offen')
await voidInvoice(draft.id)
assert.equal((await listInvoices())[0].status, 'storniert')
const second = await createProjectInvoice({ customerId: 'c1', items: BASE_ITEMS, createdBy: 'k' })
assert.equal((await listInvoices()).length, 2)
await deleteDraft(second.id)
assert.deepEqual((await listInvoices()).map((i) => i.id), [draft.id])
})
test('Stripe-Nachbau: Statusuebergaenge wie Stripe (finalize nur Entwurf, send/void/pay nur offen, loeschen nur Entwurf)', async () => {
const stripe = getStripe()
const status = (id) => st.invoices.get(id).status
const refused = (promise, pattern) => assert.rejects(promise, (err) => err.statusCode === 400 && pattern.test(err.message))
const create = () => stripe.invoices.create({ customer: 'cus_c1', collection_method: 'send_invoice', days_until_due: 7 })
const item = (id) => stripe.invoiceItems.create({ customer: 'cus_c1', invoice: id, description: 'Test', quantity: 3, unit_amount_decimal: '250', currency: 'eur' })
// send_invoice braucht ein Zahlungsziel
await assert.rejects(stripe.invoices.create({ customer: 'cus_c1', collection_method: 'send_invoice' }), (err) => err.statusCode === 400)
const draft = await create()
assert.equal(draft.status, 'draft')
await item(draft.id)
assert.equal(st.invoices.get(draft.id).total, 750)
await refused(stripe.invoices.sendInvoice(draft.id), /open/)
await refused(stripe.invoices.voidInvoice(draft.id), /open/)
await refused(stripe.invoices.pay(draft.id), /open/)
// Metadaten zusammenfuehren, leerer Wert loescht
await stripe.invoices.update(draft.id, { metadata: { a: '1', b: '2' } })
await stripe.invoices.update(draft.id, { metadata: { b: '', c: '3' } })
assert.deepEqual(st.invoices.get(draft.id).metadata, { a: '1', c: '3' })
const open = await stripe.invoices.finalizeInvoice(draft.id, { auto_advance: false })
assert.equal(open.status, 'open')
assert.match(open.number, /^WK-\d{4}$/)
assert.equal(open.auto_advance, false)
await refused(stripe.invoices.finalizeInvoice(draft.id), /draft/)
await refused(stripe.invoices.del(draft.id), /draft/)
await refused(item(draft.id), /draft/)
assert.equal(status(draft.id), 'open')
// Nummern laufen fortlaufend
const second = await create()
assert.equal((await stripe.invoices.finalizeInvoice(second.id)).number, `WK-${String(Number(open.number.slice(3)) + 1).padStart(4, '0')}`)
// bezahlen (ausserhalb von Stripe) und danach nichts mehr
const paid = await stripe.invoices.pay(draft.id, { paid_out_of_band: true })
assert.equal(paid.status, 'paid')
assert.equal(paid.amount_paid, 750)
assert.equal(paid.amount_remaining, 0)
assert.equal(paid.paid_out_of_band, true)
assert.ok(paid.status_transitions.paid_at)
await refused(stripe.invoices.voidInvoice(draft.id), /open/)
await refused(stripe.invoices.pay(draft.id), /open/)
// stornieren nur offen; Entwurf loeschen
await stripe.invoices.voidInvoice(second.id)
assert.equal(status(second.id), 'void')
const third = await create()
const removed = await stripe.invoices.del(third.id)
assert.equal(removed.deleted, true)
assert.equal(st.invoices.has(third.id), false)
})
+180 -1
View File
@@ -17,6 +17,14 @@ import http from 'node:http'
* 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.
*
* Rechnungen schreiben (Finanzen, Projektrechnungen): POST /v1/invoices (Entwurf; send_invoice braucht
* days_until_due), POST /v1/invoiceitems (mit invoice=... als Zeile mit quantity und unit_amount_decimal,
* Summe = Menge mal Einzelpreis; nur an Entwuerfe), POST /v1/invoices/:id (Metadaten zusammenfuehren, leerer
* Wert loescht), .../finalize (nur draft -> open, vergibt die Nummer WK-0001, WK-0002, ... und die Faelligkeit),
* .../send (nur open), .../void (nur open -> void), .../pay (nur open -> paid, auch paid_out_of_band) und
* DELETE /v1/invoices/:id (nur draft); jeder andere Zustand ist wie bei Stripe ein 400. Der Kunde einer neuen
* Rechnung muss nicht in st.customers stehen (dann bleiben Name und E-Mail leer).
*/
// Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/
@@ -36,11 +44,15 @@ export async function startFakeStripe() {
const billingPortalConfigurations = new Map()
const customers = new Map()
const payouts = new Map()
// Rechnungsposten ohne Rechnung (pending invoice items), je ID
const invoiceItems = new Map()
const checkoutSessions = new Map()
// Abo-Aenderungen, die Stripe bis zur Zahlung zurueckhaelt (pending_update), je Abo-ID
const pendingUpdates = new Map()
const calls = []
let seq = 0
// Fortlaufende Rechnungsnummer, die finalize vergibt (WK-0001, ...)
let invoiceNumberSeq = 0
let nextSubscriptionUpdate = null
let invoicePreview = null
// Eingeplanter Fehler (failNext, F41d): der naechste passende Aufruf scheitert einmalig
@@ -64,6 +76,23 @@ export async function startFakeStripe() {
const flat = (params) => Object.fromEntries(params.entries())
const noSuch = (what, id) => [404, { error: { type: 'invalid_request_error', code: 'resource_missing', message: `No such ${what}: '${id}'` } }]
const invalid = (param, message) => [400, { error: { type: 'invalid_request_error', param, message } }]
const nowUnix = () => Math.floor(Date.now() / 1000)
// Rechnungen aendern: nur Entwuerfe nehmen Posten an, finalize/send/void/pay haben feste Ausgangszustaende
const wrongState = (what, invoice, needed) =>
invalid(undefined, `This invoice is ${invoice.status}; ${what} is only possible for ${needed} invoices.`)
// Metadaten wie Stripe: Schluessel hoechstens 40, Werte hoechstens 500 Zeichen, leerer Wert loescht
const mergeMetadata = (existing, incoming) => {
for (const [key, value] of Object.entries(incoming || {})) {
if (key.length > 40) return invalid(`metadata[${key}]`, 'Metadata keys can be at most 40 characters long.')
if (String(value).length > 500) return invalid(`metadata[${key}]`, 'Metadata values can be at most 500 characters long.')
}
const metadata = { ...(existing || {}) }
for (const [key, value] of Object.entries(incoming || {})) {
if (value === '') delete metadata[key]
else metadata[key] = value
}
return metadata
}
const listOf = (data) => ({ object: 'list', data, has_more: false, url: '/v1/list' })
// Rechnungszeilen mit IDs (fuer starting_after), wie Stripe sie liefert; die Quelle behaelt alle Zeilen
const invoiceLinesOf = (invoiceId) =>
@@ -230,10 +259,13 @@ export async function startFakeStripe() {
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)
const lines = invoiceLinesOf(m[1])
const stored = invoices.get(m[1])
const expandCustomer = [...query.entries()].some(([k, v]) => k.startsWith('expand') && v === 'customer')
return [
200,
{
...invoices.get(m[1]),
...stored,
...(expandCustomer && typeof stored.customer === 'string' && customers.has(stored.customer) ? { customer: customers.get(stored.customer) } : {}),
lines: { object: 'list', data: lines.slice(0, EMBEDDED_LINES), has_more: lines.length > EMBEDDED_LINES, url: `/v1/invoices/${m[1]}/lines` },
},
]
@@ -254,6 +286,152 @@ export async function startFakeStripe() {
const preview = typeof invoicePreview === 'function' ? invoicePreview(data) : invoicePreview
return [200, preview || { object: 'invoice', lines: { object: 'list', data: [], has_more: false }, amount_due: 0, subtotal: 0, total: 0 }]
}
// Rechnungen schreiben (Finanzen): Entwurf anlegen, Posten anhaengen, finalisieren, senden, stornieren, bezahlen, loeschen
if (method === 'POST' && path === '/v1/invoices') {
const data = unflatten(body)
if (!data.customer) return invalid('customer', 'Missing required param: customer.')
const collectionMethod = data.collection_method || 'charge_automatically'
if (collectionMethod === 'send_invoice' && data.days_until_due === undefined && data.due_date === undefined) {
return invalid('days_until_due', 'Either days_until_due or due_date must be set when collection_method=send_invoice.')
}
const metadata = mergeMetadata({}, data.metadata)
if (Array.isArray(metadata)) return metadata
const customer = customers.get(data.customer)
const invoice = {
id: nextId('in'),
object: 'invoice',
number: null,
status: 'draft',
customer: data.customer,
customer_name: customer?.name ?? null,
customer_email: customer?.email ?? null,
collection_method: collectionMethod,
days_until_due: data.days_until_due !== undefined ? Number(data.days_until_due) : null,
due_date: null,
auto_advance: data.auto_advance === 'true',
pending_invoice_items_behavior: data.pending_invoice_items_behavior || 'exclude',
currency: data.currency || 'eur',
description: data.description ?? null,
footer: data.footer ?? null,
metadata,
created: nowUnix(),
total: 0,
subtotal: 0,
amount_due: 0,
amount_paid: 0,
amount_remaining: 0,
status_transitions: { finalized_at: null, paid_at: null, voided_at: null },
hosted_invoice_url: null,
invoice_pdf: null,
lines: { object: 'list', data: [], has_more: false, url: '' },
}
invoices.set(invoice.id, invoice)
return [200, invoice]
}
if (method === 'POST' && path === '/v1/invoiceitems') {
const data = unflatten(body)
if (!data.customer) return invalid('customer', 'Missing required param: customer.')
const quantity = data.quantity !== undefined ? Number(data.quantity) : 1
if (!Number.isInteger(quantity) || quantity < 0) return invalid('quantity', 'Invalid non-negative integer: quantity.')
let unit = null
if (data.unit_amount_decimal !== undefined) {
if (!/^-?\d+(\.\d+)?$/.test(data.unit_amount_decimal)) return invalid('unit_amount_decimal', `Invalid decimal: ${data.unit_amount_decimal}`)
unit = data.unit_amount_decimal
} else if (data.amount !== undefined) {
unit = String(Number(data.amount))
} else {
return invalid('amount', 'Missing required param: amount, unit_amount_decimal or pricing.')
}
const amount = Math.round(Number(unit) * (data.unit_amount_decimal !== undefined ? quantity : 1))
const item = {
id: nextId('ii'),
object: 'line_item',
description: data.description ?? null,
quantity: data.unit_amount_decimal !== undefined ? quantity : 1,
amount,
currency: data.currency || 'eur',
pricing: { type: 'price_details', unit_amount_decimal: unit },
metadata: data.metadata || {},
}
if (data.invoice) {
const invoice = invoices.get(data.invoice)
if (!invoice) return noSuch('invoice', data.invoice)
if (invoice.status !== 'draft') return invalid('invoice', 'You can only add invoice items to draft invoices.')
const total = invoice.total + amount
invoices.set(invoice.id, {
...invoice,
total,
subtotal: total,
amount_due: total,
amount_remaining: total,
lines: { object: 'list', has_more: false, ...invoice.lines, data: [...(invoice.lines?.data || []), item] },
})
item.invoice = invoice.id
} else {
item.invoice = null
}
invoiceItems.set(item.id, item)
return [200, { ...item, object: 'invoiceitem', customer: data.customer }]
}
if (method === 'POST' && (m = path.match(/^\/v1\/invoices\/([^/]+)\/(finalize|send|void|pay)$/))) {
const invoice = invoices.get(m[1])
if (!invoice) return noSuch('invoice', m[1])
const data = unflatten(body)
const at = nowUnix()
let updated
if (m[2] === 'finalize') {
if (invoice.status !== 'draft') return wrongState('finalizing', invoice, 'draft')
invoiceNumberSeq += 1
updated = {
...invoice,
status: 'open',
number: `WK-${String(invoiceNumberSeq).padStart(4, '0')}`,
auto_advance: data.auto_advance !== undefined ? data.auto_advance === 'true' : invoice.auto_advance,
due_date: invoice.collection_method === 'send_invoice' && invoice.days_until_due !== null ? at + invoice.days_until_due * 86_400 : null,
hosted_invoice_url: `https://invoice.stripe.test/i/${invoice.id}`,
invoice_pdf: `https://invoice.stripe.test/i/${invoice.id}/pdf`,
status_transitions: { ...invoice.status_transitions, finalized_at: at },
}
} else if (m[2] === 'send') {
if (invoice.status !== 'open') return wrongState('sending', invoice, 'open')
updated = { ...invoice, sent_at: at }
} else if (m[2] === 'void') {
if (invoice.status !== 'open') return wrongState('voiding', invoice, 'open')
updated = { ...invoice, status: 'void', status_transitions: { ...invoice.status_transitions, voided_at: at } }
} else {
if (invoice.status !== 'open') return wrongState('paying', invoice, 'open')
updated = {
...invoice,
status: 'paid',
paid: true,
paid_out_of_band: data.paid_out_of_band === 'true',
amount_paid: invoice.total,
amount_remaining: 0,
status_transitions: { ...invoice.status_transitions, paid_at: at },
}
}
invoices.set(invoice.id, updated)
return [200, updated]
}
if (method === 'POST' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) {
const invoice = invoices.get(m[1])
if (!invoice) return noSuch('invoice', m[1])
const data = unflatten(body)
const metadata = data.metadata ? mergeMetadata(invoice.metadata, data.metadata) : invoice.metadata
if (Array.isArray(metadata)) return metadata
const updated = { ...invoice, metadata }
invoices.set(invoice.id, updated)
return [200, updated]
}
if (method === 'DELETE' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) {
const invoice = invoices.get(m[1])
if (!invoice) return noSuch('invoice', m[1])
if (invoice.status !== 'draft') {
return invalid(undefined, 'You can only delete draft invoices. Finalized invoices must be voided or marked uncollectible instead.')
}
invoices.delete(m[1])
return [200, { id: m[1], object: 'invoice', deleted: true }]
}
// scripts/stripe-products.mjs --pruefen liest das Produkt zurueck (Beschreibung, Bild)
if (method === 'GET' && (m = path.match(/^\/v1\/products\/([^/]+)$/))) {
return products.has(m[1]) ? [200, products.get(m[1])] : noSuch('product', m[1])
@@ -412,6 +590,7 @@ export async function startFakeStripe() {
billingPortalConfigurations,
customers,
payouts,
invoiceItems,
checkoutSessions,
/**
* Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. latest_invoice). Mit