Finanzen: Projektrechnungen in Stripe anlegen, senden, stornieren, Entwurf loeschen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
db243f50bb
commit
05e3478229
4 files changed
+748
-4
No files matched your search
+180
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user