Files

1195 lines
69 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, kundenansichtCookie, seedFixtures, stripeSubscription, subscriptionDoc } =
await import('./support/fixtures.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const { previewSwitch, switchToWartung, markSwitchFailed, previewProblem, paymentMethodTypeOf, reconcilePendingSwitch, SWITCH_FAILED_REASONS } =
await import('../server/services/planSwitch.js')
const { listContractEvents, recordContractEvent, updateContractEvent } = 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)
// Fix 1 (7): auch die Zustimmungsdaten des Hosting-Abschlusses, damit eine gescheiterte Lastschrift sie wiederherstellt
assert.deepEqual(ev.details.previousMetadata, {
productKey: 'hosting', termStartAt: null, switchedAt: null, switchedFrom: null,
termsVersion: config.abo.termsVersion, termsAcceptedAt: consent.consentAt, earlyStartConsentAt: consent.consentAt,
consentIp: '127.0.0.1', consentUserAgent: 'test',
})
// 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)), [])
})
// Task 12 (Spec 3.5): {{monatspreis}} in K12 und T12 ist die Monatsgebühr des Wartungs-Abos mit gebuchtem
// Zusatzspeicher; K12 sagt es im Block zusatzspeicher. Auch nach einem gescheiterten Wechsel (T12 aus dem Ereignis)
test('K12/T12: Monatspreis mit gebuchtem Zusatzspeicher, auch im Hinweis zum gescheiterten Wechsel', async () => {
await resetHosting({ stripe: { storageUnits: 2 } })
st.nextSubscriptionUpdate = { pending_update: pendingUpdate(), latest_invoice: switchInvoice({ id: 'in_speicher', status: 'open', amount_due: 4300, hosted_invoice_url: 'https://pay.example/inv2' }) }
await switchToWartung('c1', consent, nowUnix())
const [k12] = mailsWithSubject(K12_SUBJECT)
assert.ok(k12.text.includes('52,00 € / Monat'), k12.text)
assert.ok(k12.text.includes('einschließlich Ihres gebuchten E-Mail-Zusatzspeichers'), k12.text)
assert.ok(k12.text.includes('43,00 €'))
assert.deepEqual(mailsWithSubject(t12Subject('k@example.invalid')), [])
const [ev] = await listContractEvents('c1')
assert.equal(ev.details.monthlyCents, 5200)
await markSwitchFailed(ev.$id, SWITCH_FAILED_REASONS.abgelaufen)
const [t12] = mailsWithSubject(t12Subject('k@example.invalid'))
assert.ok(t12.text.includes('52,00 €'), t12.text)
assert.ok(t12.text.includes('43,00 €'))
// Ohne Zusatzspeicher: 50,00 € und kein Block
await resetHosting()
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
await switchToWartung('c1', consent, nowUnix())
const [plain] = mailsWithSubject(K12_SUBJECT)
assert.ok(plain.text.includes('50,00 € / Monat'), plain.text)
assert.ok(!plain.text.includes('einschließlich Ihres gebuchten'))
})
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, Kundenansicht 403, ohne Stripe-Aufruf', async () => {
await resetHosting()
const cookie = customerCookie()
const body = { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100 }
const before = st.calls.length
for (const missing of ['acceptTerms', 'earlyStart', 'termsVersion', 'prorationDate', 'amountDueCents']) {
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)
assert.equal((await post('/api/abo/switch', { ...body, amountDueCents: '4100' }, { 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: kundenansichtCookie() })).status, 403)
assert.equal((await post('/api/abo/switch/preview', {}, { cookie: kundenansichtCookie() })).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()
// Fix 1 (8): der Server rechnet die Vorschau nach; hier dieselben 41,00 € wie in der Bestelluebersicht
st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000))
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(), amountDueCents: 4100 },
{ 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(), amountDueCents: 4100 }, { 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(), amountDueCents: 4100 }, { 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()
st.expirePendingUpdate('sub_h')
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: der Nachbau zeigt pending_update am Abo (Fix 1, 6)
assert.ok(st.subscriptions.get('sub_h').pending_update)
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, switchedFrom und die Zustimmungsdaten', '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)
})
/** Handgriffe des Teams in Stripe nach einer gescheiterten Lastschrift (T12): Rechnung stornieren, Preis und Metadaten zurueck. */
function revertAtStripe(event, { voidInvoice = true, price = true, metadata = true } = {}) {
const sub = st.subscriptions.get(event.subscriptionId)
if (voidInvoice) st.invoices.set(event.details.invoiceId, { id: event.details.invoiceId, object: 'invoice', status: 'void', lines: { data: [] } })
if (price) sub.items.data = sub.items.data.map((item) => (item.price.id === 'price_wartung' ? { ...item, price: st.prices.get('price_hosting') } : item))
if (metadata) {
for (const [key, value] of Object.entries(event.details.previousMetadata)) {
if (value === null) delete sub.metadata[key]
else sub.metadata[key] = value
}
}
}
test('Skript wechsel-gescheitert: Aufruf, prüfen zeigt den Stripe-Stand, ausführen erst nach den Handgriffen', 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')
// Fix 1 (5): kein Schalter im Grund (Tippfehler wie "--pruefen" landet sonst in K14), Grund hoechstens 300 Zeichen
assert.equal(parseArgs(['node', 'wechsel-gescheitert.mjs', 'sub_h', '--ausfuehren', '--pruefen']), null)
assert.equal(parseArgs(['node', 'wechsel-gescheitert.mjs', 'sub_h', '--ausfuehren', 'Grund', '--force']), null)
assert.equal(parseArgs(['node', 'wechsel-gescheitert.mjs', 'sub_h', '--ausfuehren', 'x'.repeat(400)]).grund.length, 300)
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())
st.invoices.set('in_wechsel', { id: 'in_wechsel', object: 'invoice', status: 'open', lines: { data: [] } })
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('consentUserAgent = test'))
// Stand bei Stripe: Rechnung offen, Preis Wartung, Metadaten des Wechsels
assert.match(printed, /Rechnung in_wechsel: open/)
assert.match(printed, /Abo-Position: wartung/)
assert.match(printed, /productKey: jetzt wartung/)
assert.ok(printed.includes('--ausfuehren'))
assert.equal((await listContractEvents('c1'))[0].status, 'offen', 'nur geprueft')
assert.deepEqual(mails.sent, [])
// Ohne die Handgriffe in Stripe: kein K14, Liste der fehlenden Schritte
out.length = 0
const early = await runFailedSwitchScript({ subscriptionId: 'sub_h', mode: 'ausfuehren', grund: SWITCH_FAILED_REASONS.lastschrift }, (line) => out.push(line))
assert.equal(early.ok, false)
assert.deepEqual(early.missing.length, 3, out.join('\n'))
assert.match(out.join('\n'), /stornieren/)
assert.equal((await listContractEvents('c1'))[0].status, 'offen')
assert.deepEqual(mails.sent, [])
// Nur teilweise erledigt (Metadaten fehlen noch)
revertAtStripe(open, { metadata: false })
const partial = await runFailedSwitchScript({ subscriptionId: 'sub_h', mode: 'ausfuehren', grund: SWITCH_FAILED_REASONS.lastschrift }, () => {})
assert.equal(partial.ok, false)
assert.equal(partial.missing.length, 1)
assert.deepEqual(mails.sent, [])
revertAtStripe(open)
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)
})
// ---------------------------------------------------------------------------
// Fix round 1
// ---------------------------------------------------------------------------
const paymentFailed = (invoiceId, subscription = 'sub_h') =>
webhookEvent('invoice.payment_failed', {
id: invoiceId, object: 'invoice', billing_reason: 'subscription_update', amount_due: 4100, customer_email: 'k@example.invalid',
parent: { type: 'subscription_details', subscription_details: { subscription } },
})
const t12Count = () => mailsWithSubject(t12Subject('k@example.invalid')).length
// Fix 1 (1): T12 "Lastschrift gescheitert" nur fuer den echten Wechsel: offenes Ereignis, SEPA, wirksam, dieselbe Rechnung
test('Lastschrift gescheitert: nur für die Wechselrechnung eines offenen SEPA-Wechsels, einmal je Wechsel', async () => {
await resetHosting({ paymentMethod: SEPA })
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ id: 'in_sepa_x', status: 'open' }) }
await switchToWartung('c1', consent, nowUnix())
mails.sent.length = 0
// Andere Rechnung desselben Abos: kein T12
assert.equal((await postWebhook(paymentFailed('in_andere'))).status, 200)
assert.equal(t12Count(), 0)
// Die Wechselrechnung: T12, Ereignis merkt sich den Fehlschlag
assert.equal((await postWebhook(paymentFailed('in_sepa_x'))).status, 200)
assert.equal(t12Count(), 1)
const [ev] = await listContractEvents('c1')
assert.equal(ev.status, 'offen')
assert.ok(ev.details.lastschriftFailedAt)
// Wiederholung durch Stripe (oder erneuter Einzugsversuch): kein zweites T12
assert.equal((await postWebhook(paymentFailed('in_sepa_x'))).status, 200)
assert.equal(t12Count(), 1)
// Wechsel schon erledigt (bezahlt): kein T12, auch nicht ueber den Metadaten-Rueckfall
await resetHosting({ paymentMethod: SEPA })
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ id: 'in_sepa_y', status: 'paid' }) }
await switchToWartung('c1', consent, nowUnix())
assert.equal((await listContractEvents('c1'))[0].status, 'erledigt')
mails.sent.length = 0
await postWebhook(paymentFailed('in_sepa_y'))
assert.equal(t12Count(), 0)
// Kartenkunde, Wechsel wirksam, Rechnung noch offen (kein pending_update): kein T12
await resetHosting({ paymentMethod: CARD })
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ id: 'in_karte_x', status: 'open' }) }
await switchToWartung('c1', consent, nowUnix())
const [cardEvent] = await listContractEvents('c1')
assert.equal(cardEvent.status, 'offen')
assert.equal(cardEvent.details.paymentMethod, 'card')
mails.sent.length = 0
await postWebhook(paymentFailed('in_karte_x'))
assert.equal(t12Count(), 0)
})
test('Lastschrift gescheitert ohne gespeichertes Ereignis: Rückfall über die Metadaten, mit Hinweis auf den fehlenden Nachweis', async () => {
await resetHosting({ paymentMethod: SEPA })
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice({ id: 'in_ohne', status: 'open' }) }
aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 })
await withCapturedLogs(() => switchToWartung('c1', consent, nowUnix()))
assert.deepEqual(await listContractEvents('c1'), [])
mails.sent.length = 0
await postWebhook(paymentFailed('in_ohne'))
const [t12] = mailsWithSubject(t12Subject('k@example.invalid'))
assert.ok(t12.text.includes('SEPA-Lastschrift für die Wechselrechnung ist gescheitert'))
assert.ok(t12.text.includes('Nachweis nicht gespeichert'))
})
// Fix 1 (2): kommt pending_update_expired (oder _applied) nie an, gleicht das Lesen des Abos den Wechsel ab
async function staleCardSwitch() {
await pendingCardSwitch()
const [ev] = await listContractEvents('c1')
assert.ok(ev.details.pendingExpiresAt, 'Ablauf der ausstehenden Aenderung gespeichert')
await updateContractEvent(ev.$id, { details: { pendingExpiresAt: new Date(Date.now() - 60000).toISOString() } })
return ev
}
test('Abgleich ohne Webhook: abgelaufene Zahlung → gescheitert und K14 beim Lesen von GET /api/abo', async () => {
await staleCardSwitch()
st.expirePendingUpdate('sub_h')
const view = await get('/api/abo', { cookie: customerCookie() })
assert.equal(view.subscription.switchPending, null)
assert.equal(view.subscription.canSwitch, true)
const [ev] = await listContractEvents('c1')
assert.equal(ev.status, 'gescheitert')
assert.equal(ev.details.grund, SWITCH_FAILED_REASONS.abgelaufen)
assert.equal(mailsWithSubject(K14_SUBJECT).length, 1)
// Ein spaeter noch eintreffender Webhook aendert nichts mehr
await postWebhook(webhookEvent('customer.subscription.pending_update_expired', { id: 'sub_h', object: 'subscription' }))
assert.equal(mailsWithSubject(K14_SUBJECT).length, 1)
})
test('Abgleich ohne Webhook: bezahlt, Stripe hat übernommen → erledigt, T12, Spiegel Wartung', async () => {
const stale = await staleCardSwitch()
st.applyPendingUpdate('sub_h')
// Rueckfall ohne gespeicherten Ablauf: 23 Stunden nach dem Wechsel
await updateContractEvent(stale.$id, { details: { pendingExpiresAt: null, switchedAt: new Date(Date.now() - 24 * 3600000).toISOString() } })
const view = await get('/api/abo', { cookie: customerCookie() })
assert.equal(view.subscription.productKey, 'wartung')
assert.equal(view.subscription.switchPending, null)
assert.equal((await listContractEvents('c1'))[0].status, 'erledigt')
assert.equal(t12Count(), 1)
assert.equal(aw.docs('subscriptions').get('sub_h').productKey, 'wartung')
})
test('Abgleich: vor Ablauf kein Stripe-Aufruf; Stripe-Fehler bleibt im Log, die Seite lädt', async () => {
await pendingCardSwitch()
const before = st.calls.length
const view = await get('/api/abo', { cookie: customerCookie() })
assert.ok(view.subscription.switchPending)
assert.deepEqual(st.calls.slice(before).filter((c) => c.path === '/v1/subscriptions/sub_h'), [], 'kein Abruf vor Ablauf')
const [ev] = await listContractEvents('c1')
await updateContractEvent(ev.$id, { details: { pendingExpiresAt: new Date(Date.now() - 60000).toISOString() } })
st.failNext({ method: 'GET', path: '/v1/subscriptions/sub_h', status: 400 })
let stillOpen
const logs = await withCapturedLogs(async () => {
stillOpen = await get('/api/abo', { cookie: customerCookie() })
})
assert.ok(stillOpen.subscription.switchPending)
assert.equal((await listContractEvents('c1'))[0].status, 'offen')
assert.ok(logs.error.some((e) => e.includes('Abgleich')), logs.error.join('\n'))
})
test('Abgleich in Vorschau und Wechsel: ein abgelaufener Wechsel sperrt den neuen nicht', async () => {
await staleCardSwitch()
st.expirePendingUpdate('sub_h')
st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000))
const p = await previewSwitch('c1')
assert.equal(p.amountDueCents, 4100)
assert.equal((await listContractEvents('c1'))[0].status, 'gescheitert')
})
// Fix 1 (3): Gutschrift netto nach Rabatt, damit die Uebersicht aufgeht
test('Vorschau mit Rabatt: Gutschrift abzüglich des Rabatts auf der Gutschriftzeile', async () => {
await resetHosting()
const credit = { ...line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), discount_amounts: [{ amount: -450, discount: 'di_1' }] }
const debit = { ...line('price_wartung', 5000), discount_amounts: [{ amount: 2500, discount: 'di_1' }] }
st.invoicePreview = { object: 'invoice', lines: { object: 'list', data: [credit, debit], has_more: false }, amount_due: 2050 }
const p = await previewSwitch('c1')
assert.equal(p.creditCents, 450)
assert.equal(p.amountDueCents, 2050)
})
// Fix 1 (4): zwei Webhooks zugleich (invoice.paid und pending_update_applied, Wiederholungen) -> T12/K14 genau einmal
test('Gleichzeitige Webhooks: T12 bzw. K14 genau einmal', async () => {
await pendingCardSwitch('in_gleichzeitig')
st.applyPendingUpdate('sub_h')
st.invoices.set('in_gleichzeitig', paidInvoice('in_gleichzeitig'))
try {
const applied = webhookEvent('customer.subscription.pending_update_applied', { id: 'sub_h', object: 'subscription' })
const paid = webhookEvent('invoice.paid', { id: 'in_gleichzeitig', object: 'invoice', billing_reason: 'subscription_update', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } } })
const results = await Promise.all([postWebhook(applied), postWebhook(applied), postWebhook(paid)])
assert.deepEqual(results.map((r) => r.status), [200, 200, 200])
assert.equal(t12Count(), 1)
assert.equal((await listContractEvents('c1'))[0].status, 'erledigt')
} finally {
await deleteDocument(config.collections.hourCredits, 'in_gleichzeitig').catch(() => {})
}
await pendingCardSwitch()
st.expirePendingUpdate('sub_h')
const expired = webhookEvent('customer.subscription.pending_update_expired', { id: 'sub_h', object: 'subscription' })
await Promise.all([postWebhook(expired), postWebhook(expired), postWebhook(expired)])
assert.equal(mailsWithSubject(K14_SUBJECT).length, 1)
})
// Fix 1 (8): der Wechsel prueft den angezeigten Betrag gegen eine neue Vorschau
test('Wechsel mit geändertem Betrag: 409 ohne Stripe-Änderung', async () => {
await resetHosting()
st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000))
const before = st.calls.length
await assert.rejects(
switchToWartung('c1', consent, nowUnix(), { expectedAmountCents: 4000 }),
(e) => e.status === 409 && e.message === 'Der Betrag hat sich geändert – bitte Seite neu laden.'
)
assert.deepEqual(updateCalls('sub_h', before), [])
const res = await post(
'/api/abo/switch',
{ acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4000 },
{ cookie: customerCookie() }
)
assert.equal(res.status, 409)
assert.match(res.body.error, /Betrag hat sich geändert/)
// Passender Betrag: Wechsel laeuft, die Vorschau nutzt dasselbe proration_date
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
const prorationDate = nowUnix()
const from = st.calls.length
await switchToWartung('c1', consent, prorationDate, { expectedAmountCents: 4100 })
const [previewCall] = previewCalls(from)
assert.equal(previewCall.body['subscription_details[proration_date]'], String(prorationDate))
assert.equal(updateCalls('sub_h', from).length, 1)
})
// Fix 1 (4): wer mit einem veralteten Stand des Ereignisses kommt (Webhook war schneller), aendert nichts mehr
test('Abgleich mit veraltetem Ereignis nach dem Webhook: kein zweites T12', async () => {
const stale = await staleCardSwitch()
const snapshot = { ...(await listContractEvents('c1'))[0] }
assert.equal(snapshot.status, 'offen')
st.applyPendingUpdate('sub_h')
await postWebhook(webhookEvent('customer.subscription.pending_update_applied', { id: 'sub_h', object: 'subscription' }))
assert.equal(t12Count(), 1)
assert.equal(await reconcilePendingSwitch(snapshot), null)
assert.equal(t12Count(), 1)
assert.equal((await listContractEvents('c1'))[0].$id, stale.$id)
assert.equal((await listContractEvents('c1'))[0].doneBy, 'stripe')
})
// Kundenansicht P6 (Task 12): der Wechsel merkt sich die gewünschte Funktion (details.funktion) und GET /api/abo nennt sie (wechselFunktion)
const NEU_MODUS = { mode: config.kundenansichtNeu.mode, pilotEmails: [...config.kundenansichtNeu.pilotEmails] }
after(() => {
config.kundenansichtNeu.mode = NEU_MODUS.mode
config.kundenansichtNeu.pilotEmails = NEU_MODUS.pilotEmails
})
const pilot = (an) => { config.kundenansichtNeu.mode = an ? 'pilot' : 'off'; config.kundenansichtNeu.pilotEmails = ['k@example.invalid'] }
const F = { key: 'termine', projectId: 'wf_p1' }
aw.seed('websiteProjects', { $id: 'wf_p1', customerId: 'c1', projectName: 'Bäckerei' }); aw.seed('websiteProjects', { $id: 'wf_fremd', customerId: 'c2', projectName: 'Fremd' })
const bestelle = async (extra = {}) => {
await resetHosting(); st.invoicePreview = previewInvoice(line('price_hosting', -900, { proration: true, periodStart: nowUnix() }), line('price_wartung', 5000)); st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
return post('/api/abo/switch', { acceptTerms: true, earlyStart: true, termsVersion: config.abo.termsVersion, prorationDate: nowUnix(), amountDueCents: 4100, ...extra }, { cookie: customerCookie() })
}
const wechsel = async () => (await listContractEvents('c1')).find((e) => e.type === 'wechsel')
test('Wechsel mit Funktion: nur in der neuen Ansicht gespeichert; Ungültiges wird verworfen, der Wechsel läuft trotzdem', async () => {
pilot(true); assert.equal((await bestelle({ funktion: F })).status, 200); assert.deepEqual((await wechsel()).details.funktion, F); assert.ok(JSON.stringify((await wechsel()).details).length <= 2000)
for (const funktion of [{ key: 'analyse', projectId: 'wf_p1' }, { key: 'foo', projectId: 'wf_p1' }, { key: 'termine', projectId: 'wf_fremd' }, { key: 'termine' }, 'termine', [], null]) {
assert.equal((await bestelle({ funktion })).status, 200, JSON.stringify(funktion)); assert.equal(Object.hasOwn((await wechsel()).details, 'funktion'), false, JSON.stringify(funktion))
}
pilot(false); assert.equal((await bestelle({ funktion: F })).status, 200); assert.equal(Object.hasOwn((await wechsel()).details, 'funktion'), false)
})
test('GET /api/abo: wechselFunktion folgt dem jüngsten erledigten Wechsel, 14 Tage, Funktion off; alte Ansicht ohne den Schlüssel', async () => {
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
const ts = (tage) => new Date(Date.now() - tage * 86400000).toISOString()
const lege = async ({ status = 'erledigt', paidVor = 2, doneVor = null, funktion = F } = {}) => {
const ev = await recordContractEvent({ customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', consent, status, details: { from: 'hosting', switchedAt: ts(paidVor), ...(status === 'erledigt' && doneVor === null ? { paidAt: ts(paidVor) } : {}), ...(funktion ? { funktion } : {}) } })
if (doneVor !== null) await updateContractEvent(ev.$id, { status: 'erledigt', doneAt: ts(doneVor) })
}
const lies = async () => (await get('/api/abo', { cookie: customerCookie() })).subscription
const neu = async (setup) => { await resetHosting({ patch: { productKey: 'wartung' } }); await lege(setup); return (await lies()).wechselFunktion }
pilot(true)
assert.deepEqual(await neu({}), F); assert.deepEqual(await neu({ paidVor: 20, doneVor: 13 }), F) // sofort bezahlt (paidAt) bzw. später erledigt (doneAt)
for (const setup of [{ status: 'offen' }, { status: 'gescheitert' }, { status: 'widerrufen' }, { paidVor: 20, doneVor: 15 }, { funktion: null }]) assert.equal(await neu(setup), null, JSON.stringify(setup))
await neu({}); await lege({ funktion: null }); assert.equal((await lies()).wechselFunktion, null) // der jüngere Wechsel ohne Funktion gewinnt
await neu({}); aw.seed('portalFeatures', { $id: 'f_wf_p1_termine', customerId: 'c1', projectId: 'wf_p1', featureKey: 'termine', state: 'setup' }); assert.equal((await lies()).wechselFunktion, null)
await updateDocument('portalFeatures', 'f_wf_p1_termine', { state: 'off' }); assert.deepEqual((await lies()).wechselFunktion, F)
pilot(false); assert.equal(Object.hasOwn(await lies(), 'wechselFunktion'), false)
})