Wechsel Hosting → Wartung: neuer Abbuchungstag, Gutschrift, 12 neue Monate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-28 11:30:21 +00:00
1 parent 379cc26dcd
commit 188db4b3c1
15 files changed
+2035 -12

No files matched your search

+49 -5
View File
@@ -7,10 +7,22 @@ import http from 'node:http'
*
* 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
* in die nächste Antwort gemischt (z. B. latest_invoice). Enthält er pending_update, bleibt das Abo wie bei
* Stripe unverändert (ausstehende Aktualisierung, Task 9), bis st.applyPendingUpdate(id) die Änderung
* übernimmt. Wie Stripe: mit payment_behavior=pending_if_incomplete nur die dafür erlaubten Parameter,
* Metadaten-Schlüssel höchstens 40 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).
*/
// Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/
// pending-updates, "Unterstützte Attribute", geprüft am 28.09.2026); items[i][id] verweist auf die Position
const PENDING_UPDATE_PARAMS = new Set([
'expand', 'payment_behavior', 'proration_behavior', 'proration_date', 'billing_cycle_anchor', 'items', 'trial_end',
'trial_from_plan', 'metadata', 'discounts', 'cancel_at_period_end', 'coupon', 'promotion_code', 'add_invoice_items',
])
const PENDING_UPDATE_ITEM_PARAMS = new Set(['id', 'price', 'quantity', 'discounts'])
export async function startFakeStripe() {
const subscriptions = new Map()
const prices = new Map()
@@ -20,6 +32,8 @@ export async function startFakeStripe() {
const billingPortalConfigurations = new Map()
const customers = 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
let nextSubscriptionUpdate = null
@@ -70,6 +84,18 @@ export async function startFakeStripe() {
const existing = subscriptions.get(m[1])
if (!existing) return noSuch('subscription', m[1])
const data = unflatten(body)
// Wie Stripe: mit pending_if_incomplete nur die Parameter, die ausstehende Aktualisierungen kennen
if (data.payment_behavior === 'pending_if_incomplete') {
const param =
Object.keys(data).find((key) => !PENDING_UPDATE_PARAMS.has(key)) ||
(data.items || []).flatMap((entry, i) => Object.keys(entry).filter((key) => !PENDING_UPDATE_ITEM_PARAMS.has(key)).map((key) => `items[${i}][${key}]`))[0]
if (param) return invalid(param, `Received unsupported parameter ${param} with payment_behavior=pending_if_incomplete.`)
}
// Wie Stripe: Metadaten-Schluessel hoechstens 40, Werte hoechstens 500 Zeichen
for (const [key, value] of Object.entries(data.metadata || {})) {
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 items = (existing.items?.data || []).map((item) => ({ ...item }))
for (const [i, entry] of (data.items || []).entries()) {
if (entry.id) {
@@ -110,11 +136,17 @@ export async function startFakeStripe() {
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
const extra = nextSubscriptionUpdate
nextSubscriptionUpdate = null
return [200, response]
if (extra?.pending_update) {
// Zahlung nicht erfolgt (Task 9): Stripe haelt die Aenderung in pending_update zurueck, das Abo bleibt
// unveraendert, bis die Rechnung bezahlt ist (im Test: applyPendingUpdate)
pendingUpdates.set(m[1], updated)
return [200, { ...existing, ...extra }]
}
subscriptions.set(m[1], updated)
return [200, extra ? { ...updated, ...extra } : updated]
}
if (method === 'GET' && (m = path.match(/^\/v1\/prices\/([^/]+)$/))) {
return prices.has(m[1]) ? [200, prices.get(m[1])] : noSuch('price', m[1])
@@ -322,13 +354,25 @@ export async function startFakeStripe() {
billingPortalConfigurations,
customers,
checkoutSessions,
/** Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. pending_update). */
/**
* Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. latest_invoice). Mit
* pending_update bleibt das Abo wie bei Stripe unveraendert, bis applyPendingUpdate(id) die Aenderung
* uebernimmt.
*/
get nextSubscriptionUpdate() {
return nextSubscriptionUpdate
},
set nextSubscriptionUpdate(value) {
nextSubscriptionUpdate = value
},
/** Zurueckgehaltene Aenderung uebernehmen (= Wechselrechnung bezahlt, Stripe wendet pending_update an). */
applyPendingUpdate(id) {
const updated = pendingUpdates.get(id)
if (!updated) throw new Error(`Stripe-Nachbau: keine ausstehende Aktualisierung fuer ${id}`)
pendingUpdates.delete(id)
subscriptions.set(id, updated)
return updated
},
/** Antwort von POST /v1/invoices/create_preview: Objekt oder Funktion (body) => Objekt; null = leere Vorschau. */
get invoicePreview() {
return invoicePreview
+21
View File
@@ -0,0 +1,21 @@
import nodemailer from 'nodemailer'
/**
* Mail-Senke für Tests (F25): nodemailer-jsonTransport statt SMTP. Nach
* setMailTransportForTests(mailSink()) (server/services/mailer.js)
* landet jede Mail in sent, in der Reihenfolge des Versands, als das Objekt, das nodemailer daraus
* erzeugt: from { address, name }, to/bcc [{ address, name }], subject, text, html, attachments
* (Inhalt base64). Es verlässt nichts den Prozess.
*/
export function mailSink() {
const json = nodemailer.createTransport({ jsonTransport: true })
const sent = []
return {
sent,
async sendMail(mail) {
const info = await json.sendMail(mail)
sent.push(JSON.parse(info.message))
return info
},
}
}