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:
ClaudeandClaude Fable 5.1 committed 2026-09-28 10:48:29 +00:00
1 parent 887e1e54a0
commit 379cc26dcd
11 files changed
+1531 -27

No files matched your search

+13 -8
View File
@@ -5,8 +5,9 @@ import http from 'node:http'
* Speicher. Mit schema = { collection: ['attr', ...] } lehnt er unbekannte Attribute ab wie
* Appwrite ("Invalid document structure: Unknown attribute"). Dokument-IDs prüft er wie Appwrite
* (höchstens 36 Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich, kein Sonderzeichen am
* Anfang). failNext({ collection?, method?, status, message? }) lässt die nächste passende Anfrage
* mit diesem Status scheitern (Fehlerpfade wie "Appwrite nicht erreichbar" oder "Collection fehlt").
* Anfang). failNext({ collection?, method?, status, message?, after? }) lässt die nächste passende Anfrage
* mit diesem Status scheitern (Fehlerpfade wie "Appwrite nicht erreichbar" oder "Collection fehlt");
* after = Zahl passender Anfragen, die vorher noch durchgehen (z. B. erst die zweite PATCH scheitert).
*/
export async function startAppwriteMock({ schema = {} } = {}) {
const collections = new Map()
@@ -104,9 +105,13 @@ export async function startAppwriteMock({ schema = {} } = {}) {
(!pendingFailure.collection || pendingFailure.collection === collection) &&
(!pendingFailure.method || pendingFailure.method === req.method)
) {
const { status, message } = pendingFailure
pendingFailure = null
return [status, { message: message || `Nachbau: eingeplanter Fehler ${status}`, code: status, type: 'general_server_error' }]
if (pendingFailure.after > 0) {
pendingFailure.after -= 1
} else {
const { status, message } = pendingFailure
pendingFailure = null
return [status, { message: message || `Nachbau: eingeplanter Fehler ${status}`, code: status, type: 'general_server_error' }]
}
}
if (!id && req.method === 'GET') return list(collection, url.searchParams.getAll('queries[]'))
@@ -155,9 +160,9 @@ export async function startAppwriteMock({ schema = {} } = {}) {
url: `http://127.0.0.1:${server.address().port}/v1`,
docs: (collection) => new Map([...docs(collection)].map(([id, doc]) => [id, publicDoc(doc)])),
seed,
/** Naechste passende Dokument-Anfrage scheitert mit status (einmalig). */
failNext: ({ collection = null, method = null, status = 500, message = '' }) => {
pendingFailure = { collection, method, status, message }
/** Naechste passende Dokument-Anfrage scheitert mit status (einmalig); after passende Anfragen gehen vorher durch. */
failNext: ({ collection = null, method = null, status = 500, message = '', after = 0 }) => {
pendingFailure = { collection, method, status, message, after }
},
close: () => new Promise((resolve) => server.close(resolve)),
}
+103 -1
View File
@@ -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)),
}
}
+3 -1
View File
@@ -10,7 +10,9 @@ import { config } from '../../server/config.js'
*/
const DAY_MS = 86400000
const daysFromNow = (days) => new Date(Date.now() + days * DAY_MS).toISOString()
// Sekundengenau wie Stripe (unix-Zeitstempel): der Spiegel schreibt toIso(unix) und trifft so die
// Fixture-Werte wortgleich (z. B. storagePaidPeriodEnd = currentPeriodEnd nach einem Speicher-Plus)
const daysFromNow = (days) => new Date(Math.floor((Date.now() + days * DAY_MS) / 1000) * 1000).toISOString()
export const customers = {
c1: { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true },