diff --git a/scripts/wechsel-gescheitert.mjs b/scripts/wechsel-gescheitert.mjs new file mode 100644 index 0000000..2f5c2c0 --- /dev/null +++ b/scripts/wechsel-gescheitert.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/** + * Wechsel ins Wartungs-Abo als gescheitert abschließen, wenn die SEPA-Lastschrift der Wechselrechnung nicht + * eingelöst wurde (Vertragsbedingungen § 9 Abs. 3, Rechtsrunde 28.09.2026). Das Team bekommt dann T12 mit den + * Handgriffen: Wechselrechnung stornieren, Preis ohne Verrechnung zurück auf Hosting, Metadaten zurücksetzen. + * Danach setzt dieses Skript das Ereignis `wechsel` auf `gescheitert` und schickt K14 an den Kunden und T12 + * (gescheitert) ans Team (markSwitchFailed in server/services/planSwitch.js, wie beim Webhook + * customer.subscription.pending_update_expired für Karten). + * + * Usage (im Container, als Datei): + * node scripts/wechsel-gescheitert.mjs --pruefen (nur anzeigen: offener Wechsel, + * Metadaten vor dem Wechsel) + * node scripts/wechsel-gescheitert.mjs --ausfuehren [Grund] (Ereignis gescheitert, K14, T12) + * Ohne Grund nennt K14: "Die SEPA-Lastschrift für die Wechselrechnung wurde nicht eingelöst." + */ +import 'dotenv/config' +import { config } from '../server/config.js' +import { getDocument } from '../server/services/appwriteAdmin.js' +import { findOpenSwitchEvent, markSwitchFailed, SWITCH_FAILED_REASONS } from '../server/services/planSwitch.js' + +const USAGE = 'Usage: node scripts/wechsel-gescheitert.mjs --pruefen | --ausfuehren [Grund]' + +/** { subscriptionId, mode: 'pruefen' | 'ausfuehren', grund? } oder null bei falschem Aufruf. */ +export function parseArgs(argv) { + const [subscriptionId, mode, ...rest] = argv.slice(2) + if (!/^sub_\w+$/.test(subscriptionId || '')) return null + if (mode === '--pruefen' && rest.length === 0) return { subscriptionId, mode: 'pruefen' } + if (mode === '--ausfuehren') return { subscriptionId, mode: 'ausfuehren', grund: rest.join(' ').trim() || SWITCH_FAILED_REASONS.lastschrift } + return null +} + +/** Offenen Wechsel des Abos zeigen und (ausfuehren) als gescheitert abschließen. Liefert { ok, event? }. */ +export async function run({ subscriptionId, mode, grund }, log = console.log) { + const sub = await getDocument(config.collections.subscriptions, subscriptionId).catch(() => null) + if (!sub) { + log(`❌ Abo ${subscriptionId} ist im Kundenportal nicht bekannt.`) + return { ok: false } + } + const event = await findOpenSwitchEvent(sub.customerId, subscriptionId) + if (!event) { + log(`❌ Kein offener Wechsel für Abo ${subscriptionId}.`) + return { ok: false } + } + const details = event.details || {} + const euro = `${(Number(details.amountDueCents || 0) / 100).toFixed(2).replace('.', ',')} €` + log(`Offener Wechsel ${event.$id} (Kunde ${event.customerId}, ${sub.email || 'ohne E-Mail'})`) + log(` Wechsel am ${details.switchedAt || '–'}, Zahlart ${details.paymentMethod || 'unbekannt'}, Rechnung ${details.invoiceId || '–'} über ${euro}`) + log(` Mindestlaufzeit des Hosting-Abos vor dem Wechsel ab ${details.previousTermStartAt || '–'}`) + log(`Metadaten am Stripe-Abo ${subscriptionId} auf den Stand vor dem Wechsel zurücksetzen:`) + for (const [key, value] of Object.entries(details.previousMetadata || {})) log(` ${key} = ${value ?? '(löschen)'}`) + if (mode === 'pruefen') { + log('') + log(`Nur geprüft, nichts geändert. Abschließen: node scripts/wechsel-gescheitert.mjs ${subscriptionId} --ausfuehren`) + return { ok: true, event } + } + const result = await markSwitchFailed(event.$id, grund, { doneBy: 'skript' }) + log(`✅ Wechsel ${event.$id} ist gescheitert: K14 an den Kunden und T12 ans Team verschickt (Fehler stehen im Log).`) + return { ok: true, event: result.event } +} + +async function main() { + const args = parseArgs(process.argv) + if (!args) { + console.error(`❌ ${USAGE}`) + process.exit(1) + } + const result = await run(args) + if (!result.ok) process.exitCode = 1 +} + +// Nur beim direkten Aufruf laufen lassen, nicht wenn ein Test das Modul importiert +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err) => { + console.error('❌', err.message) + process.exit(1) + }) +} diff --git a/server/mail-templates/K12 Wechsel Wartungs-Abo.html b/server/mail-templates/K12 Wechsel Wartungs-Abo.html new file mode 100644 index 0000000..8e51bd8 --- /dev/null +++ b/server/mail-templates/K12 Wechsel Wartungs-Abo.html @@ -0,0 +1,60 @@ + + + + + + + + +Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung + + + + +Ihr Wechsel ins Wartungs-Abo ist bestellt. Alle Vertragsunterlagen finden Sie im Anhang. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/K14 Wechsel nicht zustande gekommen.html b/server/mail-templates/K14 Wechsel nicht zustande gekommen.html new file mode 100644 index 0000000..fe1efab --- /dev/null +++ b/server/mail-templates/K14 Wechsel nicht zustande gekommen.html @@ -0,0 +1,52 @@ + + + + + + + + +Ihr Wechsel ins WEBklar Wartungs-Abo ist nicht zustande gekommen + + + + +Ihr Hosting-Abo läuft wie bisher weiter. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/T12 Wechsel.html b/server/mail-templates/T12 Wechsel.html new file mode 100644 index 0000000..a63ab17 --- /dev/null +++ b/server/mail-templates/T12 Wechsel.html @@ -0,0 +1,68 @@ + + + + + + + + +[Abo] Wechsel ins Wartungs-Abo: {{email}} + + + + +{{name}}: Wechsel vom Hosting- ins Wartungs-Abo. ͏ ͏ ͏ + + + + + diff --git a/server/routes/abo.js b/server/routes/abo.js index fdbcf49..89c843a 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -11,6 +11,14 @@ import { getLedger } from '../services/hourLedger.js' import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js' import { changeStorage, previewStorageChange, recordPaidStorage } from '../services/mailStorage.js' import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js' +import { + handlePendingUpdateApplied, + handlePendingUpdateExpired, + handleSwitchInvoicePaid, + handleSwitchPaymentFailed, + previewSwitch, + switchToWartung, +} from '../services/planSwitch.js' import { PRODUCT_KEYS, productView, termsDocFor } from '../services/products.js' import { ACTIVE_STATUSES, @@ -311,6 +319,64 @@ router.post('/storage', requireSession, async (req, res) => { } }) +// --------------------------------------------------------------------------- +// Wechsel vom Hosting- ins Wartungs-Abo (Spec 3.5, Bedingungen § 9): Vorschau und Bestellung (planSwitch.js) +// --------------------------------------------------------------------------- + +/** Vorschau fuer die Bestelluebersicht: Gutschrift, heute faellig, Monatspreis, neuer Abbuchungstag, Mindestlaufzeit. */ +router.post('/switch/preview', requireSession, async (req, res) => { + try { + if (req.session.viewAs) { + return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' }) + } + return res.json(await previewSwitch(getSessionCustomerId(req))) + } catch (err) { + return res.status(err.status || 500).json({ error: err.message || 'Vorschau fehlgeschlagen' }) + } +}) + +/** + * Bestellung { acceptTerms: true, earlyStart: true, termsVersion, prorationDate } ("Zahlungspflichtig bestellen"). + * Nachweis: Fassung, Zeitpunkt, IP, Browser und die Zustimmung zum Sofortbeginn (§ 357a Abs. 2 BGB) landen am + * Abo und im Ereignis `wechsel`. Antwort: die Abo-Ansicht wie in GET /api/abo und paymentUrl (Karte: Zahlung + * noch zu bestaetigen, das Portal leitet zur Stripe-Zahlungsseite weiter; sonst null). + */ +router.post('/switch', requireSession, async (req, res) => { + try { + if (req.session.viewAs) { + return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht gewechselt werden.' }) + } + const { acceptTerms, earlyStart, termsVersion, prorationDate } = req.body || {} + if (acceptTerms !== true) { + return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.' }) + } + if (earlyStart !== true) { + return res.status(400).json({ error: 'Bitte bestätigen Sie, dass wir sofort mit dem Wartungs-Abo beginnen sollen.' }) + } + if (!termsVersion || !Number.isInteger(prorationDate)) { + return res.status(400).json({ error: 'Die Bestellung ist unvollständig. Bitte laden Sie die Seite neu.' }) + } + if (termsVersion !== config.abo.termsVersion) { + return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' }) + } + const customerId = getSessionCustomerId(req) + const now = new Date().toISOString() + const { subscription, paymentUrl } = await switchToWartung( + customerId, + { termsVersion, consentAt: now, earlyStartConsentAt: now, consentIp: req.ip, consentUserAgent: req.get('user-agent') || '' }, + prorationDate + ) + const events = await listContractEvents(customerId).catch((e) => { + console.error('[abo] Vertragsereignisse nicht lesbar:', e.message) + return [] + }) + return res.json({ subscription: publicSubscription(subscription, events), paymentUrl }) + } catch (err) { + console.error('[abo] Wechsel fehlgeschlagen:', err.message) + return res.status(err.status || 500).json({ error: err.message || 'Wechsel fehlgeschlagen' }) + } +}) + // --------------------------------------------------------------------------- // Stripe-Webhook // --------------------------------------------------------------------------- @@ -461,11 +527,17 @@ async function handleStripeEvent(event) { if (storage.recorded) { console.log(`[abo] Zusatzspeicher bezahlt: Abo ${storage.subscriptionId}, ${storage.units} Pakete bis ${storage.periodEnd}`) } + // Wechselrechnung bezahlt (F26): SEPA-Wechsel erledigt; Karte, falls vor pending_update_applied + const switched = await handleSwitchInvoicePaid(fresh) + if (switched.completed) console.log(`[abo] Wechsel ins Wartungs-Abo bezahlt: Ereignis ${switched.eventId}`) return } case 'invoice.payment_failed': { const ref = obj.parent?.subscription_details?.subscription || obj.subscription const sub = ref ? await syncSubscription(typeof ref === 'string' ? ref : ref.id) : null + // Wechselrechnung per SEPA-Lastschrift nicht eingeloest: T12 mit den Handgriffen (vor T04, damit eine + // Wiederholung nach einem Fehler hier beide Hinweise nachholt) + await handleSwitchPaymentFailed(obj) await sendAdminNotice({ template: 'T04', values: { @@ -479,6 +551,15 @@ async function handleStripeEvent(event) { case 'customer.subscription.created': await syncSubscription(obj.id) return + // Wechsel ins Wartungs-Abo mit Karte (Rechtsrunde 28.09.2026): Stripe hat die zurueckgehaltene Aenderung + // mit der Zahlung uebernommen (erledigt, T12) oder verworfen (gescheitert, K14, T12). Am Stripe-Endpunkt + // freischalten (Task 20). + case 'customer.subscription.pending_update_applied': + await handlePendingUpdateApplied(obj.id) + return + case 'customer.subscription.pending_update_expired': + await handlePendingUpdateExpired(obj.id) + return case 'customer.subscription.updated': { const sub = await syncSubscription(obj.id) const prev = event.data.previous_attributes || {} diff --git a/server/services/contractEvents.js b/server/services/contractEvents.js index dd5105e..fcb4189 100644 --- a/server/services/contractEvents.js +++ b/server/services/contractEvents.js @@ -110,6 +110,12 @@ export async function recordContractEvent({ } } +/** Ein Ereignis mit geparstem details; fehlt es, 404 wie bei Appwrite. */ +export async function getContractEvent(id) { + const doc = await getDocument(COLLECTION(), id) + return { ...doc, details: parseJson(doc.details, {}) } +} + /** Ereignisse eines Kunden, neueste zuerst, mit geparstem details. */ export async function listContractEvents(customerId) { const docs = await listDocuments(COLLECTION(), [ diff --git a/server/services/mailer.js b/server/services/mailer.js index 5bca5d7..a4b83c7 100644 --- a/server/services/mailer.js +++ b/server/services/mailer.js @@ -7,8 +7,19 @@ const CUSTOMER_SENDER = 'WEBklar' const TEAM_SENDER = 'WEBklar Kundenportal' let transporter = null +let testTransport = null + +/** + * Nur für Tests (F25): alle Mails über diesen Transport statt per SMTP, z. B. mailSink() aus + * test/support/mailSink.js (nodemailer jsonTransport, gesendete Mails gesammelt). null schaltet zurück. + * Gerendert wird wie im Betrieb; so lassen sich Vorlagen, Blöcke und Anhänge in Routentests prüfen. + */ +export function setMailTransportForTests(transport) { + testTransport = transport || null +} function getTransporter() { + if (testTransport) return testTransport if (!config.smtp.host || !config.smtp.user) return null if (!transporter) { transporter = nodemailer.createTransport({ diff --git a/server/services/planSwitch.js b/server/services/planSwitch.js new file mode 100644 index 0000000..7ed745e --- /dev/null +++ b/server/services/planSwitch.js @@ -0,0 +1,561 @@ +import { readFile } from 'node:fs/promises' +import { config } from '../config.js' +import { getDocument } from './appwriteAdmin.js' +import { eventIdFor, getContractEvent, listContractEvents, recordContractEvent, updateContractEvent } from './contractEvents.js' +import { sendAdminNotice, sendCustomerMail } from './mailer.js' +import { contractAttachmentFor, describeItems } from './products.js' +import { + addMonths, + ensurePrice, + ensureTestCoupon, + getCurrentSubscription, + getStripe, + subscriptionIdOfInvoice, + syncSubscription, + termStartOf, +} from './stripeBilling.js' + +/** + * Wechsel vom Hosting- ins Wartungs-Abo (Spec 3.5; Bedingungen Fassung 4 § 9, § 7 Abs. 4, § 2 Abs. 5). + * + * Dasselbe Stripe-Abo wechselt den Preis der Abo-Position von Hosting auf Wartung: neuer Abbuchungstag + * (billing_cycle_anchor now), Gutschrift fuer die nicht genutzten Hosting-Tage (create_prorations mit dem + * proration_date der Vorschau) und 12 neue Monate Mindestlaufzeit ab dem Wechseltag (Metadaten termStartAt, + * switchedAt, switchedFrom). + * + * Rechtsrunde 28.09.2026: Der Wechsel wird erst mit der Zahlung wirksam (§ 9 Abs. 3). + * - Karte (und jede Zahlart ausser SEPA, auch unbekannt): payment_behavior pending_if_incomplete. Ist die + * Zahlung nicht sofort erfolgreich (3-D Secure, Ablehnung), haelt Stripe Preis, Anker, Metadaten und Rabatte + * in pending_update zurueck: Ereignis `wechsel` offen, paymentUrl = Stripe-Zahlungsseite der Rechnung, + * Spiegel unveraendert. Wirksam mit customer.subscription.pending_update_applied (oder invoice.paid, was + * zuerst ankommt); verfaellt die Aenderung (pending_update_expired, Stripe storniert die Rechnung), ist der + * Wechsel gescheitert (markSwitchFailed: K14 an den Kunden, T12 ans Team). + * - SEPA-Lastschrift: Stripe kennt dafuer keine ausstehenden Aktualisierungen, also allow_incomplete - der + * Wechsel gilt sofort, das Ereignis bleibt offen, bis invoice.paid kommt. Scheitert die Lastschrift + * (invoice.payment_failed), bekommt das Team T12 mit den Handgriffen und schliesst den Wechsel danach mit + * scripts/wechsel-gescheitert.mjs ab (markSwitchFailed). + * - K12 (Vertragsbestaetigung mit PDF der Fassung 4) geht sofort nach dem Klick raus, T12 erst, wenn der + * Wechsel wirksam ist. Mailfehler werden protokolliert und brechen den Wechsel nie ab. + */ + +function httpError(status, message) { + const err = new Error(message) + err.status = status + return err +} + +const fmtDate = (value) => + value + ? new Date(value).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) + : '–' +const fmtDateTime = (value) => + new Date(value).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'medium', timeStyle: 'short' }) +// Preis wie in den Bedingungen: "41,00 €" (normales Leerzeichen) +const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €` +const idOf = (ref) => (typeof ref === 'string' ? ref : ref?.id || null) + +const DAY_MS = 86400000 +// Die Bestelluebersicht rechnet ab dem Zeitpunkt der Vorschau; aelter als 30 Minuten -> Seite neu laden +const PREVIEW_MAX_AGE_SECONDS = 30 * 60 +// Uhrabweichung zwischen Browser-Anfrage und Server, die eine Vorschau "aus der Zukunft" noch haben darf +const PREVIEW_CLOCK_SKEW_SECONDS = 5 * 60 +// Toleranz fuer Sekunden zwischen proration_date und den Zeilen der Stripe-Vorschau (wie beim Zusatzspeicher) +const PRORATION_TOLERANCE_SECONDS = 300 +// invoice.payment_failed einer Wechselrechnung zaehlt als gescheiterte Lastschrift, wenn der Wechsel so lange her ist +const SEPA_FAILURE_WINDOW_MS = 30 * DAY_MS + +const PENDING_MESSAGE = 'Ein Wechsel wartet noch auf die Zahlung.' +const ONLY_HOSTING_MESSAGE = 'Nur ein Hosting-Abo kann ins Wartungs-Abo wechseln.' +const NOT_ACTIVE_MESSAGE = 'Der Wechsel ist erst nach Beginn Ihres Hosting-Abos möglich und nicht, solange eine Zahlung offen ist.' +const CANCELLED_MESSAGE = 'Ihr Hosting-Abo ist gekündigt. Ein Wechsel ist deshalb nicht möglich.' + +/** Grund in K14 ({{grund}}) je nach Weg: Karte (ausstehende Aktualisierung verfallen) oder SEPA (Skript). */ +export const SWITCH_FAILED_REASONS = Object.freeze({ + abgelaufen: + 'Die Zahlung der Wechselrechnung wurde nicht rechtzeitig abgeschlossen, zum Beispiel weil die von Ihrer Bank verlangte Bestätigung nicht erfolgt ist.', + lastschrift: 'Die SEPA-Lastschrift für die Wechselrechnung wurde nicht eingelöst.', +}) + +// Metadaten, die der Wechsel ueberschreibt und die bei einer gescheiterten Lastschrift zurueckgesetzt werden (F27) +const RESTORABLE_METADATA = ['productKey', 'termStartAt', 'switchedAt', 'switchedFrom'] + +/** + * Passt der Zeitpunkt der Vorschau (proration_date, Unix-Sekunden)? Die Bestelluebersicht zeigt Gutschrift, + * Betrag, Abbuchungstag und Mindestlaufzeit ab diesem Zeitpunkt, der Wechsel rechnet mit demselben + * proration_date. null = in Ordnung, sonst { status, message }: keine ganze Zahl oder aus der Zukunft 400; + * aelter als 30 Minuten oder an einem anderen Kalendertag in Deutschland (angezeigter Wechseltag) 409. + */ +export function previewProblem(prorationDate, now = Date.now()) { + if (!Number.isInteger(prorationDate) || prorationDate <= 0) { + return { status: 400, message: 'Die Vorschau fehlt. Bitte laden Sie die Seite neu.' } + } + const nowSeconds = Math.floor(now / 1000) + if (prorationDate > nowSeconds + PREVIEW_CLOCK_SKEW_SECONDS) { + return { status: 400, message: 'Die Vorschau ist ungültig. Bitte laden Sie die Seite neu.' } + } + if (nowSeconds - prorationDate > PREVIEW_MAX_AGE_SECONDS) { + return { status: 409, message: 'Die Vorschau ist älter als 30 Minuten. Bitte Seite neu laden.' } + } + if (fmtDate(prorationDate * 1000) !== fmtDate(now)) { + return { status: 409, message: 'Der Wechseltag hat sich durch den Datumswechsel verschoben. Bitte Seite neu laden.' } + } + return null +} + +/** + * Zahlart des Abos (F24): die Standard-Zahlungsmethode des Abos, sonst die des Kunden, aus + * subscriptions.retrieve(id, { expand: ['default_payment_method', 'customer.invoice_settings.default_payment_method'] }). + * Nicht erweitert (nur eine ID), unbekannt oder keine: null - dann gilt der Weg fuer Karten. + */ +export function paymentMethodTypeOf(stripeSub) { + const customer = stripeSub?.customer && typeof stripeSub.customer === 'object' ? stripeSub.customer : null + for (const method of [stripeSub?.default_payment_method, customer?.invoice_settings?.default_payment_method]) { + if (method && typeof method === 'object' && method.type) return method.type + } + return null +} + +/** Offenes Ereignis `wechsel` eines Abos (hoechstens eines, siehe 409 in switchToWartung) oder null. */ +export async function findOpenSwitchEvent(customerId, subscriptionId) { + if (!customerId || !subscriptionId) return null + const events = await listContractEvents(customerId) + return events.find((e) => e.type === 'wechsel' && e.status === 'offen' && e.subscriptionId === subscriptionId) || null +} + +async function requireSubscription(customerId) { + const doc = await getCurrentSubscription(customerId) + if (!doc) throw httpError(404, 'Kein Abo vorhanden.') + return doc +} + +const customerOf = (customerId) => + customerId ? getDocument(config.collections.customers, customerId).catch(() => null) : Promise.resolve(null) + +/** Name und E-Mail fuer K12/K14/T12: Kundendaten des Portals, sonst die E-Mail am Abo. */ +async function contactOf(customerId, fallbackEmail = '') { + const customer = await customerOf(customerId) + return { name: customer?.name || '', email: customer?.email || fallbackEmail || '' } +} + +/** + * Voraussetzungen im Portal (Spiegel und Ereignisse, wie canSwitch in GET /api/abo): Hosting-Abo, Status + * active, nicht gekuendigt (cancelAtPeriodEnd oder cancelAt), kein Wechsel, der auf die Zahlung wartet. + */ +function assertCanSwitch(doc, events) { + if ((doc.productKey || 'wartung') !== 'hosting') throw httpError(409, ONLY_HOSTING_MESSAGE) + if (doc.status !== 'active') throw httpError(409, NOT_ACTIVE_MESSAGE) + if (doc.cancelAtPeriodEnd || doc.cancelAt) throw httpError(409, CANCELLED_MESSAGE) + if (events.some((e) => e.type === 'wechsel' && e.status === 'offen' && e.subscriptionId === doc.$id)) { + throw httpError(409, PENDING_MESSAGE) + } +} + +// Coupon eines Rabatts (API 2025-10-29.clover: discount.source.coupon; aeltere Versionen: discount.coupon) +const couponIdOf = (discount) => idOf(discount?.source?.coupon ?? discount?.coupon) + +/** + * Rabatte eines Testkontos nach dem Wechsel: WEBKLAR-TEST-050-HOSTING faellt weg, WEBKLAR-TEST-050 kommt dazu, + * andere Rabatte (z. B. ein laufendes Angebot) bleiben. Ohne Aufloesung (nur ID) bleibt ein Rabatt ebenfalls. + */ +async function testAccountDiscounts(stripeSub) { + const hostingCoupon = config.products.hosting.testCouponId + const wartungCoupon = await ensureTestCoupon('wartung') + const keep = (stripeSub.discounts || []) + .filter((discount) => discount && ![hostingCoupon, wartungCoupon].includes(couponIdOf(discount))) + .map((discount) => ({ discount: idOf(discount) })) + return [...keep, { coupon: wartungCoupon }] +} + +/** + * Stripe-Stand des Abos fuer Vorschau und Wechsel. Stripe ist die Wahrheit: ein veralteter Spiegel (Webhook + * noch unterwegs) darf keinen zweiten Wechsel ausloesen - schon Wartung, zurueckgehaltene Aenderung + * (pending_update), anderer Status oder Kuendigung bei Stripe -> 409. + */ +async function stripeContext(doc, customer) { + const stripe = getStripe() + const [wartungPriceId, stripeSub] = await Promise.all([ + ensurePrice('wartung'), + stripe.subscriptions.retrieve(doc.$id, { + expand: ['default_payment_method', 'customer.invoice_settings.default_payment_method', 'discounts'], + }), + ]) + const items = describeItems(stripeSub.items?.data) + if (items.productKey !== 'hosting' || !items.aboItemId) throw httpError(409, ONLY_HOSTING_MESSAGE) + if (stripeSub.pending_update) throw httpError(409, PENDING_MESSAGE) + if (stripeSub.status !== 'active') throw httpError(409, NOT_ACTIVE_MESSAGE) + if (stripeSub.cancel_at_period_end || stripeSub.cancel_at) throw httpError(409, CANCELLED_MESSAGE) + const aboItem = stripeSub.items.data.find((item) => item.id === items.aboItemId) + const testAccount = config.abo.testEmails.includes(String(customer?.email || doc.email || '').toLowerCase()) + return { + stripeSub, + wartungPriceId, + aboItemId: items.aboItemId, + // Beginn des laufenden Abrechnungsmonats (seit API 2025-03 am Posten) + periodStart: aboItem?.current_period_start ?? stripeSub.current_period_start ?? null, + paymentMethod: paymentMethodTypeOf(stripeSub), + discounts: testAccount ? await testAccountDiscounts(stripeSub) : null, + } +} + +/** Abo (Spiegel), Kunde und Stripe-Stand fuer Vorschau und Wechsel, nach allen Voraussetzungen (404/409). */ +async function switchContext(customerId, current = null) { + const doc = current || (await requireSubscription(customerId)) + const [customer, events] = await Promise.all([customerOf(customerId), listContractEvents(customerId)]) + assertCanSwitch(doc, events) + return { doc, customer, ...(await stripeContext(doc, customer)) } +} + +const isProrationLine = (line) => { + const details = line?.parent?.subscription_item_details || line?.parent?.invoice_item_details || {} + return details.proration === true +} + +/** + * Vorschau fuer die Bestelluebersicht: { prorationDate, amountDueCents, creditCents, monthlyCents, + * nextChargeAt, minTermEnd }. Grundlage ist invoices.createPreview mit dem Preiswechsel der Abo-Position, + * billing_cycle_anchor now und create_prorations ab jetzt. creditCents ist die Gutschrift dieses Wechsels (die + * negativen anteiligen Zeilen ab prorationDate: nicht genutzte Hosting-Tage und gebuchter Zusatzspeicher, F29); + * aeltere, schon vorgemerkte Posten stecken nur im faelligen Betrag (amount_due, nach Guthaben und Rabatten). + * Abbuchungstag und Mindestlaufzeit rechnen ab dem Wechseltag (1 bzw. 12 Monate). + */ +export async function previewSwitch(customerId) { + const ctx = await switchContext(customerId) + const prorationDate = Math.floor(Date.now() / 1000) + const preview = await getStripe().invoices.createPreview({ + subscription: ctx.doc.$id, + subscription_details: { + items: [{ id: ctx.aboItemId, price: ctx.wartungPriceId }], + billing_cycle_anchor: 'now', + proration_behavior: 'create_prorations', + proration_date: prorationDate, + }, + // Testkonto: die Rabatte nach dem Wechsel (sonst erbt die Vorschau die Rabatte des Abos) + ...(ctx.discounts ? { discounts: ctx.discounts } : {}), + }) + const credit = (preview?.lines?.data || []) + .filter((line) => isProrationLine(line) && line.amount < 0) + .filter((line) => !line.period?.start || line.period.start >= prorationDate - PRORATION_TOLERANCE_SECONDS) + .reduce((sum, line) => sum + (Number(line.amount) || 0), 0) + const start = new Date(prorationDate * 1000) + return { + prorationDate, + amountDueCents: Number(preview?.amount_due) || 0, + creditCents: Math.abs(credit), + monthlyCents: config.products.wartung.amountCents, + nextChargeAt: addMonths(start, 1).toISOString(), + minTermEnd: addMonths(start, 12).toISOString(), + } +} + +/** Vertragsunterlagen (PDF) in der zugestimmten Fassung; ohne Datei geht K12 ohne Anhang raus (Fehler im Log). */ +async function contractPdfAttachment(termsVersion) { + const { filename, path } = contractAttachmentFor(termsVersion) + const content = await readFile(path).catch((err) => { + console.error(`[wechsel] Vertragsunterlagen ${filename} nicht lesbar:`, err.message) + return null + }) + return content ? [{ filename, content, contentType: 'application/pdf' }] : [] +} + +/** K12 an den Kunden: Vertragsbestaetigung mit PDF. Ein Mailfehler bricht den Wechsel nicht ab. */ +async function sendSwitchConfirmation(contact, consent, facts, blocks) { + if (!contact.email) { + console.error('[wechsel] K12 nicht verschickt: keine E-Mail-Adresse zum Kunden') + return + } + const attachments = await contractPdfAttachment(consent.termsVersion) + await sendCustomerMail(contact.email, { + template: 'K12', + values: { + name: contact.name, + wechseltag: fmtDate(facts.switchedAt), + betrag_heute: fmtEuro(facts.amountDueCents), + monatspreis: fmtEuro(config.products.wartung.amountCents), + mindestlaufzeit_bis: fmtDate(addMonths(new Date(facts.switchedAt), 12)), + bedingungen_version: consent.termsVersion, + zustimmung_zeitpunkt: fmtDateTime(consent.consentAt), + }, + blocks, + attachments, + }).catch((err) => console.error(`[wechsel] K12 an ${contact.email} fehlgeschlagen:`, err.message)) +} + +/** T12 ans Team (sendAdminNotice protokolliert Fehler selbst und wirft nie). */ +async function sendSwitchNotice(contact, subscriptionId, facts, blocks) { + await sendAdminNotice({ + template: 'T12', + values: { + email: contact.email, + name: contact.name, + stripe_abo_id: subscriptionId, + wechseltag: fmtDate(facts.switchedAt), + betrag_heute: fmtEuro(facts.amountDueCents), + monatspreis: fmtEuro(config.products.wartung.amountCents), + }, + blocks, + }) +} + +const factsOf = (event) => ({ switchedAt: event.details?.switchedAt || event.consentAt, amountDueCents: event.details?.amountDueCents }) + +// Ein Wechsel je Abo zur selben Zeit (Doppelklick): der zweite wartet nicht, sondern bekommt 409 +const switchesInFlight = new Set() + +/** + * Wechsel ins Wartungs-Abo (Button "Zahlungspflichtig bestellen"). consent: { termsVersion, consentAt, + * consentIp, consentUserAgent, earlyStartConsentAt } - ohne Zustimmung zum Sofortbeginn kein Wechsel (400, F16). + * prorationDate: Zeitpunkt der Vorschau (previewProblem). Voraussetzungen wie previewSwitch (409). + * Liefert { subscription, paymentUrl, event }: paymentUrl nur, wenn Stripe die Aenderung bis zur Zahlung + * zurueckhaelt (pending_update, Karte), sonst null - auch bei SEPA, dort zieht Stripe die Lastschrift ein. + */ +export async function switchToWartung(customerId, consent, prorationDate) { + if (!consent?.termsVersion || !consent?.consentAt) { + throw httpError(400, 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu wechseln.') + } + if (!consent.earlyStartConsentAt) { + throw httpError(400, 'Bitte bestätigen Sie, dass wir sofort mit dem Wartungs-Abo beginnen sollen.') + } + const problem = previewProblem(prorationDate) + if (problem) throw httpError(problem.status, problem.message) + const doc = await requireSubscription(customerId) + if (switchesInFlight.has(doc.$id)) throw httpError(409, 'Ihr Wechsel wird gerade verarbeitet.') + switchesInFlight.add(doc.$id) + try { + return await performSwitch(customerId, doc, consent, prorationDate) + } finally { + switchesInFlight.delete(doc.$id) + } +} + +async function performSwitch(customerId, current, consent, prorationDate) { + const ctx = await switchContext(customerId, current) + const { doc, customer, stripeSub } = ctx + // Seit der Vorschau hat ein neuer Abrechnungsmonat begonnen: Gutschrift und Betrag stimmen nicht mehr + if (ctx.periodStart && prorationDate < ctx.periodStart) { + throw httpError(409, 'Ihr Abrechnungsmonat hat seit der Vorschau neu begonnen. Bitte Seite neu laden.') + } + const sepa = ctx.paymentMethod === 'sepa_debit' + const switchedAt = new Date().toISOString() + // Stripe-Metadaten am Abo (max. 500 Zeichen je Wert): Produkt, neue Mindestlaufzeit, Nachweis der Zustimmung + const metadata = { + productKey: 'wartung', + termStartAt: switchedAt, + termsVersion: consent.termsVersion, + termsAcceptedAt: consent.consentAt, + earlyStartConsentAt: consent.earlyStartConsentAt, + consentIp: String(consent.consentIp || '').slice(0, 64), + consentUserAgent: String(consent.consentUserAgent || '').slice(0, 300), + switchedFrom: 'hosting', + switchedAt, + } + const updated = await getStripe().subscriptions.update(doc.$id, { + items: [{ id: ctx.aboItemId, price: ctx.wartungPriceId }], + billing_cycle_anchor: 'now', + proration_behavior: 'create_prorations', + proration_date: prorationDate, + // Karte: erst mit der Zahlung wirksam; SEPA kennt keine ausstehenden Aktualisierungen (Rechtsrunde) + payment_behavior: sepa ? 'allow_incomplete' : 'pending_if_incomplete', + metadata, + ...(ctx.discounts ? { discounts: ctx.discounts } : {}), + expand: ['latest_invoice'], + }) + + const invoice = updated.latest_invoice && typeof updated.latest_invoice === 'object' ? updated.latest_invoice : null + const pending = Boolean(updated.pending_update) + // Ohne Rechnung ist nichts zu zahlen; eine nicht aufgeloeste Rechnung (nur ID) gilt als offen + const paid = invoice ? invoice.status === 'paid' : !updated.latest_invoice + const effective = !pending + const paymentUrl = pending ? invoice?.hosted_invoice_url || null : null + const amountDueCents = Number(invoice?.amount_due) || 0 + + // Spiegel erst, wenn Stripe den Wechsel uebernommen hat; ein Fehler hier bricht nichts ab (der Webhook + // customer.subscription.updated spiegelt ohnehin nach) + let subscription = doc + if (effective) { + subscription = + (await syncSubscription(doc.$id).catch((err) => { + console.error(`[wechsel] Spiegel von Abo ${doc.$id} nach dem Wechsel nicht aktualisiert:`, err.message) + return null + })) || doc + } + + const event = await recordSwitchEvent({ + doc, + customerId, + consent, + status: effective && paid ? 'erledigt' : 'offen', + details: { + from: 'hosting', + amountDueCents, + prorationDate, + switchedAt, + paymentMethod: ctx.paymentMethod, + invoiceId: idOf(updated.latest_invoice), + // F12/F27: Link zur Stripe-Zahlungsseite, solange die Zahlung offen ist (GET /api/abo switchPending) + paymentUrl, + effectiveAt: effective ? switchedAt : null, + paidAt: effective && paid ? switchedAt : null, + // F27: Stand vor dem Wechsel, damit eine gescheiterte Lastschrift ihn wiederherstellen kann + previousTermStartAt: termStartOf(stripeSub).toISOString(), + previousMetadata: Object.fromEntries(RESTORABLE_METADATA.map((key) => [key, stripeSub.metadata?.[key] ?? null])), + }, + }) + + const contact = { name: customer?.name || '', email: customer?.email || doc.email || '' } + const facts = { switchedAt, amountDueCents } + const lastschrift = sepa && effective && !paid + // K12 sofort; zahlung_offen nur bei zurueckgehaltener Aenderung (Karte), lastschrift bei SEPA (F37) + await sendSwitchConfirmation(contact, consent, facts, { zahlung_offen: pending, lastschrift }) + // T12 erst, wenn der Wechsel wirksam ist - ohne Nachweis sofort, damit das Team ihn von Hand verfolgt + if (effective || !event) { + await sendSwitchNotice(contact, doc.$id, facts, { wirksam: effective, lastschrift, nachweis_fehlt: !event }) + } + return { subscription, paymentUrl, event } +} + +/** + * Ereignis `wechsel` (Nachweis der Bestellung und Stand der Zahlung). Feste ID je Abo und Wechselzeitpunkt + * (eventIdFor, 36 Zeichen). Ein Fehler beim Speichern bricht den schon bei Stripe ausgefuehrten Wechsel nicht + * ab: er wird protokolliert, T12 meldet ihn dem Team (nachweis_fehlt). + */ +async function recordSwitchEvent({ doc, customerId, consent, status, details }) { + try { + return await recordContractEvent({ + id: eventIdFor('wechsel', `${doc.$id}:${details.switchedAt}`), + customerId, + subscriptionId: doc.$id, + type: 'wechsel', + productKey: 'wartung', + details, + consent: { + termsVersion: consent.termsVersion, + consentAt: consent.consentAt, + consentIp: consent.consentIp || null, + consentUserAgent: consent.consentUserAgent || null, + earlyStartConsentAt: consent.earlyStartConsentAt, + }, + status, + }) + } catch (err) { + console.error(`[wechsel] Ereignis für Abo ${doc.$id} nicht gespeichert:`, err.message) + return null + } +} + +/** + * Wechsel ist wirksam und bezahlt: Ereignis erledigt (F01, updateContractEvent). War er noch nicht wirksam + * (Karte, Zahlung stand aus), geht jetzt T12 raus; bei SEPA ging es schon beim Klick raus. + */ +async function completeSwitch(event, contact) { + const now = new Date().toISOString() + const wasEffective = Boolean(event.details?.effectiveAt) + await updateContractEvent(event.$id, { + status: 'erledigt', + doneAt: now, + doneBy: 'stripe', + details: { ...(wasEffective ? {} : { effectiveAt: now }), paidAt: now }, + }) + if (!wasEffective) await sendSwitchNotice(contact, event.subscriptionId, factsOf(event), { wirksam: true }) +} + +/** + * Wechsel ist gescheitert (F27): Ereignis `gescheitert` mit Grund, K14 an den Kunden (bei SEPA mit dem Hinweis, + * dass der Abbuchungstag der Wechseltag bleibt), T12 ans Team. Aufgerufen vom Webhook + * customer.subscription.pending_update_expired und fuer SEPA vom Team ueber scripts/wechsel-gescheitert.mjs + * (options.doneBy 'skript'). Nur ein offenes Ereignis wird geaendert; sonst { skipped } ohne Mails. + */ +export async function markSwitchFailed(eventId, grund, { doneBy = 'stripe' } = {}) { + const event = await getContractEvent(eventId) + if (event.type !== 'wechsel') throw httpError(400, `Ereignis ${eventId} ist kein Wechsel.`) + if (event.status !== 'offen') return { skipped: `Wechsel ist schon ${event.status}`, event } + const reason = String(grund || '').trim() || SWITCH_FAILED_REASONS.abgelaufen + const now = new Date().toISOString() + await updateContractEvent(eventId, { status: 'gescheitert', doneAt: now, doneBy, details: { failedAt: now, grund: reason } }) + const mirror = await getDocument(config.collections.subscriptions, event.subscriptionId).catch(() => null) + const contact = await contactOf(event.customerId, mirror?.email) + if (contact.email) { + await sendCustomerMail(contact.email, { + template: 'K14', + values: { name: contact.name, grund: reason }, + blocks: { lastschrift: event.details?.paymentMethod === 'sepa_debit' }, + }).catch((err) => console.error(`[wechsel] K14 an ${contact.email} fehlgeschlagen:`, err.message)) + } else { + console.error(`[wechsel] K14 nicht verschickt: keine E-Mail-Adresse zu Wechsel ${eventId}`) + } + await sendSwitchNotice(contact, event.subscriptionId, factsOf(event), { gescheitert: true }) + return { failed: true, event: await getContractEvent(eventId) } +} + +// --------------------------------------------------------------------------- +// Stripe-Webhooks (routes/abo.js) +// --------------------------------------------------------------------------- + +/** customer.subscription.pending_update_applied: Stripe hat den Wechsel mit der Zahlung uebernommen. */ +export async function handlePendingUpdateApplied(subscriptionId) { + const sub = await syncSubscription(subscriptionId) + if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' } + const event = await findOpenSwitchEvent(sub.customerId, subscriptionId) + // Kein offenes Ereignis: invoice.paid war schneller (schon erledigt) oder eine Wiederholung + if (!event) return { skipped: 'kein offener Wechsel' } + await completeSwitch(event, await contactOf(sub.customerId, sub.email)) + return { completed: true, eventId: event.$id } +} + +/** customer.subscription.pending_update_expired: nicht rechtzeitig bezahlt, Stripe hat die Rechnung storniert. */ +export async function handlePendingUpdateExpired(subscriptionId) { + const sub = await syncSubscription(subscriptionId) + if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' } + const event = await findOpenSwitchEvent(sub.customerId, subscriptionId) + if (!event) { + console.warn(`[wechsel] pending_update_expired für Abo ${subscriptionId}, aber kein offener Wechsel`) + return { skipped: 'kein offener Wechsel' } + } + return markSwitchFailed(event.$id, SWITCH_FAILED_REASONS.abgelaufen) +} + +/** + * invoice.paid (F26) einer Wechselrechnung (billing_reason subscription_update) mit offenem Ereignis: SEPA -> + * erledigt. Karte: kommt invoice.paid vor pending_update_applied, schliesst es den Wechsel ebenso (mit T12), + * aber erst, wenn Stripe die zurueckgehaltene Aenderung uebernommen hat. invoice ist die frisch geladene Rechnung. + */ +export async function handleSwitchInvoicePaid(invoice) { + if (invoice?.billing_reason !== 'subscription_update') return { skipped: 'keine Wechselrechnung' } + const subscriptionId = idOf(subscriptionIdOfInvoice(invoice)) + if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' } + const doc = + (await getDocument(config.collections.subscriptions, subscriptionId).catch(() => null)) || (await syncSubscription(subscriptionId)) + if (!doc) return { skipped: 'Abo ohne Kundenzuordnung' } + const event = await findOpenSwitchEvent(doc.customerId, subscriptionId) + if (!event) return { skipped: 'kein offener Wechsel' } + if (event.details?.invoiceId && event.details.invoiceId !== invoice.id) return { skipped: 'andere Rechnung' } + const stripeSub = await getStripe().subscriptions.retrieve(subscriptionId) + if (stripeSub.pending_update) return { skipped: 'Stripe hält die Änderung noch zurück' } + if (!event.details?.effectiveAt) await syncSubscription(subscriptionId) + await completeSwitch(event, await contactOf(doc.customerId, doc.email)) + return { completed: true, eventId: event.$id } +} + +/** + * invoice.payment_failed einer Wechselrechnung bei SEPA-Lastschrift (Abo mit switchedAt in den letzten 30 + * Tagen, billing_reason subscription_update, keine zurueckgehaltene Aenderung): T12 mit den Handgriffen fuer + * das Team. Das Ereignis bleibt offen, bis das Team den Wechsel mit dem Skript als gescheitert abschliesst. + * Bei Karte haelt Stripe die Aenderung zurueck (kein switchedAt), dort greift pending_update_expired. + */ +export async function handleSwitchPaymentFailed(invoice) { + if (invoice?.billing_reason !== 'subscription_update') return { skipped: 'keine Wechselrechnung' } + const subscriptionId = idOf(subscriptionIdOfInvoice(invoice)) + if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' } + const stripeSub = await getStripe().subscriptions.retrieve(subscriptionId) + const switchedAt = Date.parse(stripeSub.metadata?.switchedAt || '') + if (!switchedAt || Date.now() - switchedAt > SEPA_FAILURE_WINDOW_MS || stripeSub.pending_update) { + return { skipped: 'kein wirksamer Wechsel in den letzten 30 Tagen' } + } + const contact = await contactOf(stripeSub.metadata?.webklarCustomerId, stripeSub.metadata?.email || invoice.customer_email) + await sendSwitchNotice( + contact, + subscriptionId, + { switchedAt: new Date(switchedAt).toISOString(), amountDueCents: invoice.amount_due }, + { lastschrift_gescheitert: true } + ) + return { notified: true } +} diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index c5ffaed..a76aaab 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -263,7 +263,7 @@ export async function syncSubscription(subscriptionId) { return doc } -function addMonths(date, months) { +export function addMonths(date, months) { // Monatsende abfangen (31.01. + 1 Monat = 28./29.02., wie bei Stripe) const d = new Date(date) const day = d.getUTCDate() diff --git a/test/contractEvents.test.js b/test/contractEvents.test.js index ae1d121..5aa12b8 100644 --- a/test/contractEvents.test.js +++ b/test/contractEvents.test.js @@ -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 () => { diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 039d69b..9854ad7 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -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) + } +}) diff --git a/test/planSwitch.test.js b/test/planSwitch.test.js new file mode 100644 index 0000000..a9c9072 --- /dev/null +++ b/test/planSwitch.test.js @@ -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) +}) diff --git a/test/support.test.js b/test/support.test.js index 337a366..67c3735 100644 --- a/test/support.test.js +++ b/test/support.test.js @@ -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 diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index 43de0c0..3b46926 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -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 diff --git a/test/support/mailSink.js b/test/support/mailSink.js new file mode 100644 index 0000000..294d79a --- /dev/null +++ b/test/support/mailSink.js @@ -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 + }, + } +}