Zusatzspeicher: Plus sofort anteilig, Minus zum Monatsende, Aufgaben für konsoleH
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
887e1e54a0
commit
379cc26dcd
11 files changed
+1531
-27
No files matched your search
+103
-1
@@ -4,6 +4,12 @@ import http from 'node:http'
|
||||
* Stripe-Nachbau für Tests. stripe-node spricht ihn über config.stripe.apiBase (STRIPE_API_BASE)
|
||||
* an. Objekte werden vorab in die Maps gelegt; jeder Aufruf landet in calls als
|
||||
* { method, path, query, body } (Formular-Schlüssel flach, z. B. 'items[0][price]').
|
||||
*
|
||||
* Abo-Änderungen (F17): POST /v1/subscriptions/:id mischt items[i][id|price|quantity|deleted] und
|
||||
* metadata (leerer Wert löscht) in das gespeicherte Abo; st.nextSubscriptionUpdate wird einmalig nur
|
||||
* in die nächste Antwort gemischt (z. B. pending_update, latest_invoice). 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).
|
||||
*/
|
||||
export async function startFakeStripe() {
|
||||
const subscriptions = new Map()
|
||||
@@ -16,11 +22,18 @@ export async function startFakeStripe() {
|
||||
const checkoutSessions = new Map()
|
||||
const calls = []
|
||||
let seq = 0
|
||||
let nextSubscriptionUpdate = null
|
||||
let invoicePreview = null
|
||||
const nextId = (prefix) => `${prefix}_test_${(seq += 1)}`
|
||||
|
||||
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 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) =>
|
||||
(invoices.get(invoiceId)?.lines?.data || []).map((line, i) => ({ id: `li_${invoiceId}_${i + 1}`, object: 'line_item', ...line }))
|
||||
const EMBEDDED_LINES = 10
|
||||
|
||||
const readBody = (req) =>
|
||||
new Promise((resolve) => {
|
||||
@@ -53,6 +66,56 @@ export async function startFakeStripe() {
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
|
||||
return subscriptions.has(m[1]) ? [200, subscriptions.get(m[1])] : noSuch('subscription', m[1])
|
||||
}
|
||||
if (method === 'POST' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
|
||||
const existing = subscriptions.get(m[1])
|
||||
if (!existing) return noSuch('subscription', m[1])
|
||||
const data = unflatten(body)
|
||||
const items = (existing.items?.data || []).map((item) => ({ ...item }))
|
||||
for (const [i, entry] of (data.items || []).entries()) {
|
||||
if (entry.id) {
|
||||
// Vorhandene Position: Menge/Preis aendern oder entfernen
|
||||
const index = items.findIndex((item) => item.id === entry.id)
|
||||
if (index < 0) return invalid(`items[${i}][id]`, `No such subscription_item: '${entry.id}'`)
|
||||
if (entry.deleted === 'true') {
|
||||
items.splice(index, 1)
|
||||
continue
|
||||
}
|
||||
if (entry.price) {
|
||||
if (!prices.has(entry.price)) return invalid(`items[${i}][price]`, `No such price: '${entry.price}'`)
|
||||
items[index].price = prices.get(entry.price)
|
||||
}
|
||||
if (entry.quantity !== undefined) items[index].quantity = Number(entry.quantity)
|
||||
} else if (entry.price) {
|
||||
// Neue Position per Preis; wie Stripe: derselbe Preis hoechstens einmal je Abo
|
||||
if (!prices.has(entry.price)) return invalid(`items[${i}][price]`, `No such price: '${entry.price}'`)
|
||||
if (items.some((item) => item.price?.id === entry.price)) {
|
||||
return invalid(`items[${i}][price]`, 'You cannot combine multiple subscription items with the same price.')
|
||||
}
|
||||
items.push({
|
||||
id: nextId('si'),
|
||||
object: 'subscription_item',
|
||||
quantity: entry.quantity !== undefined ? Number(entry.quantity) : 1,
|
||||
price: prices.get(entry.price),
|
||||
})
|
||||
} else {
|
||||
return invalid(`items[${i}]`, 'Each subscription item must have an id or a price.')
|
||||
}
|
||||
}
|
||||
// Metadaten wie bei Stripe zusammenfuehren: leerer Wert loescht den Schluessel
|
||||
const metadata = { ...(existing.metadata || {}) }
|
||||
for (const [key, value] of Object.entries(data.metadata || {})) {
|
||||
if (value === '') delete metadata[key]
|
||||
else metadata[key] = value
|
||||
}
|
||||
const updated = { ...existing, items: { object: 'list', ...(existing.items || {}), data: items }, metadata }
|
||||
if (data.cancel_at_period_end !== undefined) updated.cancel_at_period_end = data.cancel_at_period_end === 'true'
|
||||
if (data.cancel_at !== undefined) updated.cancel_at = data.cancel_at === '' ? null : Number(data.cancel_at)
|
||||
subscriptions.set(m[1], updated)
|
||||
// Einmaliger Zusatz nur fuer diese Antwort (pending_update, latest_invoice, ...), nicht gespeichert
|
||||
const response = nextSubscriptionUpdate ? { ...updated, ...nextSubscriptionUpdate } : updated
|
||||
nextSubscriptionUpdate = null
|
||||
return [200, response]
|
||||
}
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/prices\/([^/]+)$/))) {
|
||||
return prices.has(m[1]) ? [200, prices.get(m[1])] : noSuch('price', m[1])
|
||||
}
|
||||
@@ -90,7 +153,32 @@ export async function startFakeStripe() {
|
||||
return [200, obj]
|
||||
}
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) {
|
||||
return invoices.has(m[1]) ? [200, invoices.get(m[1])] : noSuch('invoice', m[1])
|
||||
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])
|
||||
return [
|
||||
200,
|
||||
{
|
||||
...invoices.get(m[1]),
|
||||
lines: { object: 'list', data: lines.slice(0, EMBEDDED_LINES), has_more: lines.length > EMBEDDED_LINES, url: `/v1/invoices/${m[1]}/lines` },
|
||||
},
|
||||
]
|
||||
}
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)\/lines$/))) {
|
||||
if (!invoices.has(m[1])) return noSuch('invoice', m[1])
|
||||
const lines = invoiceLinesOf(m[1])
|
||||
const limit = Math.min(Number(query.get('limit')) || 10, 100)
|
||||
const after = query.get('starting_after')
|
||||
const afterIndex = after ? lines.findIndex((line) => line.id === after) : -1
|
||||
if (after && afterIndex < 0) return invalid('starting_after', `No such line item: '${after}'`)
|
||||
const start = afterIndex + 1
|
||||
return [200, { object: 'list', data: lines.slice(start, start + limit), has_more: start + limit < lines.length, url: `/v1/invoices/${m[1]}/lines` }]
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/invoices/create_preview') {
|
||||
const data = unflatten(body)
|
||||
if (data.subscription && !subscriptions.has(data.subscription)) return noSuch('subscription', data.subscription)
|
||||
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 }]
|
||||
}
|
||||
// scripts/stripe-products.mjs --pruefen liest das Produkt zurueck (Beschreibung, Bild)
|
||||
if (method === 'GET' && (m = path.match(/^\/v1\/products\/([^/]+)$/))) {
|
||||
@@ -234,6 +322,20 @@ export async function startFakeStripe() {
|
||||
billingPortalConfigurations,
|
||||
customers,
|
||||
checkoutSessions,
|
||||
/** Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. pending_update). */
|
||||
get nextSubscriptionUpdate() {
|
||||
return nextSubscriptionUpdate
|
||||
},
|
||||
set nextSubscriptionUpdate(value) {
|
||||
nextSubscriptionUpdate = value
|
||||
},
|
||||
/** Antwort von POST /v1/invoices/create_preview: Objekt oder Funktion (body) => Objekt; null = leere Vorschau. */
|
||||
get invoicePreview() {
|
||||
return invoicePreview
|
||||
},
|
||||
set invoicePreview(value) {
|
||||
invoicePreview = value
|
||||
},
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user