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

+18
View File
@@ -19,6 +19,7 @@ const {
completeContractEvent,
updateContractEvent,
eventIdFor,
getContractEvent,
} = await import('../server/services/contractEvents.js')
test('Ereignis speichern und lesen (details als JSON)', async () => {
@@ -124,6 +125,23 @@ test('Ereignis aktualisieren: Status setzen, details zusammenfuehren', async ()
assert.deepEqual(JSON.parse(updated.details), { von: 'wartung', zu: 'hosting', grund: 'Zahlung fehlgeschlagen' })
})
// Task 9: ein einzelnes Ereignis lesen (markSwitchFailed, Skript fuers Team), details geparst wie in der Liste
test('Einzelnes Ereignis lesen: details geparst, unbekannte id 404', async () => {
const created = await recordContractEvent({
customerId: 'c5',
subscriptionId: 'sub_5',
type: 'wechsel',
productKey: 'wartung',
details: { from: 'hosting', paymentUrl: 'https://pay.example/inv' },
status: 'offen',
})
const event = await getContractEvent(created.$id)
assert.equal(event.$id, created.$id)
assert.equal(event.status, 'offen')
assert.deepEqual(event.details, { from: 'hosting', paymentUrl: 'https://pay.example/inv' })
await assert.rejects(getContractEvent('gibt-es-nicht'), (e) => e.status === 404)
})
// F05: dieselbe id zweimal speichern (z. B. Webhook-Wiederholung) ist kein Fehler, sondern liefert
// das schon vorhandene Dokument zurueck, ohne ein zweites anzulegen
test('Ereignis mit vorhandener id (409): liefert das schon gespeicherte Dokument', async () => {
+52 -1
View File
@@ -4,15 +4,19 @@ import { test } from 'node:test'
import { escapeHtml, htmlToText, loadTemplate, renderMail, templateIds } from '../server/services/mailTemplates.js'
const DIR = new URL('../server/mail-templates/', import.meta.url)
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9; K13/T13 und die Endfassung der Texte folgen in Task 12
const EXPECTED = [
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
'K12',
'K14',
...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`),
'T12',
]
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
const allBlocks = (tpl, on) => Object.fromEntries(tpl.blocks.map((b) => [b, on]))
test('alle 22 Vorlagen sind da', () => {
test('alle Vorlagen sind da', () => {
assert.deepEqual(templateIds(), EXPECTED)
})
@@ -149,3 +153,50 @@ test('T02: Block nachweis_fehlt weist auf den fehlenden Nachweis hin', () => {
assert.match(withBlock.text, /contractEvents/)
assert.doesNotMatch(renderMail('T02', values, {}).text, /Nachweis nicht gespeichert/)
})
// Task 9 (Rechtsrunde 28.09.2026): Wechsel ins Wartungs-Abo wird erst mit der Zahlung wirksam
test('K12, K14, T12: Betreff, Platzhalter und Blöcke des Wechsels', () => {
assert.equal(loadTemplate('K12').subject, 'Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung')
assert.deepEqual(loadTemplate('K12').placeholders, ['name', 'wechseltag', 'betrag_heute', 'monatspreis', 'mindestlaufzeit_bis', 'bedingungen_version', 'zustimmung_zeitpunkt'])
assert.equal(loadTemplate('K14').subject, 'Ihr Wechsel ins WEBklar Wartungs-Abo ist nicht zustande gekommen')
assert.deepEqual(loadTemplate('K14').placeholders, ['name', 'grund'])
assert.equal(loadTemplate('T12').subject, '[Abo] Wechsel ins Wartungs-Abo: {{email}}')
// F37: feste Platzhalter fuer T12
assert.deepEqual(loadTemplate('T12').placeholders, ['email', 'name', 'stripe_abo_id', 'wechseltag', 'betrag_heute', 'monatspreis'])
for (const block of ['gescheitert', 'lastschrift_gescheitert']) assert.ok(loadTemplate('T12').blocks.includes(block), block)
})
test('K12: Block zahlung_offen wörtlich, Block lastschrift mit dem Satz aus § 9 Abs. 3', () => {
const values = sampleValues(loadTemplate('K12'))
const offen = renderMail('K12', values, { zahlung_offen: true })
assert.ok(offen.text.includes('Der Wechsel wird wirksam, sobald die Zahlung bestätigt ist. Andernfalls läuft Ihr Hosting-Abo mit Preis und Mindestlaufzeit wie bisher weiter.'))
assert.doesNotMatch(offen.text, /SEPA-Lastschrift/)
const sepa = renderMail('K12', values, { lastschrift: true })
assert.match(sepa.text, /Wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt/)
assert.match(sepa.text, /Ihr Abbuchungstag bleibt der Wechseltag/)
assert.doesNotMatch(sepa.text, /sobald die Zahlung bestätigt ist/)
const plain = renderMail('K12', values, {})
assert.doesNotMatch(plain.text, /sobald die Zahlung bestätigt ist|Wird die Lastschrift nicht eingelöst/)
// Vertragsbestaetigung: Preis mit § 19 UStG, Mindestlaufzeit, Unterlagen, Zustimmung zum Sofortbeginn
for (const text of ['gemäß § 19 UStG ohne Umsatzsteuer', 'Mindestlaufzeit 12 Monate bis Wert-mindestlaufzeit_bis.', 'Widerrufsbelehrung mit Muster-Widerrufsformular', 'Sie haben ausdrücklich verlangt, dass wir vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnen']) {
assert.ok(plain.text.includes(text), text)
}
})
test('K14: Hosting-Abo läuft wie vor dem Wechsel weiter; SEPA: Abbuchungstag bleibt', () => {
const values = { name: 'Kunde', grund: 'Die SEPA-Lastschrift für die Wechselrechnung wurde nicht eingelöst.' }
const card = renderMail('K14', values, {})
assert.deepEqual(card.problems, [])
assert.ok(card.text.includes('Ihr Hosting-Abo läuft mit Preis und Mindestlaufzeit wie vor dem Wechsel weiter.'))
assert.ok(card.text.includes(values.grund))
assert.doesNotMatch(card.text, /Abbuchungstag/)
assert.match(renderMail('K14', values, { lastschrift: true }).text, /Ihr Abbuchungstag bleibt der Tag des Wechsels\./)
})
// Nach der Pruefung der Rechtsrunden-Texte: Stripe kann die Frist auch kuerzer setzen
test('Wechsel-Mails nennen keine feste Frist von 23 Stunden', () => {
for (const id of ['K12', 'K14', 'T12']) {
const tpl = loadTemplate(id)
assert.doesNotMatch(renderMail(id, sampleValues(tpl), allBlocks(tpl, true)).text, /23 Stunden/, id)
}
})
+877
View File
@@ -0,0 +1,877 @@
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
const aw = await startAppwriteMock({
schema: {
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
hourCredits: schemaKeys('hourCredits'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
// Testkonto (Testrabatte): Kunde c4 der Fixtures
process.env.ABO_TEST_EMAILS = 'test@example.invalid'
const { config } = await import('../server/config.js')
const { startTestApp, get, post, postWebhook } = await import('./support/testApp.js')
const { hostingDoc, hosting2Doc, consent, customerCookie, viewAsCookie, seedFixtures, stripeSubscription, subscriptionDoc } =
await import('./support/fixtures.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const { previewSwitch, switchToWartung, markSwitchFailed, previewProblem, paymentMethodTypeOf, SWITCH_FAILED_REASONS } =
await import('../server/services/planSwitch.js')
const { listContractEvents, recordContractEvent } = await import('../server/services/contractEvents.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { addMonths } = await import('../server/services/stripeBilling.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { parseArgs, run: runFailedSwitchScript } = await import('../scripts/wechsel-gescheitert.mjs')
const app = await startTestApp({ '/api/abo': aboRoutes })
// F25: Mails landen in einer Senke (nodemailer jsonTransport) statt bei SMTP, damit K12, K14 und T12 pruefbar sind
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
// Preise im Stripe-Nachbau mit den lookup_keys des Katalogs (ensurePrice findet sie, die Fixture
// stripeSubscription() haengt dieselben IDs an die Abo-Posten)
const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', active: true, lookup_key, unit_amount, recurring: { interval: 'month' } })
st.prices.set('price_hosting', price('price_hosting', config.products.hosting.lookupKey, config.products.hosting.amountCents))
st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lookupKey, config.products.wartung.amountCents))
st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage.lookupKey, config.mailStorage.amountCents))
const nowUnix = () => Math.floor(Date.now() / 1000)
const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000)
// Standard-Zahlungsmethoden, wie subscriptions.retrieve sie mit expand liefert (F24)
const CARD = { id: 'pm_card_visa', object: 'payment_method', type: 'card' }
const SEPA = { id: 'pm_sepa_de', object: 'payment_method', type: 'sepa_debit' }
const K12_SUBJECT = 'Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung'
const K14_SUBJECT = 'Ihr Wechsel ins WEBklar Wartungs-Abo ist nicht zustande gekommen'
const t12Subject = (email) => `[Abo] Wechsel ins Wartungs-Abo: ${email}`
// Wortlaut aus der Rechtsrunde (Task 9, Block zahlung_offen)
const ZAHLUNG_OFFEN =
'Der Wechsel wird wirksam, sobald die Zahlung bestätigt ist. Andernfalls läuft Ihr Hosting-Abo mit Preis und Mindestlaufzeit wie bisher weiter.'
const LASTSCHRIFT = 'Wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt'
const WIRKSAM = 'Der Wechsel ist wirksam'
const HOSTING_LAEUFT_WEITER = 'Ihr Hosting-Abo läuft mit Preis und Mindestlaufzeit wie vor dem Wechsel weiter.'
async function clearEvents(customerId) {
for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id)
}
/**
* Hosting-Abo in Spiegel und Stripe-Nachbau auf den Ausgangsstand setzen (Standard: sub_h von c1, Karte),
* Ereignisse des Kunden und die Mail-Senke leeren. patch aendert den Spiegel, stripe das Stripe-Abo.
*/
async function resetHosting({ id = 'sub_h', customerId = 'c1', mirror = hostingDoc, patch = {}, stripe = {}, paymentMethod = CARD } = {}) {
await clearEvents(customerId)
aw.seed('subscriptions', { ...mirror, ...patch })
st.subscriptions.set(id, stripeSubscription({ id, customerId, productKey: 'hosting', default_payment_method: paymentMethod, ...stripe }))
st.nextSubscriptionUpdate = null
mails.sent.length = 0
}
// Rechnungszeilen in der Form der SDK-API-Version (2025-10-29.clover)
const line = (priceId, amount, { proration = false, subscription = 'sub_h', periodStart = null } = {}) => ({
amount,
quantity: 1,
period: { start: periodStart ?? unix(hostingDoc.startedAt), end: unix(hostingDoc.currentPeriodEnd) },
pricing: { type: 'price_details', price_details: { price: priceId } },
parent: { type: 'subscription_item_details', subscription_item_details: { proration, subscription } },
})
const previewInvoice = (...lines) => ({
object: 'invoice',
lines: { object: 'list', data: lines, has_more: false },
amount_due: lines.reduce((sum, l) => sum + l.amount, 0),
})
// Wechselrechnung, wie subscriptions.update sie mit expand: ['latest_invoice'] liefert
const switchInvoice = (overrides = {}) => ({
id: 'in_wechsel',
object: 'invoice',
status: 'paid',
amount_due: 4100,
billing_reason: 'subscription_update',
hosted_invoice_url: 'https://invoice.stripe.test/i/in_wechsel',
...overrides,
})
// Stripe haelt die Aenderung zurueck, bis die Rechnung bezahlt ist (Karte, z. B. 3-D Secure)
const pendingUpdate = () => ({ expires_at: nowUnix() + 23 * 3600, billing_cycle_anchor: nowUnix(), subscription_items: [], trial_end: null, trial_from_plan: null })
const paidInvoice = (id, subscription = 'sub_h') => ({
id,
object: 'invoice',
number: `WK-${id}`,
status: 'paid',
billing_reason: 'subscription_update',
amount_due: 4100,
created: nowUnix(),
status_transitions: { paid_at: nowUnix() },
parent: { type: 'subscription_details', subscription_details: { subscription } },
lines: { object: 'list', data: [line('price_hosting', -900, { proration: true, periodStart: nowUnix(), subscription }), line('price_wartung', 5000, { subscription })] },
})
const webhookEvent = (type, object) => ({
id: `evt_${Math.random().toString(36).slice(2, 10)}`,
object: 'event',
type,
created: nowUnix(),
data: { object },
})
const updateCalls = (id = 'sub_h', from = 0) => st.calls.slice(from).filter((c) => c.method === 'POST' && c.path === `/v1/subscriptions/${id}`)
const previewCalls = (from = 0) => st.calls.slice(from).filter((c) => c.method === 'POST' && c.path === '/v1/invoices/create_preview')
const mailsWithSubject = (subject) => mails.sent.filter((m) => m.subject === subject)
const recipients = (mail) => mail.to.map((t) => t.address)
async function withCapturedLogs(fn) {
const logs = { error: [], warn: [] }
const original = { error: console.error, warn: console.warn }
console.error = (...args) => logs.error.push(args.join(' '))
console.warn = (...args) => logs.warn.push(args.join(' '))
try {
await fn(logs)
} finally {
console.error = original.error
console.warn = original.warn
}
return logs
}
// ---------------------------------------------------------------------------
// Vorschau
// ---------------------------------------------------------------------------
test('Vorschau: Gutschrift und heute fällig aus der Stripe-Vorschau', async () => {
await resetHosting()
// Stripe-Nachbau: POST /v1/invoices/create_preview liefert die Zeilen -900 (anteilig) und 5000
st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000))
const before = st.calls.length
const p = await previewSwitch('c1')
assert.equal(p.creditCents, 900); assert.equal(p.amountDueCents, 4100); assert.equal(p.monthlyCents, 5000)
assert.ok(Math.abs(p.prorationDate - nowUnix()) <= 5, 'Vorschau-Zeitpunkt jetzt')
// Neuer Abbuchungstag = Wechseltag, neue Mindestlaufzeit 12 Monate ab dem Wechseltag
assert.equal(p.nextChargeAt, addMonths(new Date(p.prorationDate * 1000), 1).toISOString())
assert.equal(p.minTermEnd, addMonths(new Date(p.prorationDate * 1000), 12).toISOString())
const [call] = previewCalls(before)
assert.equal(call.body['subscription'], 'sub_h')
assert.equal(call.body['subscription_details[items][0][id]'], 'si_sub_h_abo')
assert.equal(call.body['subscription_details[items][0][price]'], 'price_wartung')
assert.equal(call.body['subscription_details[billing_cycle_anchor]'], 'now')
assert.equal(call.body['subscription_details[proration_behavior]'], 'create_prorations')
assert.equal(call.body['subscription_details[proration_date]'], String(p.prorationDate))
assert.equal(call.body['discounts[0][coupon]'], undefined, 'kein Testkonto: Rabatte wie am Abo')
// Eine Vorschau aendert nichts bei Stripe
assert.deepEqual(updateCalls('sub_h', before), [])
})
test('Vorschau: Gutschrift nur aus den anteiligen Zeilen dieses Wechsels (auch Speicher), ältere zählen nicht', async () => {
await resetHosting({ stripe: { storageUnits: 2 } })
const now = nowUnix()
st.invoicePreview = previewInvoice(
// Speicher-Plus von vorhin (schon vorgemerkte Rechnungsposition): im Betrag, aber keine Gutschrift dieses Wechsels
line('price_mailStorage', -30, { proration: true, periodStart: now - 3 * 3600 }),
line('price_mailStorage', 90, { proration: true, periodStart: now - 3 * 3600 }),
line('price_hosting', -900, { proration: true, periodStart: now }),
line('price_mailStorage', -120, { proration: true, periodStart: now }),
line('price_wartung', 5000),
line('price_mailStorage', 200)
)
const p = await previewSwitch('c1')
assert.equal(p.creditCents, 1020)
assert.equal(p.amountDueCents, 5000 + 200 - 900 - 120 - 30 + 90)
})
test('Vorschau: Voraussetzungen wie beim Wechsel (409), kein Abo 404, ohne Stripe-Vorschau', async () => {
const before = st.calls.length
for (const patch of [{ cancelAtPeriodEnd: true }, { productKey: 'wartung' }, { status: 'trialing' }, { status: 'past_due' }]) {
await resetHosting({ patch })
await assert.rejects(previewSwitch('c1'), (e) => e.status === 409, JSON.stringify(patch))
}
await assert.rejects(previewSwitch('c3'), (e) => e.status === 404)
assert.deepEqual(previewCalls(before), [])
})
// ---------------------------------------------------------------------------
// Wechsel: Karte, Karte mit offener Zahlung, SEPA-Lastschrift (Rechtsrunde 28.09.2026, F23/F24)
// ---------------------------------------------------------------------------
test('Wechsel: Stripe-Aufruf mit Anker jetzt, Metadaten, Ereignis', async () => {
await resetHosting({ paymentMethod: CARD })
// Karte, Zahlung sofort erfolgreich: Stripe wendet die Aenderung an, die Wechselrechnung ist bezahlt
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
const prorationDate = nowUnix()
const before = st.calls.length
let r
const logs = await withCapturedLogs(async () => {
r = await switchToWartung('c1', consent, prorationDate)
})
const call = st.calls.findLast((c) => c.path === '/v1/subscriptions/sub_h' && c.method === 'POST')
assert.equal(call.body['billing_cycle_anchor'], 'now')
assert.equal(call.body['metadata[productKey]'], 'wartung')
assert.equal(call.body['metadata[switchedFrom]'], 'hosting')
assert.equal((await listContractEvents('c1'))[0].type, 'wechsel')
assert.equal(r.paymentUrl, null)
// F23: Karte -> pending_if_incomplete; Preis der Abo-Position, Gutschrift ab dem Zeitpunkt der Vorschau
assert.equal(call.body['payment_behavior'], 'pending_if_incomplete')
assert.equal(call.body['proration_behavior'], 'create_prorations')
assert.equal(call.body['proration_date'], String(prorationDate))
assert.equal(call.body['items[0][id]'], 'si_sub_h_abo')
assert.equal(call.body['items[0][price]'], 'price_wartung')
assert.equal(call.body['expand[0]'], 'latest_invoice')
assert.equal(call.body['discounts[0][coupon]'], undefined)
assert.equal(call.body['discounts[0][discount]'], undefined)
// Metadaten: neue Mindestlaufzeit ab dem Wechseltag, Nachweis der Zustimmung
const switchedAt = call.body['metadata[switchedAt]']
assert.ok(Math.abs(new Date(switchedAt).getTime() - Date.now()) < 5000, 'Wechsel jetzt')
assert.equal(call.body['metadata[termStartAt]'], switchedAt)
assert.equal(call.body['metadata[termsVersion]'], consent.termsVersion)
assert.equal(call.body['metadata[termsAcceptedAt]'], consent.consentAt)
assert.equal(call.body['metadata[earlyStartConsentAt]'], consent.earlyStartConsentAt)
assert.equal(call.body['metadata[consentIp]'], '127.0.0.1')
assert.equal(call.body['metadata[consentUserAgent]'], 'test')
// F24: Zahlart aus der Standard-Zahlungsmethode (Abo, sonst Kunde)
const retrieve = st.calls.slice(before).find((c) => c.method === 'GET' && c.path === '/v1/subscriptions/sub_h')
const expand = Object.entries(retrieve.query).filter(([k]) => k.startsWith('expand')).map(([, v]) => v)
assert.ok(expand.includes('default_payment_method'))
assert.ok(expand.includes('customer.invoice_settings.default_payment_method'))
// Ereignis: bezahlt und wirksam -> erledigt; Nachweis und Angaben fuer ein spaeteres Zuruecksetzen (F27)
const [ev] = await listContractEvents('c1')
assert.equal(ev.status, 'erledigt')
assert.equal(ev.subscriptionId, 'sub_h')
assert.equal(ev.productKey, 'wartung')
assert.equal(ev.termsVersion, consent.termsVersion)
assert.equal(ev.consentAt, consent.consentAt)
assert.equal(ev.earlyStartConsentAt, consent.earlyStartConsentAt)
assert.equal(ev.consentIp, '127.0.0.1')
assert.equal(ev.consentUserAgent, 'test')
assert.equal(ev.$id.length, 36)
assert.equal(ev.details.from, 'hosting')
assert.equal(ev.details.amountDueCents, 4100)
assert.equal(ev.details.prorationDate, prorationDate)
assert.equal(ev.details.switchedAt, switchedAt)
assert.equal(ev.details.paymentMethod, 'card')
assert.equal(ev.details.invoiceId, 'in_wechsel')
assert.equal(ev.details.paymentUrl, null)
assert.equal(ev.details.effectiveAt, switchedAt)
// Mindestlaufzeit des Hosting-Abos vor dem Wechsel (ohne termStartAt: Abo-Start) und alte Metadaten
assert.equal(ev.details.previousTermStartAt, hostingDoc.startedAt)
assert.deepEqual(ev.details.previousMetadata, { productKey: 'hosting', termStartAt: null, switchedAt: null, switchedFrom: null })
// Spiegel: Wartungs-Abo, Mindestlaufzeit ab dem Wechseltag
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.productKey, 'wartung')
assert.equal(doc.termStartAt, switchedAt)
assert.equal(r.subscription.productKey, 'wartung')
// K12 sofort (Vertragsbestaetigung mit PDF der Fassung 4), T12 sofort (Wechsel ist wirksam)
const [k12] = mailsWithSubject(K12_SUBJECT)
assert.ok(k12, 'K12 verschickt')
assert.deepEqual(recipients(k12), ['k@example.invalid'])
assert.deepEqual(k12.attachments.map((a) => [a.filename, a.contentType]), [['WEBklar-Abo-Vertragsunterlagen.pdf', 'application/pdf']])
assert.ok(k12.attachments[0].content.length > 1000, 'PDF-Inhalt angehaengt')
assert.ok(k12.text.includes('Guten Tag Kunde Eins,'))
assert.ok(k12.text.includes('41,00 €'), k12.text)
assert.ok(k12.text.includes('50,00 €'))
assert.ok(k12.text.includes(`Mindestlaufzeit 12 Monate bis ${addMonths(new Date(switchedAt), 12).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })}.`))
assert.ok(k12.text.includes(`(Version ${consent.termsVersion})`))
assert.ok(!k12.text.includes(ZAHLUNG_OFFEN), 'kein Block zahlung_offen')
assert.ok(!k12.text.includes(LASTSCHRIFT), 'kein Block lastschrift')
const [t12] = mailsWithSubject(t12Subject('k@example.invalid'))
assert.ok(t12, 'T12 verschickt')
assert.ok(t12.text.includes(WIRKSAM))
assert.ok(t12.text.includes('sub_h'))
assert.ok(t12.text.includes('Weitere Postfächer erst einrichten, wenn die Wechselrechnung bezahlt ist'), t12.text)
assert.equal(mails.sent.length, 2)
// Alle Platzhalter und Bloecke passen zu den Vorlagen
assert.deepEqual(logs.warn.filter((w) => /Vorlage (K12|T12)/.test(w)), [])
})
test('Offene Rechnung liefert paymentUrl', async () => {
await resetHosting({ id: 'sub_h2', customerId: 'c2', mirror: hosting2Doc })
// Karte, Zahlung nicht sofort erfolgreich (3-D Secure): Stripe haelt die Aenderung in pending_update zurueck
st.nextSubscriptionUpdate = { pending_update: pendingUpdate(), latest_invoice: switchInvoice({ id: 'in_offen', status: 'open', hosted_invoice_url: 'https://pay.example/inv' }) }
const r = await switchToWartung('c2', consent, Math.floor(Date.now() / 1000))
assert.equal(r.paymentUrl, 'https://pay.example/inv')
assert.equal((await listContractEvents('c2'))[0].type, 'wechsel')
const call = updateCalls('sub_h2').at(-1)
assert.equal(call.body['payment_behavior'], 'pending_if_incomplete')
const [ev] = await listContractEvents('c2')
assert.equal(ev.status, 'offen')
assert.equal(ev.details.paymentUrl, 'https://pay.example/inv')
assert.equal(ev.details.effectiveAt, null)
// Metadaten und Spiegel aendern sich noch nicht
assert.equal(aw.docs('subscriptions').get('sub_h2').productKey, 'hosting')
assert.equal(aw.docs('subscriptions').get('sub_h2').termStartAt, hosting2Doc.termStartAt)
assert.equal(st.subscriptions.get('sub_h2').metadata.productKey, 'hosting')
assert.equal(st.subscriptions.get('sub_h2').metadata.switchedAt, undefined)
// K12 sofort, mit Block zahlung_offen; T12 erst, wenn der Wechsel wirksam ist
const [k12] = mailsWithSubject(K12_SUBJECT)
assert.deepEqual(recipients(k12), ['k2@example.invalid'])
assert.ok(k12.text.includes(ZAHLUNG_OFFEN), k12.text)
assert.ok(!k12.text.includes(LASTSCHRIFT))
assert.deepEqual(mailsWithSubject(t12Subject('k2@example.invalid')), [])
// Portal: Wechsel wartet auf die Zahlung, Link zur Zahlungsseite (F12)
const view = await get('/api/abo', { cookie: customerCookie('c2') })
assert.deepEqual(view.subscription.switchPending, { since: consent.consentAt, paymentUrl: 'https://pay.example/inv' })
assert.equal(view.subscription.canSwitch, false)
assert.equal(view.subscription.productKey, 'hosting')
// Ein zweiter Wechsel, waehrend einer auf die Zahlung wartet
await assert.rejects(switchToWartung('c2', consent, nowUnix()), (e) => e.status === 409 && e.message === 'Ein Wechsel wartet noch auf die Zahlung.')
await clearEvents('c2')
})
test('SEPA-Lastschrift: allow_incomplete, sofort wirksam, offen bis zur Zahlung, K12 mit Block lastschrift, T12 sofort', async () => {
await resetHosting({ paymentMethod: SEPA })
// SEPA: Stripe wendet die Aenderung sofort an, die Lastschrift ist noch nicht eingeloest
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ status: 'open' }) }
const r = await switchToWartung('c1', consent, nowUnix())
const call = updateCalls('sub_h').at(-1)
assert.equal(call.body['payment_behavior'], 'allow_incomplete')
assert.equal(r.paymentUrl, null, 'keine Weiterleitung bei SEPA')
const [ev] = await listContractEvents('c1')
assert.equal(ev.status, 'offen')
assert.equal(ev.details.paymentMethod, 'sepa_debit')
assert.equal(ev.details.paymentUrl, null)
assert.equal(ev.details.effectiveAt, ev.details.switchedAt)
// Wechsel gilt sofort: Stripe und Spiegel sind Wartung
assert.equal(st.subscriptions.get('sub_h').metadata.productKey, 'wartung')
assert.equal(aw.docs('subscriptions').get('sub_h').productKey, 'wartung')
const [k12] = mailsWithSubject(K12_SUBJECT)
assert.ok(k12.text.includes(LASTSCHRIFT), k12.text)
assert.ok(k12.text.includes('Ihr Abbuchungstag bleibt der Wechseltag'))
assert.ok(!k12.text.includes(ZAHLUNG_OFFEN))
const [t12] = mailsWithSubject(t12Subject('k@example.invalid'))
assert.ok(t12.text.includes(WIRKSAM))
assert.ok(t12.text.includes('SEPA-Lastschrift'))
// Portal: Zahlung offen (ohne Link), keine Speicheraenderung bis zur Zahlung
const view = await get('/api/abo', { cookie: customerCookie() })
assert.deepEqual(view.subscription.switchPending, { since: consent.consentAt, paymentUrl: null })
assert.equal(view.subscription.canChangeStorage, false)
assert.equal(view.subscription.productKey, 'wartung')
})
test('Zahlart: Standard-Zahlungsmethode des Abos, sonst des Kunden; unbekannt oder keine → Karte', async () => {
assert.equal(paymentMethodTypeOf({ default_payment_method: SEPA }), 'sepa_debit')
assert.equal(paymentMethodTypeOf({ default_payment_method: CARD, customer: { invoice_settings: { default_payment_method: SEPA } } }), 'card')
assert.equal(paymentMethodTypeOf({ default_payment_method: null, customer: { id: 'cus_1', invoice_settings: { default_payment_method: SEPA } } }), 'sepa_debit')
assert.equal(paymentMethodTypeOf({ default_payment_method: 'pm_nicht_erweitert', customer: 'cus_1' }), null)
assert.equal(paymentMethodTypeOf({}), null)
// Kunde mit SEPA als Standard, Abo ohne eigene Zahlungsmethode -> allow_incomplete
await resetHosting({
paymentMethod: null,
stripe: { customer: { id: 'cus_c1', object: 'customer', email: 'k@example.invalid', invoice_settings: { default_payment_method: SEPA } } },
})
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ status: 'open' }) }
await switchToWartung('c1', consent, nowUnix())
assert.equal(updateCalls('sub_h').at(-1).body['payment_behavior'], 'allow_incomplete')
// Keine Zahlungsmethode bekannt -> Weg fuer Karten (Stripe haelt die Aenderung bis zur Zahlung zurueck)
await resetHosting({ paymentMethod: null })
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
await switchToWartung('c1', consent, nowUnix())
assert.equal(updateCalls('sub_h').at(-1).body['payment_behavior'], 'pending_if_incomplete')
assert.equal((await listContractEvents('c1'))[0].details.paymentMethod, null)
})
// ---------------------------------------------------------------------------
// Voraussetzungen und Frische der Vorschau
// ---------------------------------------------------------------------------
test('Gekündigt, Wartung, trialing, past_due: 409', async () => {
await resetHosting()
const before = st.calls.length
for (const patch of [{ cancelAtPeriodEnd: true }, { productKey: 'wartung' }, { status: 'trialing' }, { status: 'past_due' }]) {
aw.seed('subscriptions', { ...hostingDoc, ...patch })
await assert.rejects(switchToWartung('c1', consent, Math.floor(Date.now() / 1000)), (e) => e.status === 409, JSON.stringify(patch))
}
// Kuendigung nur per cancelAt zaehlt ebenso
aw.seed('subscriptions', { ...hostingDoc, cancelAt: '2027-10-05T10:00:00.000Z' })
await assert.rejects(switchToWartung('c1', consent, nowUnix()), (e) => e.status === 409)
assert.deepEqual(updateCalls('sub_h', before), [], 'keine Aenderung bei Stripe')
aw.seed('subscriptions', hostingDoc)
})
test('Alte Vorschau (> 30 min): 409', async () => {
await resetHosting()
const before = st.calls.length
await assert.rejects(switchToWartung('c1', consent, Math.floor(Date.now() / 1000) - 31 * 60), (e) => e.status === 409 && /Bitte Seite neu laden/.test(e.message))
assert.equal(st.calls.length, before, 'kein Stripe-Aufruf')
})
test('Frische der Vorschau: 30 Minuten, derselbe Kalendertag in Deutschland, nicht aus der Zukunft', () => {
const at = (iso) => new Date(iso).getTime()
assert.equal(previewProblem(unix('2026-10-01T10:00:00Z'), at('2026-10-01T10:29:00Z')), null)
assert.equal(previewProblem(unix('2026-10-01T10:00:00Z'), at('2026-10-01T10:31:00Z'))?.status, 409)
// 23:55 und 00:05 Uhr in Deutschland (Sommerzeit): der angezeigte Wechseltag stimmt nicht mehr
const midnight = previewProblem(unix('2026-10-01T21:55:00Z'), at('2026-10-01T22:05:00Z'))
assert.equal(midnight?.status, 409)
assert.match(midnight.message, /Datumswechsel/)
// Aus der Zukunft (mehr als 5 Minuten) oder keine ganze Zahl: 400
assert.equal(previewProblem(unix('2026-10-01T10:10:00Z'), at('2026-10-01T10:00:00Z'))?.status, 400)
assert.equal(previewProblem(unix('2026-10-01T10:02:00Z'), at('2026-10-01T10:00:00Z')), null, 'kleine Uhrabweichung erlaubt')
for (const bad of ['1790000000', 1.5, null, undefined, 0, -5]) assert.equal(previewProblem(bad, at('2026-10-01T10:00:00Z'))?.status, 400, String(bad))
})
test('Abrechnungsmonat hat seit der Vorschau neu begonnen: 409', async () => {
await resetHosting()
st.subscriptions.get('sub_h').items.data[0].current_period_start = nowUnix()
const before = st.calls.length
await assert.rejects(switchToWartung('c1', consent, nowUnix() - 60), (e) => e.status === 409 && /Bitte Seite neu laden/.test(e.message))
assert.deepEqual(updateCalls('sub_h', before), [])
})
test('Ein zweiter Wechsel, während einer auf die Zahlung wartet: 409', async () => {
await resetHosting()
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting', paymentUrl: 'https://pay.example/alt' }, consent, status: 'offen',
})
const before = st.calls.length
await assert.rejects(switchToWartung('c1', consent, nowUnix()), (e) => e.status === 409 && e.message === 'Ein Wechsel wartet noch auf die Zahlung.')
await assert.rejects(previewSwitch('c1'), (e) => e.status === 409)
assert.equal(st.calls.length, before)
await clearEvents('c1')
})
test('Stripe ist die Wahrheit: schon Wartung oder ausstehende Änderung bei Stripe → 409 ohne Änderung', async () => {
// Spiegel veraltet (Webhook noch nicht da), Stripe hat schon den Wartungs-Preis
await resetHosting()
st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'wartung', default_payment_method: CARD }))
const before = st.calls.length
await assert.rejects(switchToWartung('c1', consent, nowUnix()), (e) => e.status === 409)
// Stripe haelt noch eine Aenderung zurueck (Ereignis fehlt im Portal)
await resetHosting({ stripe: { pending_update: pendingUpdate() } })
await assert.rejects(switchToWartung('c1', consent, nowUnix()), (e) => e.status === 409 && /wartet noch auf die Zahlung/.test(e.message))
await assert.rejects(previewSwitch('c1'), (e) => e.status === 409)
assert.deepEqual(updateCalls('sub_h', before), [])
})
test('Ohne Zustimmung zu Bedingungen oder Sofortbeginn kein Wechsel (400)', async () => {
await resetHosting()
const before = st.calls.length
const { earlyStartConsentAt: _drop, ...withoutEarlyStart } = consent
await assert.rejects(switchToWartung('c1', withoutEarlyStart, nowUnix()), (e) => e.status === 400)
await assert.rejects(switchToWartung('c1', { ...consent, termsVersion: '' }, nowUnix()), (e) => e.status === 400)
await assert.rejects(switchToWartung('c1', null, nowUnix()), (e) => e.status === 400)
assert.equal(st.calls.length, before)
})
test('Testkonto: Testrabatt Hosting fällt weg, Testrabatt Wartung kommt dazu, andere Rabatte bleiben', async () => {
const subId = 'sub_t'
await clearEvents('c4')
aw.seed('subscriptions', subscriptionDoc(subId, { ...hostingDoc, customerId: 'c4', email: 'test@example.invalid', stripeCustomerId: 'cus_c4' }))
st.subscriptions.set(
subId,
stripeSubscription({
id: subId,
customerId: 'c4',
productKey: 'hosting',
default_payment_method: CARD,
// expand: ['discounts'] -> Rabatt-Objekte (API 2025-10-29.clover: discount.source.coupon)
discounts: [
{ id: 'di_test_hosting', object: 'discount', source: { type: 'coupon', coupon: 'WEBKLAR-TEST-050-HOSTING' } },
{ id: 'di_angebot', object: 'discount', source: { type: 'coupon', coupon: { id: 'WEBKLAR-ANGEBOT-X', object: 'coupon' } } },
],
})
)
st.invoicePreview = previewInvoice(line('price_hosting', -30, { proration: true, periodStart: nowUnix(), subscription: subId }), line('price_wartung', 50, { subscription: subId }))
try {
const before = st.calls.length
const p = await previewSwitch('c4')
const [previewCall] = previewCalls(before)
assert.equal(previewCall.body['discounts[0][discount]'], 'di_angebot')
assert.equal(previewCall.body['discounts[1][coupon]'], 'WEBKLAR-TEST-050')
assert.equal(previewCall.body['discounts[2][coupon]'], undefined)
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ id: 'in_test', amount_due: 20 }) }
await switchToWartung('c4', consent, p.prorationDate)
const call = updateCalls(subId).at(-1)
assert.equal(call.body['discounts[0][discount]'], 'di_angebot')
assert.equal(call.body['discounts[1][coupon]'], 'WEBKLAR-TEST-050')
assert.equal(call.body['discounts[2][coupon]'], undefined)
assert.equal(call.body['discounts[2][discount]'], undefined)
assert.ok(!Object.values(call.body).includes('di_test_hosting'), 'Testrabatt Hosting faellt weg')
// Der Wartungs-Testrabatt existiert (angelegt, falls er fehlte)
assert.equal(st.coupons.get('WEBKLAR-TEST-050').amount_off, 4950)
} finally {
await clearEvents('c4')
await deleteDocument(config.collections.subscriptions, subId).catch(() => {})
}
})
// ---------------------------------------------------------------------------
// Fehler beim Nachweis und beim Mailversand
// ---------------------------------------------------------------------------
test('Mailfehler brechen den Wechsel nicht ab (nur im Log)', async () => {
await resetHosting()
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP gestört') } })
try {
let r
const logs = await withCapturedLogs(async () => {
r = await switchToWartung('c1', consent, nowUnix())
})
assert.equal(r.subscription.productKey, 'wartung')
assert.equal((await listContractEvents('c1'))[0].status, 'erledigt')
assert.ok(logs.error.some((e) => e.includes('K12') && e.includes('SMTP gestört')), logs.error.join('\n'))
assert.ok(logs.error.some((e) => e.includes('Admin-Hinweis fehlgeschlagen')))
} finally {
setMailTransportForTests(mails)
}
})
test('Nachweis nicht speicherbar: Wechsel läuft, T12 meldet es sofort (auch bei offener Zahlung)', async () => {
await resetHosting()
st.nextSubscriptionUpdate = { pending_update: pendingUpdate(), latest_invoice: switchInvoice({ status: 'open', hosted_invoice_url: 'https://pay.example/x' }) }
aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 })
let r
const logs = await withCapturedLogs(async () => {
r = await switchToWartung('c1', consent, nowUnix())
})
assert.equal(r.paymentUrl, 'https://pay.example/x')
assert.deepEqual(await listContractEvents('c1'), [])
assert.ok(logs.error.some((e) => e.includes('Ereignis') && e.includes('sub_h')), logs.error.join('\n'))
const [t12] = mailsWithSubject(t12Subject('k@example.invalid'))
assert.ok(t12, 'T12 geht sofort raus, damit das Team den Wechsel von Hand verfolgt')
assert.ok(t12.text.includes('Nachweis nicht gespeichert'))
assert.ok(!t12.text.includes(WIRKSAM), 'Zahlung noch offen: nicht wirksam')
assert.ok(mailsWithSubject(K12_SUBJECT)[0].text.includes(ZAHLUNG_OFFEN))
})
// ---------------------------------------------------------------------------
// Routen
// ---------------------------------------------------------------------------
test('Route: ohne earlyStart 400', async () => {
const res = await post('/api/abo/switch', { acceptTerms: true, termsVersion: config.abo.termsVersion, prorationDate: 1 }, { cookie: customerCookie() })
assert.equal(res.status, 400)
})
test('Routen: fehlende Angaben 400, alte Fassung 409, ohne Session 401, „Als Kunde ansehen“ 403, ohne Stripe-Aufruf', async () => {
await resetHosting()
const cookie = customerCookie()
const body = { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix() }
const before = st.calls.length
for (const missing of ['acceptTerms', 'earlyStart', 'termsVersion', 'prorationDate']) {
const { [missing]: _drop, ...rest } = body
assert.equal((await post('/api/abo/switch', rest, { cookie })).status, 400, missing)
}
assert.equal((await post('/api/abo/switch', { ...body, earlyStart: 'ja' }, { cookie })).status, 400)
assert.equal((await post('/api/abo/switch', { ...body, prorationDate: String(body.prorationDate) }, { cookie })).status, 400)
const stale = await post('/api/abo/switch', { ...body, termsVersion: '2026-09-25-v3' }, { cookie })
assert.equal(stale.status, 409)
assert.match(stale.body.error, /Bedingungen/)
assert.equal((await post('/api/abo/switch', body)).status, 401)
assert.equal((await post('/api/abo/switch/preview', {})).status, 401)
assert.equal((await post('/api/abo/switch', body, { cookie: viewAsCookie() })).status, 403)
assert.equal((await post('/api/abo/switch/preview', {}, { cookie: viewAsCookie() })).status, 403)
assert.equal(st.calls.length, before)
})
test('Route Vorschau: Beträge; Wartungs-Abo 409', async () => {
await resetHosting()
st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000))
const res = await post('/api/abo/switch/preview', {}, { cookie: customerCookie() })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.creditCents, 900)
assert.equal(res.body.amountDueCents, 4100)
assert.equal(res.body.monthlyCents, 5000)
assert.ok(Number.isInteger(res.body.prorationDate))
assert.ok(res.body.nextChargeAt && res.body.minTermEnd)
await resetHosting({ patch: { productKey: 'wartung' } })
const wartung = await post('/api/abo/switch/preview', {}, { cookie: customerCookie() })
assert.equal(wartung.status, 409)
assert.ok(wartung.body.error)
aw.seed('subscriptions', hostingDoc)
})
test('Route Wechsel: Antwort mit Abo-Ansicht und paymentUrl, Zustimmung aus der Anfrage', async () => {
await resetHosting()
st.nextSubscriptionUpdate = { pending_update: pendingUpdate(), latest_invoice: switchInvoice({ status: 'open', hosted_invoice_url: 'https://pay.example/route' }) }
const res = await post(
'/api/abo/switch',
{ acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix() },
{ cookie: customerCookie(), headers: { 'user-agent': 'Testbrowser/1.0' } }
)
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.paymentUrl, 'https://pay.example/route')
assert.equal(res.body.subscription.productKey, 'hosting')
assert.equal(res.body.subscription.canSwitch, false)
assert.equal(res.body.subscription.switchPending.paymentUrl, 'https://pay.example/route')
// F16: Zustimmung zum Sofortbeginn aus earlyStart, Nachweis mit Fassung, Zeitpunkt, IP, Browser
const [ev] = await listContractEvents('c1')
assert.equal(ev.termsVersion, config.abo.termsVersion)
assert.ok(ev.consentAt)
assert.equal(ev.earlyStartConsentAt, ev.consentAt)
assert.equal(ev.consentIp, '127.0.0.1')
assert.equal(ev.consentUserAgent, 'Testbrowser/1.0')
// Karte, sofort bezahlt: Antwort zeigt das Wartungs-Abo mit Widerrufsfrist fuer den Wechsel
await resetHosting()
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
const done = await post('/api/abo/switch', { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix() }, { cookie: customerCookie() })
assert.equal(done.status, 200, JSON.stringify(done.body))
assert.equal(done.body.paymentUrl, null)
assert.equal(done.body.subscription.productKey, 'wartung')
assert.equal(done.body.subscription.switchPending, null)
assert.deepEqual(done.body.subscription.withdrawals.map((w) => w.type), ['wechsel'])
// Wechsel geht nur einmal: jetzt Wartung -> 409
const again = await post('/api/abo/switch', { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix() }, { cookie: customerCookie() })
assert.equal(again.status, 409)
})
// ---------------------------------------------------------------------------
// Webhooks (F23, F26, F27)
// ---------------------------------------------------------------------------
async function pendingCardSwitch(invoiceId = 'in_karte') {
await resetHosting({ paymentMethod: CARD })
st.nextSubscriptionUpdate = { pending_update: pendingUpdate(), latest_invoice: switchInvoice({ id: invoiceId, status: 'open', hosted_invoice_url: 'https://pay.example/karte' }) }
await switchToWartung('c1', consent, nowUnix())
assert.equal(aw.docs('subscriptions').get('sub_h').productKey, 'hosting')
mails.sent.length = 0
}
test('Webhook pending_update_applied: Ereignis erledigt, Spiegel Wartung, T12; Wiederholung ohne zweite Mail', async () => {
await pendingCardSwitch()
// Zahlung auf der Stripe-Seite: Stripe uebernimmt Preis, Anker und Metadaten
st.applyPendingUpdate('sub_h')
const event = webhookEvent('customer.subscription.pending_update_applied', { id: 'sub_h', object: 'subscription' })
const res = await postWebhook(event)
assert.equal(res.status, 200, JSON.stringify(res.body))
const [ev] = await listContractEvents('c1')
assert.equal(ev.status, 'erledigt')
assert.equal(ev.doneBy, 'stripe')
assert.ok(ev.details.effectiveAt)
assert.ok(ev.details.paidAt)
assert.equal(ev.details.paymentUrl, 'https://pay.example/karte', 'details bleiben erhalten')
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.productKey, 'wartung')
assert.equal(doc.termStartAt, ev.details.switchedAt)
assert.equal(mailsWithSubject(t12Subject('k@example.invalid')).length, 1)
assert.ok(mailsWithSubject(t12Subject('k@example.invalid'))[0].text.includes(WIRKSAM))
assert.deepEqual(mailsWithSubject(K12_SUBJECT), [], 'K12 ging schon beim Klick raus')
// Wiederholung durch Stripe: nichts mehr offen, keine zweite Mail
assert.equal((await postWebhook(event)).status, 200)
assert.equal(mails.sent.length, 1)
const view = await get('/api/abo', { cookie: customerCookie() })
assert.equal(view.subscription.switchPending, null)
assert.equal(view.subscription.productKey, 'wartung')
})
test('Webhook pending_update_expired: Ereignis gescheitert, K14 an den Kunden, T12 gescheitert; Hosting bleibt', async () => {
await pendingCardSwitch()
const event = webhookEvent('customer.subscription.pending_update_expired', { id: 'sub_h', object: 'subscription' })
const res = await postWebhook(event)
assert.equal(res.status, 200, JSON.stringify(res.body))
const [ev] = await listContractEvents('c1')
assert.equal(ev.status, 'gescheitert')
assert.equal(ev.doneBy, 'stripe')
assert.equal(ev.details.grund, SWITCH_FAILED_REASONS.abgelaufen)
assert.ok(ev.details.failedAt)
const [k14] = mailsWithSubject(K14_SUBJECT)
assert.ok(k14, 'K14 verschickt')
assert.deepEqual(recipients(k14), ['k@example.invalid'])
assert.ok(k14.text.includes(HOSTING_LAEUFT_WEITER), k14.text)
assert.ok(k14.text.includes(SWITCH_FAILED_REASONS.abgelaufen))
assert.ok(!k14.text.includes('Abbuchungstag'), 'Karte: der alte Abbuchungstag bleibt, kein Hinweis darauf')
assert.ok(!/23 Stunden/.test(k14.text))
const [t12] = mailsWithSubject(t12Subject('k@example.invalid'))
assert.ok(t12.text.includes('nicht zustande gekommen'), t12.text)
assert.ok(!t12.text.includes(WIRKSAM))
// Hosting-Abo laeuft weiter, der Wechsel kann neu bestellt werden; keine Widerrufsfrist fuer den gescheiterten Wechsel
assert.equal(aw.docs('subscriptions').get('sub_h').productKey, 'hosting')
const view = await get('/api/abo', { cookie: customerCookie() })
assert.equal(view.subscription.switchPending, null)
assert.equal(view.subscription.canSwitch, true)
assert.ok(!view.subscription.withdrawals.some((w) => w.type === 'wechsel'))
// Wiederholung: keine zweite K14
mails.sent.length = 0
assert.equal((await postWebhook(event)).status, 200)
assert.deepEqual(mails.sent, [])
})
test('Webhook invoice.paid (SEPA): Ereignis erledigt, Stunde gutgeschrieben, kein zweites T12', async () => {
await resetHosting({ paymentMethod: SEPA })
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ id: 'in_sepa', status: 'open' }) }
await switchToWartung('c1', consent, nowUnix())
st.invoices.set('in_sepa', paidInvoice('in_sepa'))
mails.sent.length = 0
try {
const event = webhookEvent('invoice.paid', { id: 'in_sepa', object: 'invoice', billing_reason: 'subscription_update', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } } })
const res = await postWebhook(event)
assert.equal(res.status, 200, JSON.stringify(res.body))
const [ev] = await listContractEvents('c1')
assert.equal(ev.status, 'erledigt')
assert.equal(ev.doneBy, 'stripe')
assert.ok(ev.details.paidAt)
assert.equal(ev.details.effectiveAt, ev.details.switchedAt, 'wirksam seit dem Klick')
// Erste Stunde mit der bezahlten Wechselrechnung (Spec 3.5)
assert.equal(aw.docs('hourCredits').get('in_sepa').minutes, 60)
assert.deepEqual(mailsWithSubject(t12Subject('k@example.invalid')), [], 'T12 ging bei SEPA schon beim Klick raus')
// Idempotent
assert.equal((await postWebhook(event)).status, 200)
assert.equal((await listContractEvents('c1'))[0].status, 'erledigt')
const view = await get('/api/abo', { cookie: customerCookie() })
assert.equal(view.subscription.switchPending, null)
assert.equal(view.subscription.canChangeStorage, true)
} finally {
await deleteDocument(config.collections.hourCredits, 'in_sepa').catch(() => {})
}
})
test('Webhook invoice.paid vor pending_update_applied (Karte): erledigt und T12 genau einmal', async () => {
await pendingCardSwitch('in_karte_zuerst')
st.applyPendingUpdate('sub_h')
st.invoices.set('in_karte_zuerst', paidInvoice('in_karte_zuerst'))
try {
const paid = webhookEvent('invoice.paid', { id: 'in_karte_zuerst', object: 'invoice', billing_reason: 'subscription_update', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } } })
assert.equal((await postWebhook(paid)).status, 200)
const [ev] = await listContractEvents('c1')
assert.equal(ev.status, 'erledigt')
assert.ok(ev.details.effectiveAt)
assert.equal(aw.docs('subscriptions').get('sub_h').productKey, 'wartung')
assert.equal(mailsWithSubject(t12Subject('k@example.invalid')).length, 1)
const applied = webhookEvent('customer.subscription.pending_update_applied', { id: 'sub_h', object: 'subscription' })
assert.equal((await postWebhook(applied)).status, 200)
assert.equal(mailsWithSubject(t12Subject('k@example.invalid')).length, 1, 'kein zweites T12')
} finally {
await deleteDocument(config.collections.hourCredits, 'in_karte_zuerst').catch(() => {})
}
})
test('Webhook invoice.paid, solange Stripe die Änderung noch zurückhält: Ereignis bleibt offen', async () => {
await pendingCardSwitch('in_noch_offen')
// Stripe hat die ausstehende Aenderung noch nicht uebernommen (pending_update am Abo)
st.subscriptions.get('sub_h').pending_update = pendingUpdate()
st.invoices.set('in_noch_offen', paidInvoice('in_noch_offen'))
try {
const paid = webhookEvent('invoice.paid', { id: 'in_noch_offen', object: 'invoice', billing_reason: 'subscription_update', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } } })
assert.equal((await postWebhook(paid)).status, 200)
assert.equal((await listContractEvents('c1'))[0].status, 'offen')
assert.deepEqual(mailsWithSubject(t12Subject('k@example.invalid')), [])
} finally {
await deleteDocument(config.collections.hourCredits, 'in_noch_offen').catch(() => {})
}
})
test('Webhook invoice.payment_failed: SEPA-Wechselrechnung → T12 mit Handgriffen; Karte ohne T12', async () => {
await resetHosting({ paymentMethod: SEPA })
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ id: 'in_sepa_f', status: 'open' }) }
await switchToWartung('c1', consent, nowUnix())
mails.sent.length = 0
const failed = webhookEvent('invoice.payment_failed', {
id: 'in_sepa_f', object: 'invoice', number: 'WK-10', billing_reason: 'subscription_update', amount_due: 4100, customer_email: 'k@example.invalid',
parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } },
})
const res = await postWebhook(failed)
assert.equal(res.status, 200, JSON.stringify(res.body))
const [t12] = mailsWithSubject(t12Subject('k@example.invalid'))
assert.ok(t12, 'T12 mit Handgriffen')
for (const text of ['SEPA-Lastschrift für die Wechselrechnung ist gescheitert', 'stornieren', 'ohne anteilige Verrechnung', 'Der Abbuchungstag bleibt der Wechseltag', 'termStartAt, switchedAt und switchedFrom', 'node scripts/wechsel-gescheitert.mjs sub_h --ausfuehren', '41,00 €']) {
assert.ok(t12.text.includes(text), `${text}\n---\n${t12.text}`)
}
assert.ok(!t12.text.includes(WIRKSAM))
assert.equal(mails.sent.length, 2, 'dazu T04 wie bei jeder fehlgeschlagenen Zahlung')
// Das Ereignis schliesst das Team (Skript), nicht der Webhook
assert.equal((await listContractEvents('c1'))[0].status, 'offen')
// Karte mit offener Zahlung: Stripe hat nichts uebernommen, kein switchedAt -> nur T04
await pendingCardSwitch('in_karte_f')
const cardFailed = webhookEvent('invoice.payment_failed', {
id: 'in_karte_f', object: 'invoice', billing_reason: 'subscription_update', amount_due: 4100,
parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } },
})
assert.equal((await postWebhook(cardFailed)).status, 200)
assert.deepEqual(mailsWithSubject(t12Subject('k@example.invalid')), [])
assert.equal(mails.sent.length, 1)
})
// ---------------------------------------------------------------------------
// markSwitchFailed (F27) und das Skript fuers Team (SEPA)
// ---------------------------------------------------------------------------
test('markSwitchFailed: gescheitert, K14 (SEPA: Abbuchungstag bleibt), T12; zweiter Aufruf ohne Mails; wirksamer Wechsel bleibt', async () => {
await resetHosting({ paymentMethod: SEPA })
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ status: 'open' }) }
await switchToWartung('c1', consent, nowUnix())
const [open] = await listContractEvents('c1')
mails.sent.length = 0
const result = await markSwitchFailed(open.$id, SWITCH_FAILED_REASONS.lastschrift, { doneBy: 'skript' })
assert.equal(result.event.status, 'gescheitert')
assert.equal(result.event.doneBy, 'skript')
assert.equal(result.event.details.grund, SWITCH_FAILED_REASONS.lastschrift)
assert.equal(result.event.details.paymentMethod, 'sepa_debit', 'details bleiben erhalten')
const [k14] = mailsWithSubject(K14_SUBJECT)
assert.ok(k14.text.includes(SWITCH_FAILED_REASONS.lastschrift))
assert.ok(k14.text.includes(HOSTING_LAEUFT_WEITER))
assert.ok(k14.text.includes('Ihr Abbuchungstag bleibt der Tag des Wechsels.'), k14.text)
assert.ok(mailsWithSubject(t12Subject('k@example.invalid'))[0].text.includes('nicht zustande gekommen'))
assert.equal(mails.sent.length, 2)
mails.sent.length = 0
const again = await markSwitchFailed(open.$id, 'egal')
assert.ok(again.skipped)
assert.deepEqual(mails.sent, [])
// Ein wirksamer, bezahlter Wechsel wird nicht zurueckgesetzt
await resetHosting()
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
await switchToWartung('c1', consent, nowUnix())
const [done] = await listContractEvents('c1')
mails.sent.length = 0
assert.ok((await markSwitchFailed(done.$id, 'egal')).skipped)
assert.equal((await listContractEvents('c1'))[0].status, 'erledigt')
assert.deepEqual(mails.sent, [])
await assert.rejects(markSwitchFailed('gibt-es-nicht', 'x'), (e) => e.status === 404)
})
test('Skript wechsel-gescheitert: Aufruf, prüfen ohne Änderung, ausführen', async () => {
assert.deepEqual(parseArgs(['node', 'wechsel-gescheitert.mjs', 'sub_h', '--pruefen']), { subscriptionId: 'sub_h', mode: 'pruefen' })
assert.deepEqual(parseArgs(['node', 'wechsel-gescheitert.mjs', 'sub_h', '--ausfuehren']), { subscriptionId: 'sub_h', mode: 'ausfuehren', grund: SWITCH_FAILED_REASONS.lastschrift })
assert.equal(parseArgs(['node', 'wechsel-gescheitert.mjs', 'sub_h', '--ausfuehren', 'Konto', 'erloschen']).grund, 'Konto erloschen')
for (const args of [['sub_h'], ['--pruefen'], ['sub_h', '--anders'], ['cus_1', '--pruefen'], ['sub_h', '--pruefen', 'zu viel'], []]) {
assert.equal(parseArgs(['node', 'wechsel-gescheitert.mjs', ...args]), null, args.join(' '))
}
await resetHosting({ paymentMethod: SEPA })
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ status: 'open' }) }
await switchToWartung('c1', consent, nowUnix())
const [open] = await listContractEvents('c1')
mails.sent.length = 0
const out = []
const checked = await runFailedSwitchScript({ subscriptionId: 'sub_h', mode: 'pruefen' }, (line) => out.push(line))
assert.equal(checked.ok, true)
const printed = out.join('\n')
assert.ok(printed.includes(open.$id), printed)
assert.ok(printed.includes('productKey = hosting'))
assert.ok(printed.includes('termStartAt = (löschen)'))
assert.ok(printed.includes('--ausfuehren'))
assert.equal((await listContractEvents('c1'))[0].status, 'offen', 'nur geprueft')
assert.deepEqual(mails.sent, [])
const done = await runFailedSwitchScript({ subscriptionId: 'sub_h', mode: 'ausfuehren', grund: SWITCH_FAILED_REASONS.lastschrift }, () => {})
assert.equal(done.ok, true)
const [ev] = await listContractEvents('c1')
assert.equal(ev.status, 'gescheitert')
assert.equal(ev.doneBy, 'skript')
assert.equal(mailsWithSubject(K14_SUBJECT).length, 1)
out.length = 0
const nothing = await runFailedSwitchScript({ subscriptionId: 'sub_h', mode: 'pruefen' }, (line) => out.push(line))
assert.equal(nothing.ok, false)
assert.match(out.join('\n'), /Kein offener Wechsel/)
assert.equal((await runFailedSwitchScript({ subscriptionId: 'sub_unbekannt', mode: 'pruefen' }, () => {})).ok, false)
})
+101 -5
View File
@@ -116,14 +116,110 @@ test('Stripe-Nachbau: Abo-Update mit Positionen, Metadaten, einmaliger Zusatz',
assert.equal(dated.cancel_at, 1800000000)
// nextSubscriptionUpdate: einmalig in die naechste Antwort gemischt, nicht gespeichert
st.nextSubscriptionUpdate = { latest_invoice: { status: 'paid', hosted_invoice_url: 'https://pay.example/inv' } }
const once = await stripe.subscriptions.update('sub_u', { metadata: { a: 'b' } })
assert.equal(once.latest_invoice.hosted_invoice_url, 'https://pay.example/inv')
assert.equal(once.metadata.a, 'b')
assert.equal((await stripe.subscriptions.retrieve('sub_u')).latest_invoice, undefined)
assert.equal((await stripe.subscriptions.update('sub_u', { metadata: { a: 'c' } })).latest_invoice, undefined)
assert.equal(st.nextSubscriptionUpdate, null)
})
// Task 9: mit pending_update im einmaligen Zusatz verhaelt sich der Nachbau wie Stripe bei einer ausstehenden
// Aktualisierung (Zahlung nicht erfolgt): das Abo bleibt unveraendert, die Aenderung wartet, bis ein Test sie
// mit applyPendingUpdate uebernimmt (= Rechnung bezahlt)
test('Stripe-Nachbau: ausstehende Aktualisierung bleibt zurückgehalten, bis sie übernommen wird', async () => {
const stripe = getStripe()
st.prices.set('price_b', { id: 'price_b', object: 'price', active: true, lookup_key: 'lookup_b', unit_amount: 500 })
st.subscriptions.set('sub_p', {
id: 'sub_p',
object: 'subscription',
status: 'active',
metadata: { productKey: 'hosting' },
items: { object: 'list', data: [{ id: 'si_p', object: 'subscription_item', quantity: 1, price: { id: 'price_0', object: 'price' } }] },
})
st.nextSubscriptionUpdate = { pending_update: { expires_at: 1 }, latest_invoice: { status: 'open', hosted_invoice_url: 'https://pay.example/inv' } }
const pending = await stripe.subscriptions.update('sub_u', { metadata: { a: 'b' } })
const pending = await stripe.subscriptions.update('sub_p', {
items: [{ id: 'si_p', price: 'price_b' }],
metadata: { productKey: 'wartung' },
payment_behavior: 'pending_if_incomplete',
})
assert.deepEqual(pending.pending_update, { expires_at: 1 })
assert.equal(pending.latest_invoice.hosted_invoice_url, 'https://pay.example/inv')
assert.equal(pending.metadata.a, 'b')
assert.equal((await stripe.subscriptions.retrieve('sub_u')).pending_update, undefined)
assert.equal((await stripe.subscriptions.update('sub_u', { metadata: { a: 'c' } })).pending_update, undefined)
assert.equal(st.nextSubscriptionUpdate, null)
// Wie Stripe: Antwort und gespeichertes Abo zeigen den alten Stand
assert.equal(pending.metadata.productKey, 'hosting')
assert.equal(pending.items.data[0].price.id, 'price_0')
const stored = await stripe.subscriptions.retrieve('sub_p')
assert.equal(stored.metadata.productKey, 'hosting')
assert.equal(stored.pending_update, undefined)
// Bezahlt: die zurueckgehaltene Aenderung wird uebernommen
const applied = st.applyPendingUpdate('sub_p')
assert.equal(applied.metadata.productKey, 'wartung')
assert.equal((await stripe.subscriptions.retrieve('sub_p')).items.data[0].price.id, 'price_b')
assert.throws(() => st.applyPendingUpdate('sub_p'), /keine ausstehende/)
})
// Wie Stripe (docs.stripe.com/billing/subscriptions/pending-updates, Stand 28.09.2026): mit
// pending_if_incomplete nur die unterstuetzten Parameter; Metadaten hoechstens 40 Zeichen je Schluessel und
// 500 je Wert
test('Stripe-Nachbau: pending_if_incomplete nur mit unterstützten Parametern, Metadaten mit Stripe-Grenzen', async () => {
const stripe = getStripe()
st.subscriptions.set('sub_q', {
id: 'sub_q',
object: 'subscription',
status: 'active',
metadata: {},
items: { object: 'list', data: [{ id: 'si_q', object: 'subscription_item', quantity: 1, price: { id: 'price_0', object: 'price' } }] },
})
await assert.rejects(
stripe.subscriptions.update('sub_q', { payment_behavior: 'pending_if_incomplete', description: 'nicht erlaubt' }),
(e) => e.statusCode === 400 && /description/.test(e.message)
)
await assert.rejects(
stripe.subscriptions.update('sub_q', { payment_behavior: 'pending_if_incomplete', items: [{ id: 'si_q', metadata: { x: '1' } }] }),
(e) => e.statusCode === 400 && /metadata/.test(e.message)
)
const ok = await stripe.subscriptions.update('sub_q', {
payment_behavior: 'pending_if_incomplete',
items: [{ id: 'si_q', quantity: 2 }],
billing_cycle_anchor: 'now',
proration_behavior: 'create_prorations',
proration_date: 1790000000,
metadata: { a: 'b' },
discounts: [{ coupon: 'X' }],
expand: ['latest_invoice'],
})
assert.equal(ok.items.data[0].quantity, 2)
await assert.rejects(stripe.subscriptions.update('sub_q', { metadata: { a: 'x'.repeat(501) } }), (e) => e.statusCode === 400)
await assert.rejects(stripe.subscriptions.update('sub_q', { metadata: { ['k'.repeat(41)]: '1' } }), (e) => e.statusCode === 400)
assert.equal((await stripe.subscriptions.update('sub_q', { metadata: { a: 'x'.repeat(500) } })).metadata.a.length, 500)
})
// F25: Mails lassen sich in Tests pruefen (nodemailer jsonTransport statt SMTP); null schaltet zurueck
test('Mail-Senke: Kunden- und Team-Mails landen in der Senke, null schaltet zurück', async () => {
const { mailSink } = await import('./support/mailSink.js')
const { sendAdminNotice, sendCustomerMail, setMailTransportForTests } = await import('../server/services/mailer.js')
const sink = mailSink()
setMailTransportForTests(sink)
try {
await sendCustomerMail('k@example.invalid', {
template: 'K10',
values: { name: 'K', antrag_datum: '01.10.2026', antrag_nr: 'A1', begruendung: 'Nachweis fehlt' },
attachments: [{ filename: 'a.pdf', content: Buffer.from('PDF'), contentType: 'application/pdf' }],
})
assert.deepEqual(await sendAdminNotice({ template: 'T06', values: { email: 'k@example.invalid', stripe_abo_id: 'sub_1' } }), { sent: true })
assert.equal(sink.sent.length, 2)
const [customerMail, notice] = sink.sent
assert.deepEqual(customerMail.to.map((t) => t.address), ['k@example.invalid'])
assert.equal(customerMail.from.name, 'WEBklar')
assert.match(customerMail.text, /Nachweis fehlt/)
assert.deepEqual(customerMail.attachments.map((a) => a.filename), ['a.pdf'])
assert.equal(notice.from.name, 'WEBklar Kundenportal')
assert.match(notice.subject, /^\[Abo\]/)
} finally {
setMailTransportForTests(null)
}
await assert.rejects(sendCustomerMail('k@example.invalid', { template: 'K10', values: {} }), /SMTP nicht konfiguriert/)
})
// F17: GET /v1/invoices/:id bettet wie Stripe hoechstens 10 Zeilen ein, GET /v1/invoices/:id/lines blaettert
+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
},
}
}