Wechsel Hosting → Wartung: neuer Abbuchungstag, Gutschrift, 12 neue Monate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
379cc26dcd
commit
188db4b3c1
15 files changed
+2035
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user