diff --git a/server/mail-templates/K13 Zusatzspeicher.html b/server/mail-templates/K13 Zusatzspeicher.html new file mode 100644 index 0000000..ec10023 --- /dev/null +++ b/server/mail-templates/K13 Zusatzspeicher.html @@ -0,0 +1,66 @@ + + + + + + + + +Ihr E-Mail-Zusatzspeicher – Bestätigung + + + + +Ihr E-Mail-Zusatzspeicher: {{pakete}} Pakete, {{gesamt_gb}} GB gesamt. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/T13 Speicher-Aufgabe.html b/server/mail-templates/T13 Speicher-Aufgabe.html new file mode 100644 index 0000000..5625d50 --- /dev/null +++ b/server/mail-templates/T13 Speicher-Aufgabe.html @@ -0,0 +1,64 @@ + + + + + + + + +[Speicher] {{aktion}}: {{email}} + + + + +{{name}}: E-Mail-Zusatzspeicher {{aktion}}, {{neue_groesse_gb}} GB gesamt. ͏ ͏ ͏ + + + + + diff --git a/server/routes/abo.js b/server/routes/abo.js index 89c843a..54d2db8 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -9,7 +9,7 @@ import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent } import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js' import { getLedger } from '../services/hourLedger.js' import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js' -import { changeStorage, previewStorageChange, recordPaidStorage } from '../services/mailStorage.js' +import { changeStorage, previewStorageChange, recordPaidStorage, storageTaskKind } from '../services/mailStorage.js' import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js' import { handlePendingUpdateApplied, @@ -80,8 +80,11 @@ function publicSubscription(sub, events = []) { const productKey = productKeyOf(sub) const own = events.filter((e) => e.subscriptionId === sub.$id) const openOf = (type) => own.filter((e) => e.type === type && e.status === 'offen') - const pendingPlus = openOf('speicher').find((e) => e.details?.toUnits > e.details?.fromUnits) - const pendingMinus = openOf('speicher').find((e) => e.details?.toUnits < e.details?.fromUnits) + // Offene Speicher-Aufgaben nach Art (Task 8, Fix 1): "einrichten" (Plus ueber die bezahlte Menge, konsoleH + // noch nicht umgestellt) und "periodenende" (Minus oder Plus bis zur bezahlten Menge, gilt ab Periodenende) + const openStorage = openOf('speicher') + const pendingSetup = openStorage.some((e) => storageTaskKind(e) === 'einrichten') + const pendingReduction = openStorage.find((e) => storageTaskKind(e) === 'periodenende') || null const pendingSwitch = openOf('wechsel')[0] || null // Plus/Minus und Wechsel nur bei Status active, ohne Kuendigung und ohne offene Wechselzahlung const cancelled = Boolean(sub.cancelAtPeriodEnd || sub.cancelAt) @@ -120,9 +123,9 @@ function publicSubscription(sub, events = []) { storage: { units: Number(sub.storageUnits) || 0, // Plus bestellt, konsoleH noch nicht umgestellt ("wird eingerichtet") - pendingSetup: Boolean(pendingPlus), - // Minus bestellt, wirkt zum Periodenende ("ab TT.MM. X GB") - pendingReduction: pendingMinus ? { units: pendingMinus.details.toUnits, effectiveAt: pendingMinus.dueAt || null } : null, + pendingSetup, + // Zielgroesse ab Periodenende ("ab TT.MM. X GB") + pendingReduction: pendingReduction ? { units: pendingReduction.details?.toUnits ?? null, effectiveAt: pendingReduction.dueAt || null } : null, }, canChangeStorage, canSwitch: canChangeStorage && productKey === 'hosting', diff --git a/server/services/mailStorage.js b/server/services/mailStorage.js index 27dcb86..60cb102 100644 --- a/server/services/mailStorage.js +++ b/server/services/mailStorage.js @@ -25,12 +25,20 @@ import { * bis zur bezahlten Menge proration_behavior none, darueber anteilig - notfalls in zwei Schritten * (planStorageChange). * - Minus ("Speichern"): Stueckzahl sofort bei Stripe, ohne Erstattung (none). Die Postfachgrenze in - * konsoleH sinkt erst zum Ende des Abrechnungsmonats (Aufgabe fuers Team, dueAt = Periodenende). - * - Jede Aenderung ist ein Ereignis `speicher` mit Status offen (= Aufgabe fuers Team in konsoleH); nur ein - * Plus traegt Zustimmungsdaten, ein Minus keine (Rechtsrunde 28.09.2026). Eine neue Aenderung ersetzt - * die aeltere offene Aufgabe desselben Abos (F21): eine Aufgabe je Abo mit Zielgroesse und Datum. - * - Bezahlte Menge je Abrechnungsmonat (storagePaidUnits, storagePaidPeriodEnd): beim Plus gesetzt, sonst - * aus der regulaeren Speicher-Zeile jeder bezahlten Rechnung (recordPaidStorage, Webhook invoice.paid). + * konsoleH sinkt erst zum Ende des Abrechnungsmonats. + * - Jede Aenderung ist ein Ereignis `speicher` (Nachweis; Status offen = Aufgabe fuers Team). Die Aufgabe + * hat eine Art (details.kind): "einrichten" (Plus ueber die bezahlte Menge, sofort, Ziel = neue Menge) + * oder "periodenende" (Minus oder Plus bis zur bezahlten Menge, faellig zum Periodenende, Ziel = neue + * Menge). Eine neue Aufgabe ersetzt aeltere offene derselben Art; eine Einrichtung ersetzt ausserdem eine + * geplante Verringerung mit kleinerem Ziel; eine Verringerung laesst eine offene Einrichtung stehen. So + * gibt es hoechstens eine offene Aufgabe je Art und Abo. Nur ein Plus traegt Zustimmungsdaten + * (Rechtsrunde 28.09.2026). + * - Reihenfolge: Ereignis zuerst (offen), dann Stripe. Lehnt Stripe ab, wird das Ereignis "gescheitert" + * und der Kunde sieht den Fehler; nach einer wirksamen Stripe-Aenderung bricht nichts mehr ab + * (Spiegel, Buchung, Aufgaben, Mails: Fehler im Log, T13 sagt es dem Team). + * - Bezahlte Menge je Abrechnungsmonat (storagePaidUnits, storagePaidPeriodEnd): beim Plus max(bezahlt, + * neu), beim Minus max(bezahlt, bisher) - jede Position war fuer den Monat berechnet; sonst aus der + * regulaeren Speicher-Zeile jeder bezahlten Rechnung (recordPaidStorage, Webhook invoice.paid). */ function httpError(status, message) { @@ -46,10 +54,16 @@ const fmtDate = (iso) => // Preis wie in den Bedingungen: "2,00 €" (normales Leerzeichen) const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €` const toIso = (unixSeconds) => (unixSeconds ? new Date(unixSeconds * 1000).toISOString() : null) +const sameInstant = (a, b) => a === b || new Date(a).getTime() === new Date(b).getTime() +// Appwrite-Fehler: 4xx dauerhaft (Attribut/Collection fehlt, ungueltige Daten), 5xx/Netz voruebergehend (wie Task 7) +const isPermanent = (err) => Boolean(err?.status) && err.status < 500 +const shortError = (err) => String(err?.message || err).slice(0, 200) const unitsOf = (doc) => Number(doc?.storageUnits) || 0 const totalGbOf = (units) => config.mailStorage.includedGb + units * config.mailStorage.gbPerUnit const monthlyCentsOf = (units) => units * config.mailStorage.amountCents +// Seit API 2025-03 liegt current_period_end am Subscription-Item +const periodEndOf = (stripeSub) => stripeSub?.current_period_end || stripeSub?.items?.data?.[0]?.current_period_end || null /** * Stripe-Schritte fuer eine Aenderung auf newUnits Pakete: { direction: 'plus' | 'minus' | 'none', steps: @@ -80,17 +94,26 @@ export function planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits /** * Fuer den laufenden Abrechnungsmonat schon bezahlte Pakete: storagePaidUnits, wenn storagePaidPeriodEnd - * der laufende Zeitraum ist (Appwrite gibt Datumswerte mit +00:00 zurueck, der Spiegel schreibt Z: gleicher - * Zeitpunkt zaehlt), sonst die aktuelle Menge (die regulaere Rechnung des Monats hat sie berechnet). + * der laufende Zeitraum ist (periodEnd, sonst doc.currentPeriodEnd; Appwrite gibt Datumswerte mit +00:00 + * zurueck, der Spiegel schreibt Z: gleicher Zeitpunkt zaehlt), sonst die aktuelle Menge (die regulaere + * Rechnung des Monats hat sie berechnet). */ -export function paidUnitsOf(doc) { +export function paidUnitsOf(doc, periodEnd = doc?.currentPeriodEnd) { const paidEnd = doc?.storagePaidPeriodEnd - const periodEnd = doc?.currentPeriodEnd - const samePeriod = - Boolean(paidEnd && periodEnd) && (paidEnd === periodEnd || new Date(paidEnd).getTime() === new Date(periodEnd).getTime()) + const samePeriod = Boolean(paidEnd && periodEnd) && sameInstant(paidEnd, periodEnd) return samePeriod ? Number(doc.storagePaidUnits) || 0 : unitsOf(doc) } +/** + * Art der Aufgabe eines Ereignisses `speicher`: details.kind ("einrichten" | "periodenende"); aeltere + * Ereignisse ohne kind (vor Fix 1) nach der Richtung (Plus = einrichten, sonst periodenende). + */ +export function storageTaskKind(event) { + const details = event?.details || {} + if (details.kind === 'einrichten' || details.kind === 'periodenende') return details.kind + return Number(details.toUnits) > Number(details.fromUnits) ? 'einrichten' : 'periodenende' +} + async function requireSubscription(customerId) { const doc = await getCurrentSubscription(customerId) if (!doc) throw httpError(404, 'Kein Abo vorhanden.') @@ -111,11 +134,14 @@ function reductionEffectiveAt(doc) { return periodEnd || cancelAt } +// Toleranz zwischen dem eigenen proration_date und Stripes Zeilen (Sekundenrundung) +const PRORATION_TOLERANCE_SECONDS = 2 + /** * Anteiliger Betrag der Speicher-Zeilen einer Stripe-Vorschau (naechste Rechnung): Belastung fuer die neue - * Menge abzueglich Gutschrift fuer die alte, jeweils ab prorationDate. Aeltere anteilige Zeilen (eine - * fruehere Aenderung im selben Monat, schon als Rechnungsposition vorgemerkt) und die regulaeren Zeilen des - * naechsten Monats zaehlen nicht. + * Menge abzueglich Gutschrift fuer die alte, genau ab prorationDate (period.start, ±2 s). Aeltere anteilige + * Zeilen (eine fruehere Aenderung im selben Monat, schon als Rechnungsposition vorgemerkt) und die + * regulaeren Zeilen des naechsten Monats zaehlen nicht. */ function storageProrationCents(preview, priceIds, prorationDate) { return (preview?.lines?.data || []) @@ -124,8 +150,7 @@ function storageProrationCents(preview, priceIds, prorationDate) { const details = parent.subscription_item_details || parent.invoice_item_details || {} if (details.proration !== true) return false if (!priceIds.has(line.pricing?.price_details?.price)) return false - // Toleranz fuer Sekunden zwischen Vorschau und Stripe-Rundung - return !line.period?.start || line.period.start >= prorationDate - 300 + return Math.abs(Number(line.period?.start) - prorationDate) <= PRORATION_TOLERANCE_SECONDS }) .reduce((sum, line) => sum + (Number(line.amount) || 0), 0) } @@ -134,6 +159,12 @@ function storageProrationCents(preview, priceIds, prorationDate) { * Vorschau fuer newUnits Pakete: Richtung, Gesamtgroesse, Monatspreis des Speichers, Mehr-/Minderpreis je * Monat, anteiliger Betrag fuer den Rest des laufenden Monats (aus invoices.createPreview), naechste Abbuchung * und Wirksamkeit (Minus: Periodenende, sonst jetzt). Bis zur schon bezahlten Menge kostet ein Plus nichts. + * + * Abrechnungsmodus (Fix 1): Abos aus dem Checkout laufen im flexiblen Modus (billing_mode.type 'flexible', + * Stripes Standard) - dort schreibt Stripe den zuletzt berechneten Betrag gut, die Vorschau hat die im + * Monat schon bezahlten Pakete also selbst verrechnet und gilt unveraendert. Nur im klassischen Modus + * ('classic') rechnet Stripe von der heutigen Menge aus; dann zaehlen nur die Pakete ueber der bezahlten + * Menge (F19: p × (neu − max(aktuell, bezahlt)) / (neu − aktuell)). */ export async function previewStorageChange(customerId, newUnits) { const doc = await requireSubscription(customerId) @@ -159,9 +190,10 @@ export async function previewStorageChange(customerId, newUnits) { // Gutschrift-Zeilen tragen den Preis des vorhandenen Postens (kann ein aelterer Speicher-Preis sein) const priceIds = new Set([priceId, items.find((item) => item.id === storageItemId)?.price?.id].filter(Boolean)) const previewed = storageProrationCents(preview, priceIds, prorationDate) - // F19: Stripe rechnet die Vorschau von der heutigen Menge (currentUnits) aus; berechnet werden nur die - // Pakete ueber der schon bezahlten Menge, der erste Schritt bis dahin laeuft ohne Verrechnung - prorationCents = Math.round((previewed * (newUnits - Math.max(currentUnits, paidUnits))) / (newUnits - currentUnits)) + const classic = stripeSub.billing_mode?.type === 'classic' + prorationCents = classic + ? Math.round((previewed * (newUnits - Math.max(currentUnits, paidUnits))) / (newUnits - currentUnits)) + : previewed } return { direction: plan.direction, @@ -175,6 +207,13 @@ export async function previewStorageChange(customerId, newUnits) { } } +function assertPlanAllowed(plan, consent) { + if (plan.direction === 'none') throw httpError(400, 'Die Anzahl der Pakete ist unverändert.') + if (plan.direction === 'plus' && !(consent?.termsVersion && consent?.consentAt)) { + throw httpError(400, 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.') + } +} + /** * Darf der Kunde die Menge aendern? Im Portal nur bei Status active, ohne Kuendigung und ohne Wechsel, der * auf die Zahlung wartet (wie canChangeStorage in GET /api/abo, F12). Ueber das Kuendigungsformular @@ -200,22 +239,19 @@ function assertChangeAllowed(doc, events, direction, viaCancellation) { } /** - * Ereignis `speicher` (Nachweis der Bestellung bzw. Aufgabe fuers Team). Aeltere offene Aufgaben desselben - * Abos sind damit ueberholt (F21: erledigt, doneBy 'ersetzt'). Ein Fehler beim Speichern bricht die schon - * bei Stripe wirksame Aenderung nicht ab: er wird protokolliert, T13 traegt die Aufgabe trotzdem zum Team. + * Ereignis `speicher` (Nachweis der Bestellung bzw. Aufgabe fuers Team), Status offen - vor dem Stripe-Aufruf. + * Laesst es sich nicht speichern, laeuft die Aenderung trotzdem: Fehler im Log, T13 traegt die Aufgabe mit + * dem Block nachweis_fehlt zum Team. */ -async function recordStorageEvent({ doc, events, change, consent, dueAt, viaCancellation }) { +async function recordStorageEvent({ doc, change, consent, viaCancellation }) { try { - const now = new Date().toISOString() - for (const e of events.filter((e) => e.type === 'speicher' && e.status === 'offen')) { - await updateContractEvent(e.$id, { status: 'erledigt', doneAt: now, doneBy: 'ersetzt' }) - } return await recordContractEvent({ customerId: doc.customerId, subscriptionId: doc.$id, type: 'speicher', productKey: doc.productKey || 'wartung', details: { + kind: change.kind, fromUnits: change.fromUnits, toUnits: change.toUnits, effectiveAt: change.effectiveAt, @@ -235,7 +271,7 @@ async function recordStorageEvent({ doc, events, change, consent, dueAt, viaCanc } : null, status: 'offen', - dueAt, + dueAt: change.dueAt, }) } catch (err) { console.error(`[speicher] Ereignis für Abo ${doc.$id} nicht gespeichert:`, err.message) @@ -243,6 +279,23 @@ async function recordStorageEvent({ doc, events, change, consent, dueAt, viaCanc } } +/** + * Aeltere offene Aufgaben desselben Abos, die die neue Aufgabe ueberholt (F21, Fix 1): eine Einrichtung + * ersetzt offene Einrichtungen und geplante Verringerungen mit hoechstens gleichem Ziel; eine Aufgabe zum + * Periodenende ersetzt nur offene Aufgaben zum Periodenende, nie eine noch nicht erledigte Einrichtung. + * events sind die Ereignisse vor der neuen Aufgabe. + */ +async function supersedeStorageTasks(events, change) { + const now = new Date().toISOString() + const superseded = events.filter((e) => { + if (e.type !== 'speicher' || e.status !== 'offen') return false + const kind = storageTaskKind(e) + if (change.kind === 'einrichten') return kind === 'einrichten' || (Number(e.details?.toUnits) || 0) <= change.toUnits + return kind === 'periodenende' + }) + for (const e of superseded) await updateContractEvent(e.$id, { status: 'erledigt', doneAt: now, doneBy: 'ersetzt' }) +} + /** Vertragsunterlagen (PDF) in der zugestimmten Fassung als Mail-Anhang; ohne Datei geht die Mail ohne Anhang raus. */ async function contractPdfAttachment(termsVersion) { const { filename, path } = contractAttachmentFor(termsVersion) @@ -254,14 +307,15 @@ async function contractPdfAttachment(termsVersion) { } /** - * K13 an den Kunden (Plus: mit PDF der zugestimmten Fassung, F22) und T13 ans Team. Ueber das - * Kuendigungsformular bestaetigt K03, K13 entfaellt dort (F31). Ein Mailfehler bricht die Aenderung nie ab. + * K13 an den Kunden (Plus: mit PDF der zugestimmten Fassung, F22) und T13 ans Team (Aufgabe nach Art, dazu + * die Bloecke teilweise und nachweis_fehlt). Ueber das Kuendigungsformular bestaetigt K03, K13 entfaellt dort + * (F31). Ein Mailfehler bricht die Aenderung nie ab. */ -async function sendStorageMails({ doc, change, dueAt, consent, viaCancellation, proofMissing }) { +async function sendStorageMails({ doc, change, consent, viaCancellation, proofMissing }) { const plus = change.direction === 'plus' const customer = await getDocument(config.collections.customers, doc.customerId).catch(() => null) const email = customer?.email || doc.email || '' - const wirksamAb = fmtDate(change.effectiveAt) + const dueLabel = fmtDate(change.dueAt) if (!viaCancellation && email) { const attachments = plus ? await contractPdfAttachment(consent.termsVersion) : [] await sendCustomerMail(email, { @@ -271,7 +325,7 @@ async function sendStorageMails({ doc, change, dueAt, consent, viaCancellation, pakete: change.toUnits, gesamt_gb: change.totalGb, monatspreis_zusatz: fmtEuro(change.monthlyCents), - wirksam_ab: wirksamAb, + wirksam_ab: fmtDate(change.effectiveAt), }, blocks: { plus, minus: !plus }, attachments, @@ -280,89 +334,169 @@ async function sendStorageMails({ doc, change, dueAt, consent, viaCancellation, await sendAdminNotice({ template: 'T13', values: { - aktion: plus ? 'einrichten' : `reduzieren zum ${wirksamAb}`, + aktion: change.kind === 'einrichten' ? 'einrichten' : `reduzieren zum ${dueLabel}`, email, + name: customer?.name || '', + stripe_abo_id: doc.$id, postfaecher_hinweis: (doc.productKey || 'wartung') === 'hosting' ? 'Hosting-Abo: 1 Postfach' : 'Wartungs-Abo: alle Postfächer des Kunden zusammen', neue_groesse_gb: change.totalGb, - faellig: fmtDate(dueAt), + pakete: change.toUnits, + faellig: dueLabel, + }, + blocks: { + einrichten: change.kind === 'einrichten', + periodenende: change.kind === 'periodenende', + teilweise: change.partial, + nachweis_fehlt: proofMissing, }, - blocks: proofMissing ? { nachweis_fehlt: true } : {}, }) } +// Abos mit laufender Aenderung (Doppelklick, zwei Tabs): die zweite gleichzeitige Aenderung bekommt 409 +const activeChanges = new Set() + /** - * Menge auf newUnits Pakete aendern: Stripe-Schritte nacheinander (die erste Position mit { price, quantity }, - * danach { id, quantity }; bei 0 bleibt die Position mit Menge 0 bestehen), Spiegel und bezahlte Menge - * (Plus: max(paidUnits, newUnits) fuer den laufenden Monat), Ereignis `speicher` (offen; dueAt jetzt bzw. - * Periodenende), K13 und T13. consent { termsVersion, consentAt, consentIp, consentUserAgent } ist beim Plus - * Pflicht (400), beim Minus null. options.viaCancellation: Kuendigungsformular (nur Verringerung, F20). - * Liefert { subscription, event, change }. + * Menge auf newUnits Pakete aendern. consent { termsVersion, consentAt, consentIp, consentUserAgent } ist beim + * Plus Pflicht (400), beim Minus null. options.viaCancellation: Kuendigungsformular (nur Verringerung, F20). + * Ablauf: Vorpruefung mit dem Spiegel (400/404/409, ohne Stripe-Aufruf), Sperre je Abo, frischer Stand aus + * Stripe, Ereignis `speicher` (offen), Stripe-Schritte nacheinander (die erste Position mit { price, quantity }, + * danach { id, quantity }; bei 0 bleibt die Position mit Menge 0), Spiegel und bezahlte Menge, ueberholte + * Aufgaben, K13 und T13. Liefert { subscription, event, change }; change = { direction, kind, fromUnits, + * toUnits, effectiveAt, dueAt, totalGb, monthlyCents, deltaMonthlyCents, partial }. */ export async function changeStorage(customerId, newUnits, consent, options = {}) { const viaCancellation = options.viaCancellation === true const doc = await requireSubscription(customerId) - const currentUnits = unitsOf(doc) - const paidUnits = paidUnitsOf(doc) + // Vorpruefung mit dem Spiegel: Grenzen, Richtung, Zustimmung, Status - ohne Stripe-Aufruf + const plan = planStorageChange({ currentUnits: unitsOf(doc), paidUnits: paidUnitsOf(doc), newUnits, maxUnits: config.mailStorage.maxUnits }) + assertPlanAllowed(plan, consent) + const events = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === doc.$id) + assertChangeAllowed(doc, events, plan.direction, viaCancellation) + // Sperre pruefen und setzen ohne await dazwischen + if (activeChanges.has(doc.$id)) throw httpError(409, 'Eine Änderung läuft bereits. Bitte warten Sie einen Moment und laden Sie die Seite neu.') + activeChanges.add(doc.$id) + try { + return await performChange({ doc, events, newUnits, consent, viaCancellation }) + } finally { + activeChanges.delete(doc.$id) + } +} + +async function performChange({ doc, events, newUnits, consent, viaCancellation }) { + const stripe = getStripe() + const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)]) + const items = stripeSub.items?.data || [] + let { storageItemId } = describeItems(items) + // Frischer Stand aus Stripe (Menge, Periodenende): der Spiegel kann nach einem Webhook-Rueckstand hinterherhinken + const fresh = { ...doc, storageUnits: describeItems(items).storageUnits, currentPeriodEnd: toIso(periodEndOf(stripeSub)) || doc.currentPeriodEnd || null } + const currentUnits = unitsOf(fresh) + const paidUnits = paidUnitsOf(fresh) const plan = planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits: config.mailStorage.maxUnits }) if (plan.direction === 'none') throw httpError(400, 'Die Anzahl der Pakete ist unverändert.') const plus = plan.direction === 'plus' - if (plus && !(consent?.termsVersion && consent?.consentAt)) { - throw httpError(400, 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.') - } - const events = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === doc.$id) - assertChangeAllowed(doc, events, plan.direction, viaCancellation) - - // Stripe: Speicher-Posten anlegen oder Menge aendern, Schritt fuer Schritt (planStorageChange) - const stripe = getStripe() - const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)]) - let { storageItemId } = describeItems(stripeSub.items?.data) - const prorationDate = Math.floor(Date.now() / 1000) - for (const step of plan.steps) { - const updated = await stripe.subscriptions.update(doc.$id, { - items: [storageItemParam(storageItemId, priceId, step.quantity)], - proration_behavior: step.proration_behavior, - ...(step.proration_behavior === 'create_prorations' ? { proration_date: prorationDate } : {}), - }) - storageItemId = describeItems(updated.items?.data).storageItemId || storageItemId - } - - // Spiegel (storageUnits aus den Positionen) und bezahlte Menge des laufenden Monats. Die Aenderung ist - // bei Stripe schon wirksam: scheitert die Buchung der bezahlten Menge (z. B. Attribut fehlt, Setup-Skript - // nicht gelaufen), laufen Ereignis und Mails trotzdem; ein Plus nach einem Minus im selben Monat wuerde - // dann anteilig berechnet statt frei bleiben - Fehler im Log. - let subscription = (await syncSubscription(doc.$id)) || doc - if (plus) { - const paid = { storagePaidUnits: Math.max(paidUnits, newUnits), storagePaidPeriodEnd: subscription.currentPeriodEnd || doc.currentPeriodEnd || null } - subscription = await updateDocument(config.collections.subscriptions, doc.$id, paid).catch((err) => { - console.error(`[speicher] Bezahlte Menge für Abo ${doc.$id} nicht gespeichert (${err.status || '?'}):`, err.message) - return subscription - }) - } - const now = new Date().toISOString() - const effectiveAt = plus ? now : reductionEffectiveAt(subscription) + const kind = plus && newUnits > paidUnits ? 'einrichten' : 'periodenende' + const reductionAt = reductionEffectiveAt(fresh) const change = { direction: plan.direction, + kind, fromUnits: currentUnits, toUnits: newUnits, - effectiveAt, + // Fuer den Kunden: ein Plus gilt sofort, ein Minus zum Periodenende; fuers Team: die Einrichtung ist + // sofort faellig, alles andere zum Periodenende + effectiveAt: plus ? now : reductionAt, + dueAt: kind === 'einrichten' ? now : reductionAt, totalGb: totalGbOf(newUnits), monthlyCents: monthlyCentsOf(newUnits), deltaMonthlyCents: monthlyCentsOf(newUnits) - monthlyCentsOf(currentUnits), + partial: false, } - // Aufgabe fuers Team: Plus sofort einrichten, Minus zum Periodenende reduzieren - const dueAt = plus ? now : effectiveAt - const event = await recordStorageEvent({ doc, events, change, consent, dueAt, viaCancellation }) - await sendStorageMails({ doc: subscription, change, dueAt, consent, viaCancellation, proofMissing: !event }) + + // 1. Ereignis zuerst: Nachweis und Aufgabe, Status offen + const event = await recordStorageEvent({ doc: fresh, change, consent, viaCancellation }) + + // 2. Stripe, Schritt fuer Schritt (planStorageChange) + const prorationDate = Math.floor(Date.now() / 1000) + let achievedUnits = currentUnits + for (const [index, step] of plan.steps.entries()) { + try { + const updated = await stripe.subscriptions.update(doc.$id, { + items: [storageItemParam(storageItemId, priceId, step.quantity)], + proration_behavior: step.proration_behavior, + ...(step.proration_behavior === 'create_prorations' ? { proration_date: prorationDate } : {}), + }) + storageItemId = describeItems(updated.items?.data).storageItemId || storageItemId + achievedUnits = step.quantity + } catch (err) { + if (index === 0) { + // Nichts geaendert: Ereignis gescheitert, Fehler an den Kunden, keine Buchung, keine Mails + console.error(`[speicher] Abo ${doc.$id}: Stripe lehnt die Änderung ab:`, err.message) + if (event) { + await updateContractEvent(event.$id, { status: 'gescheitert', details: { error: shortError(err) } }).catch((e) => + console.error(`[speicher] Ereignis ${event.$id} nicht auf gescheitert gesetzt:`, e.message) + ) + } + const failure = httpError(502, 'Die Änderung konnte bei Stripe nicht gespeichert werden. Es wurde nichts berechnet. Bitte versuchen Sie es später erneut.') + failure.cause = err + throw failure + } + // Schritt 2 nach Schritt 1: Stripe steht auf der bezahlten Menge, die Bestellung bleibt offen - das Team + // setzt die Menge in Stripe von Hand (T13, Block teilweise) + console.error(`[speicher] Abo ${doc.$id}: Schritt ${index + 1} bei Stripe fehlgeschlagen, Menge bleibt ${achievedUnits}:`, err.message) + change.partial = true + if (event) { + await updateContractEvent(event.$id, { details: { partial: true, error: shortError(err) } }).catch((e) => + console.error(`[speicher] Ereignis ${event.$id} nicht als teilweise markiert:`, e.message) + ) + } + break + } + } + + // 3. Spiegel (storageUnits aus den Positionen); scheitert er, holt der Stripe-Webhook das nach + let subscription + try { + subscription = (await syncSubscription(doc.$id)) || { ...fresh, storageUnits: achievedUnits } + } catch (err) { + console.error(`[speicher] Spiegel für Abo ${doc.$id} nicht aktualisiert (Webhook holt das nach):`, err.message) + subscription = { ...fresh, storageUnits: achievedUnits } + } + + // 4. Bezahlte Menge des laufenden Monats: Plus max(bezahlt, erreicht), Minus max(bezahlt, bisher) - jede + // Position war fuer den Monat berechnet. Scheitert die Buchung (z. B. Attribut fehlt), laeuft der Rest weiter; + // ein Plus nach einem Minus im selben Monat wuerde dann anteilig berechnet statt frei bleiben. + const paid = { + storagePaidUnits: Math.max(paidUnits, plus ? achievedUnits : currentUnits), + storagePaidPeriodEnd: fresh.currentPeriodEnd, + } + try { + await updateDocument(config.collections.subscriptions, doc.$id, paid) + subscription = { ...subscription, ...paid } + } catch (err) { + console.error(`[speicher] Bezahlte Menge für Abo ${doc.$id} nicht gespeichert (${err.status || '?'}):`, err.message) + } + + // 5. Ueberholte Aufgaben schliessen, erst nachdem die neue existiert + if (event) { + await supersedeStorageTasks(events, change).catch((err) => + console.error(`[speicher] Ersetzte Aufgaben für Abo ${doc.$id} nicht geschlossen:`, err.message) + ) + } + + // 6. Mails + await sendStorageMails({ doc: subscription, change, consent, viaCancellation, proofMissing: !event }) return { subscription, event, change } } /** * Bezahlte Rechnung (Webhook invoice.paid, frisch geladen mit loadInvoice): hat sie eine regulaere * Zusatzspeicher-Zeile (nicht anteilig), ist deren Stueckzahl fuer den Zeitraum bis line.period.end bezahlt - * (storagePaidUnits, storagePaidPeriodEnd). Idempotent; fehlt der Spiegel noch (Webhook-Reihenfolge), - * wird er aus Stripe angelegt. + * (storagePaidUnits, storagePaidPeriodEnd). Im selben Monat gilt die groessere Menge (eine verspaetete + * Rechnung senkt eine Buchung nach einem Plus nicht), das Periodenende geht nie zurueck (wiederholte aeltere + * Rechnung). Fehlt der Spiegel noch (Webhook-Reihenfolge), wird er aus Stripe angelegt. Dauerhafte + * Appwrite-Fehler (4xx) werden protokolliert und uebersprungen, voruebergehende (5xx, Netz) geworfen, damit + * Stripe den Webhook wiederholt. */ export async function recordPaidStorage(invoice) { const lines = await invoiceLines(invoice) @@ -372,13 +506,36 @@ export async function recordPaidStorage(invoice) { const ref = line.parent?.subscription_item_details?.subscription || subscriptionIdOfInvoice(loaded) const subscriptionId = typeof ref === 'string' ? ref : ref?.id if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' } - const data = { storagePaidUnits: Number(line.quantity) || 0, storagePaidPeriodEnd: toIso(line.period?.end) } + const periodEnd = toIso(line.period?.end) + if (!periodEnd) return { skipped: 'Speicher-Zeile ohne Zeitraum' } + const units = Number(line.quantity) || 0 + const tag = `[speicher] Bezahlte Menge für Abo ${subscriptionId} (Rechnung ${loaded.id || '?'})` + + let existing + try { + existing = await getDocument(config.collections.subscriptions, subscriptionId) + } catch (err) { + if (err.status !== 404) { + if (!isPermanent(err)) throw err + console.error(`${tag} nicht lesbar (${err.status}):`, err.message) + return { skipped: `Appwrite ${err.status}` } + } + existing = await syncSubscription(subscriptionId) + if (!existing) return { skipped: 'Abo ohne Kundenzuordnung' } + } + const knownEnd = existing.storagePaidPeriodEnd || null + if (knownEnd && new Date(periodEnd) < new Date(knownEnd)) return { skipped: 'ältere Rechnung' } + const samePeriod = Boolean(knownEnd) && sameInstant(knownEnd, periodEnd) + const data = { + storagePaidUnits: samePeriod ? Math.max(Number(existing.storagePaidUnits) || 0, units) : units, + storagePaidPeriodEnd: periodEnd, + } try { await updateDocument(config.collections.subscriptions, subscriptionId, data) } catch (err) { - if (err.status !== 404) throw err - if (!(await syncSubscription(subscriptionId))) return { skipped: 'Abo ohne Kundenzuordnung' } - await updateDocument(config.collections.subscriptions, subscriptionId, data) + if (!isPermanent(err)) throw err + console.error(`${tag} nicht gespeichert (${err.status}):`, err.message) + return { skipped: `Appwrite ${err.status}` } } - return { recorded: true, subscriptionId, units: data.storagePaidUnits, periodEnd: data.storagePaidPeriodEnd } + return { recorded: true, subscriptionId, units: data.storagePaidUnits, periodEnd } } diff --git a/test/mailStorage.test.js b/test/mailStorage.test.js index 420a25a..7746c4b 100644 --- a/test/mailStorage.test.js +++ b/test/mailStorage.test.js @@ -4,6 +4,7 @@ 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({ @@ -19,13 +20,20 @@ const { config } = await import('../server/config.js') const { startTestApp, post, rawGet, postWebhook } = await import('./support/testApp.js') const { hostingDoc, consent, customerCookie, viewAsCookie, seedFixtures, stripeSubscription } = await import('./support/fixtures.js') const { default: aboRoutes } = await import('../server/routes/abo.js') -const { planStorageChange, paidUnitsOf, previewStorageChange, changeStorage, recordPaidStorage } = +const { planStorageChange, paidUnitsOf, previewStorageChange, changeStorage, recordPaidStorage, storageTaskKind } = await import('../server/services/mailStorage.js') const { listContractEvents, listOpenStorageTasks, recordContractEvent } = await import('../server/services/contractEvents.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') const app = await startTestApp({ '/api/abo': aboRoutes }) -after(() => Promise.all([app.close(), aw.close(), st.close()])) +// F25: Mails landen in der Senke statt bei SMTP - K13 (Kunde, beim Plus mit PDF) und T13 (Team) sind pruefbar +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 @@ -37,6 +45,10 @@ st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lo const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000) const PERIOD_END_UNIX = unix(hostingDoc.currentPeriodEnd) +const DAY = 86400 +const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) +const PERIOD_END_LABEL = fmtDate(hostingDoc.currentPeriodEnd) +const K13_SUBJECT = 'Ihr E-Mail-Zusatzspeicher – Bestätigung' async function clearEvents(customerId) { for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id) @@ -46,10 +58,11 @@ async function clearEvents(customerId) { * Hosting-Abo sub_h von c1 in beiden Nachbauten auf einen Stand setzen: units Pakete bei Stripe und im * Spiegel; paidUnits (mit storagePaidPeriodEnd = currentPeriodEnd) als schon bezahlte Menge dieses Monats, * sonst wie die Fixture (keine Buchung: bezahlt gilt dann die aktuelle Menge). patch aendert den Spiegel, - * stripe das Stripe-Abo (der Spiegel wird nach jeder Aenderung aus Stripe neu geschrieben). + * stripe das Stripe-Abo (der Spiegel wird nach jeder Aenderung aus Stripe neu geschrieben). Leert die Senke. */ async function resetSubH({ units = 0, paidUnits = null, patch = {}, stripe = {} } = {}) { await clearEvents('c1') + mails.sent.length = 0 aw.seed('subscriptions', { ...hostingDoc, storageUnits: units, @@ -75,9 +88,14 @@ const previewInvoice = (...lines) => ({ }) const subscriptionUpdates = (from = 0) => st.calls.slice(from).filter((c) => c.method === 'POST' && c.path === '/v1/subscriptions/sub_h') const previewCalls = (from = 0) => st.calls.slice(from).filter((c) => c.method === 'POST' && c.path === '/v1/invoices/create_preview') +const storageItem = () => st.subscriptions.get('sub_h').items.data.find((i) => i.price.lookup_key === config.mailStorage.lookupKey) +const doc = () => aw.docs('subscriptions').get('sub_h') +const openTasks = async () => (await listOpenStorageTasks()).filter((t) => t.subscriptionId === 'sub_h') +const k13 = () => mails.sent.find((m) => m.subject === K13_SUBJECT) || null +const t13 = () => mails.sent.find((m) => m.subject.startsWith('[Speicher]')) || null // --------------------------------------------------------------------------- -// Reine Funktionen: Plan und bezahlte Menge +// Reine Funktionen: Plan, bezahlte Menge, Aufgabenart // --------------------------------------------------------------------------- test('Plan: erstes Plus anteilig', () => { @@ -130,6 +148,18 @@ test('Bezahlte Menge gilt nur im laufenden Zeitraum', () => { assert.equal(paidUnitsOf({ storageUnits: 2, storagePaidUnits: 0, storagePaidPeriodEnd: null, currentPeriodEnd: null }), 2) assert.equal(paidUnitsOf({ storageUnits: 2 }), 2) assert.equal(paidUnitsOf({}), 0) + // Fix 1 (4): frisches Periodenende aus Stripe statt des Spiegels + assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'B' }, 'A'), 3) + assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'A' }, 'C'), 1) +}) + +// Fix 1 (2): Aufgabenart aus details.kind; aeltere Ereignisse ohne kind (Aufgabe 7) nach der Richtung +test('storageTaskKind: details.kind, sonst Richtung', () => { + assert.equal(storageTaskKind({ details: { kind: 'einrichten', fromUnits: 3, toUnits: 1 } }), 'einrichten') + assert.equal(storageTaskKind({ details: { kind: 'periodenende', fromUnits: 1, toUnits: 2 } }), 'periodenende') + assert.equal(storageTaskKind({ details: { fromUnits: 0, toUnits: 2 } }), 'einrichten') + assert.equal(storageTaskKind({ details: { fromUnits: 2, toUnits: 1 } }), 'periodenende') + assert.equal(storageTaskKind({}), 'periodenende') }) // --------------------------------------------------------------------------- @@ -140,11 +170,12 @@ test('Vorschau Plus: anteiliger Betrag aus der Stripe-Vorschau, Gesamtgröße, n await resetSubH({ units: 0 }) // Stripe-Vorschau = naechste Rechnung: anteilige Speicher-Zeile fuer den Rest des Monats plus die // regulaeren Zeilen des naechsten Monats (die zaehlen nicht zum anteiligen Betrag) - st.invoicePreview = previewInvoice( - line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Math.floor(Date.now() / 1000) }), - line('price_hosting', 1500), - line('price_mailStorage', 200, { quantity: 2 }) - ) + st.invoicePreview = (body) => + previewInvoice( + line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Number(body.subscription_details.proration_date) }), + line('price_hosting', 1500), + line('price_mailStorage', 200, { quantity: 2 }) + ) const before = st.calls.length const p = await previewStorageChange('c1', 2) assert.equal(p.direction, 'plus') @@ -167,17 +198,20 @@ test('Vorschau Plus: anteiliger Betrag aus der Stripe-Vorschau, Gesamtgröße, n assert.deepEqual(subscriptionUpdates(before), []) }) -test('Vorschau Plus mit Posten: Gutschrift und Belastung der Speicher-Zeilen, ältere anteilige Zeilen zählen nicht', async () => { +// Fix 1 (6): nur die Zeilen dieser Vorschau (period.start = proration_date, ±2 s); eine Aenderung von vor +// einer Minute steckt schon als offene Rechnungsposition in der naechsten Rechnung und zaehlt nicht mit +test('Vorschau Plus mit Posten: Gutschrift und Belastung ab proration_date, ältere anteilige Zeilen zählen nicht', async () => { await resetSubH({ units: 1 }) - const now = Math.floor(Date.now() / 1000) - st.invoicePreview = previewInvoice( - // Aenderung von vorhin (schon als offene Rechnungsposition vorgemerkt): nicht Teil dieser Bestellung - line('price_mailStorage', 40, { quantity: 1, proration: true, periodStart: now - 3600 }), - line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: now }), - line('price_mailStorage', 200, { quantity: 3, proration: true, periodStart: now }), - line('price_hosting', 1500), - line('price_mailStorage', 300, { quantity: 3 }) - ) + st.invoicePreview = (body) => { + const at = Number(body.subscription_details.proration_date) + return previewInvoice( + line('price_mailStorage', 40, { quantity: 1, proration: true, periodStart: at - 60 }), + line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at - 1 }), + line('price_mailStorage', 200, { quantity: 3, proration: true, periodStart: at + 1 }), + line('price_hosting', 1500), + line('price_mailStorage', 300, { quantity: 3 }) + ) + } const before = st.calls.length const p = await previewStorageChange('c1', 3) assert.equal(p.prorationCents, 133) @@ -212,18 +246,51 @@ test('Vorschau Minus bei gekündigtem Abo: wirksam mit dem Abo-Ende, wenn das fr assert.equal((await previewStorageChange('c1', 1)).effectiveAt, cancelAt) }) -// F19: Stripe rechnet die Vorschau von der heutigen Stueckzahl (1) aus; bis zur schon bezahlten Menge (3) -// laeuft der erste Schritt ohne Verrechnung, berechnet werden nur die 2 Pakete darueber -test('Vorschau Plus in zwei Schritten: anteilig nur für die Pakete über der bezahlten Menge', async () => { +// Fix 1 (1): Abos aus dem Checkout laufen im flexiblen Abrechnungsmodus (billing_mode.type 'flexible', bei +// Stripe der Standard). Dort schreibt Stripe den zuletzt berechneten Betrag gut - die Vorschau hat die im +// Monat schon bezahlten Pakete also selbst verrechnet und wird unveraendert angezeigt. +test('Vorschau Plus in zwei Schritten, flexibler Modus (Standard): Betrag der Stripe-Vorschau unverändert', async () => { await resetSubH({ units: 1, paidUnits: 3 }) - const now = Math.floor(Date.now() / 1000) + // Gutschrift der letzten Berechnung (3 Pakete, -200), Belastung fuer 5 Pakete (333) = 133 fuer die 2 Pakete darueber + st.invoicePreview = (body) => { + const at = Number(body.subscription_details.proration_date) + return previewInvoice( + line('price_mailStorage', -200, { quantity: 3, proration: true, periodStart: at }), + line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at }), + line('price_hosting', 1500), + line('price_mailStorage', 500, { quantity: 5 }) + ) + } + const p = await previewStorageChange('c1', 5) + assert.equal(p.prorationCents, 133) + assert.equal(p.direction, 'plus') + assert.equal(p.deltaMonthlyCents, 400) + // Ausdruecklich flexible: ebenfalls keine Umrechnung (F19 gilt nur im klassischen Modus) + await resetSubH({ units: 1, paidUnits: 3, stripe: { billing_mode: { type: 'flexible' } } }) + st.invoicePreview = (body) => { + const at = Number(body.subscription_details.proration_date) + return previewInvoice( + line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at }), + line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at }) + ) + } + assert.equal((await previewStorageChange('c1', 5)).prorationCents, 266) +}) + +// F19 (klassischer Modus): Stripe rechnet die Vorschau von der heutigen Stueckzahl (1) aus; bis zur schon +// bezahlten Menge (3) laeuft der erste Schritt ohne Verrechnung, berechnet werden nur die 2 Pakete darueber +test('Vorschau Plus in zwei Schritten, klassischer Modus: anteilig nur für die Pakete über der bezahlten Menge', async () => { + await resetSubH({ units: 1, paidUnits: 3, stripe: { billing_mode: { type: 'classic' } } }) // Vorschau 1 -> 5: Gutschrift fuer 1 Paket (-67), Belastung fuer 5 Pakete (333) = 266 fuer 4 Pakete - st.invoicePreview = previewInvoice( - line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: now }), - line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: now }), - line('price_hosting', 1500), - line('price_mailStorage', 500, { quantity: 5 }) - ) + st.invoicePreview = (body) => { + const at = Number(body.subscription_details.proration_date) + return previewInvoice( + line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at }), + line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at }), + line('price_hosting', 1500), + line('price_mailStorage', 500, { quantity: 5 }) + ) + } const before = st.calls.length const p = await previewStorageChange('c1', 5) // 266 × (5 − max(1, 3)) / (5 − 1) = 133 @@ -268,7 +335,7 @@ test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => { const [ev] = await listContractEvents('c1') assert.equal(ev.type, 'speicher') assert.equal(ev.status, 'offen') - assert.equal(aw.docs('subscriptions').get('sub_h').storagePaidUnits, 2) + assert.equal(doc().storagePaidUnits, 2) // Erste Position: mit Preis angelegt, nicht mit Posten-ID; anteilig ab jetzt (proration_date) assert.equal(subscriptionUpdates(before).length, 1) @@ -276,13 +343,10 @@ test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => { assert.equal(upd.at(-1).body['items[0][id]'], undefined) assert.match(upd.at(-1).body['proration_date'], /^\d{10}$/) // Stripe-Nachbau hat jetzt den Speicher-Posten mit Menge 2, der Spiegel ebenso - const stripeSub = st.subscriptions.get('sub_h') - const storageItem = stripeSub.items.data.find((i) => i.price.lookup_key === config.mailStorage.lookupKey) - assert.equal(storageItem.quantity, 2) - const doc = aw.docs('subscriptions').get('sub_h') - assert.equal(doc.storageUnits, 2) - assert.equal(doc.storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) - // Ereignis: Nachweis mit Zustimmung, Aufgabe faellig jetzt, Ziel und Wirksamkeit in den details + assert.equal(storageItem().quantity, 2) + assert.equal(doc().storageUnits, 2) + assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) + // Ereignis: Nachweis mit Zustimmung, Aufgabe "einrichten" faellig jetzt, Ziel und Wirksamkeit in den details assert.equal(ev.subscriptionId, 'sub_h') assert.equal(ev.customerId, 'c1') assert.equal(ev.productKey, 'hosting') @@ -291,6 +355,7 @@ test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => { assert.equal(ev.consentIp, '127.0.0.1') assert.equal(ev.consentUserAgent, 'test') assert.equal(ev.earlyStartConsentAt ?? null, null) + assert.equal(ev.details.kind, 'einrichten') assert.equal(ev.details.fromUnits, 0) assert.equal(ev.details.toUnits, 2) assert.equal(ev.details.totalGb, 15) @@ -301,15 +366,31 @@ test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => { assert.equal(result.subscription.storageUnits, 2) assert.equal(result.event.$id, ev.$id) assert.deepEqual( - { direction: result.change.direction, fromUnits: result.change.fromUnits, toUnits: result.change.toUnits }, - { direction: 'plus', fromUnits: 0, toUnits: 2 } + { direction: result.change.direction, kind: result.change.kind, fromUnits: result.change.fromUnits, toUnits: result.change.toUnits, partial: result.change.partial }, + { direction: 'plus', kind: 'einrichten', fromUnits: 0, toUnits: 2, partial: false } ) + // K13 an den Kunden mit den Vertragsunterlagen der zugestimmten Fassung (F22), T13 ans Team + assert.equal(mails.sent.length, 2) + const customerMail = k13() + assert.deepEqual(customerMail.to.map((t) => t.address), ['k@example.invalid']) + assert.deepEqual(customerMail.attachments.map((a) => [a.filename, a.contentType]), [['WEBklar-Abo-Vertragsunterlagen.pdf', 'application/pdf']]) + assert.ok(customerMail.attachments[0].content.length > 1000, 'PDF-Inhalt angehaengt') + for (const text of ['Guten Tag Kunde Eins,', '2 Pakete', '15 GB', '2,00 €', 'gemäß § 19 UStG ohne Umsatzsteuer', 'wird eingerichtet', '14 Tagen widerrufen']) { + assert.ok(customerMail.text.includes(text), `K13 ohne "${text}":\n${customerMail.text}`) + } + assert.doesNotMatch(customerMail.text, /ohne anteilige Erstattung/) + const teamMail = t13() + assert.equal(teamMail.subject, '[Speicher] einrichten: k@example.invalid') + for (const text of ['Kunde Eins', 'sub_h', 'Hosting-Abo: 1 Postfach', '15 GB', '2 Pakete']) { + assert.ok(teamMail.text.includes(text), `T13 ohne "${text}":\n${teamMail.text}`) + } + assert.doesNotMatch(teamMail.text, /Nachweis nicht gespeichert|Schritt 2 fehlgeschlagen/) }) test('Plus in zwei Schritten: erst die bezahlte Menge ohne Verrechnung, dann anteilig', async () => { await resetSubH({ units: 1, paidUnits: 3 }) const before = st.calls.length - await changeStorage('c1', 5, consent) + const result = await changeStorage('c1', 5, consent) const upd = subscriptionUpdates(before) assert.equal(upd.length, 2) assert.equal(upd[0].body['items[0][id]'], 'si_sub_h_speicher') @@ -320,29 +401,40 @@ test('Plus in zwei Schritten: erst die bezahlte Menge ohne Verrechnung, dann ant assert.equal(upd[1].body['items[0][quantity]'], '5') assert.equal(upd[1].body['proration_behavior'], 'create_prorations') assert.match(upd[1].body['proration_date'], /^\d{10}$/) - const doc = aw.docs('subscriptions').get('sub_h') - assert.equal(doc.storageUnits, 5) - assert.equal(doc.storagePaidUnits, 5) - assert.equal(doc.storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) + assert.equal(doc().storageUnits, 5) + assert.equal(doc().storagePaidUnits, 5) + assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) + assert.equal(result.change.kind, 'einrichten') + assert.equal(result.change.partial, false) }) -test('Plus bis zur bezahlten Menge: ein Schritt ohne Verrechnung, bezahlte Menge bleibt', async () => { +// Fix 1 (2b): ein Plus bis zur bezahlten Menge kostet nichts und braucht keine Einrichtung - konsoleH steht +// noch auf der bezahlten Menge; zum Periodenende gilt dann die neue Zielgroesse statt der frueheren Verringerung +test('Plus bis zur bezahlten Menge: ein Schritt ohne Verrechnung, Aufgabe zum Periodenende, kein Einrichten', async () => { await resetSubH({ units: 1, paidUnits: 3 }) const before = st.calls.length - await changeStorage('c1', 3, consent) + const result = await changeStorage('c1', 3, consent) const upd = subscriptionUpdates(before) assert.equal(upd.length, 1) assert.equal(upd[0].body['items[0][quantity]'], '3') assert.equal(upd[0].body['proration_behavior'], 'none') - const doc = aw.docs('subscriptions').get('sub_h') - assert.equal(doc.storageUnits, 3) - assert.equal(doc.storagePaidUnits, 3) + assert.equal(doc().storageUnits, 3) + assert.equal(doc().storagePaidUnits, 3) + const [ev] = await listContractEvents('c1') + assert.equal(ev.details.kind, 'periodenende') + assert.equal(ev.details.toUnits, 3) + assert.equal(ev.dueAt, hostingDoc.currentPeriodEnd) + assert.equal(ev.termsVersion, consent.termsVersion, 'Bestellung mit Zustimmung') + assert.equal(result.change.kind, 'periodenende') + assert.ok(Math.abs(new Date(result.change.effectiveAt).getTime() - Date.now()) < 5000, 'fuer den Kunden sofort wirksam') + assert.equal(t13().subject, `[Speicher] reduzieren zum ${PERIOD_END_LABEL}: k@example.invalid`) + assert.ok(k13().attachments.length === 1, 'K13 mit PDF (Bestellung)') }) test('Plus ohne Zustimmung: 400; gekündigt: 409', async () => { - await assert.rejects(changeStorage('c1', 3, null), (e) => e.status === 400) + await assert.rejects(changeStorage('c1', 4, null), (e) => e.status === 400) aw.seed('subscriptions', { ...hostingDoc, $id: 'sub_h', cancelAtPeriodEnd: true }) - await assert.rejects(changeStorage('c1', 3, consent), (e) => e.status === 409) + await assert.rejects(changeStorage('c1', 4, consent), (e) => e.status === 409) }) test('Plus ohne Zustimmung nennt die Regeln zum Zusatzspeicher, ohne Stripe-Aufruf', async () => { @@ -352,6 +444,7 @@ test('Plus ohne Zustimmung nennt die Regeln zum Zusatzspeicher, ohne Stripe-Aufr await assert.rejects(changeStorage('c1', 1, {}), (e) => e.status === 400) assert.equal(st.calls.length, before) assert.deepEqual(await listContractEvents('c1'), []) + assert.deepEqual(mails.sent, []) }) test('Keine Änderung bei trialing, past_due, cancelAt oder offenem Wechsel (409)', async () => { @@ -404,18 +497,28 @@ test('Minus: sofort bei Stripe ohne Erstattung, Aufgabe zum Periodenende, keine assert.equal(ev.type, 'speicher') assert.equal(ev.status, 'offen') assert.equal(ev.dueAt, hostingDoc.currentPeriodEnd) + assert.equal(ev.details.kind, 'periodenende') assert.deepEqual([ev.details.fromUnits, ev.details.toUnits, ev.details.effectiveAt], [3, 1, hostingDoc.currentPeriodEnd]) assert.equal(ev.consentIp ?? null, null) assert.equal(ev.consentUserAgent ?? null, null) assert.equal(ev.termsVersion ?? null, null) assert.equal(ev.consentAt ?? null, null) // Spiegel: Stripe-Menge sofort 1, bezahlt bleiben 3 fuer diesen Monat (ein Plus zurueck auf 3 kostet nichts) - const doc = aw.docs('subscriptions').get('sub_h') - assert.equal(doc.storageUnits, 1) - assert.equal(doc.storagePaidUnits, 3) - assert.equal(doc.storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) + assert.equal(doc().storageUnits, 1) + assert.equal(doc().storagePaidUnits, 3) + assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) assert.equal(result.change.direction, 'minus') + assert.equal(result.change.kind, 'periodenende') assert.equal(result.change.effectiveAt, hostingDoc.currentPeriodEnd) + // K13 ohne Anhang mit dem Hinweis zum Monatsende, T13 "reduzieren zum" + const customerMail = k13() + assert.deepEqual(customerMail.attachments, []) + for (const text of [`zum ${PERIOD_END_LABEL}`, 'ohne anteilige Erstattung', 'keine neuen E-Mails', '10 GB', '1,00 €']) { + assert.ok(customerMail.text.includes(text), `K13 ohne "${text}":\n${customerMail.text}`) + } + assert.doesNotMatch(customerMail.text, /wird eingerichtet|14 Tagen widerrufen/) + assert.equal(t13().subject, `[Speicher] reduzieren zum ${PERIOD_END_LABEL}: k@example.invalid`) + assert.ok(t13().text.includes(PERIOD_END_LABEL)) // Plus zurueck auf 3 im selben Monat: ohne Verrechnung const before2 = st.calls.length @@ -424,14 +527,29 @@ test('Minus: sofort bei Stripe ohne Erstattung, Aufgabe zum Periodenende, keine assert.equal(subscriptionUpdates(before2)[0].body['proration_behavior'], 'none') }) +// Fix 1 (4): jede Position war fuer den laufenden Monat berechnet - ein Minus haelt das fest, auch ohne +// fruehere Buchung (Altbestand); ein Plus zurueck bis dahin kostet dann nichts +test('Minus bucht die bezahlte Menge des Monats (max aus gebucht und aktuell), auch ohne frühere Buchung', async () => { + await resetSubH({ units: 3 }) // storagePaidPeriodEnd null: bisher galt nur die aktuelle Menge + await changeStorage('c1', 1, null) + assert.equal(doc().storagePaidUnits, 3) + assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) + const before = st.calls.length + await changeStorage('c1', 3, consent) + assert.equal(subscriptionUpdates(before)[0].body['proration_behavior'], 'none') + // Eine hoehere Buchung bleibt beim Minus erhalten + await resetSubH({ units: 2, paidUnits: 4 }) + await changeStorage('c1', 1, null) + assert.equal(doc().storagePaidUnits, 4) +}) + test('Minus auf 0: Position bleibt mit Menge 0, ein späteres Plus nutzt sie wieder', async () => { await resetSubH({ units: 2, paidUnits: 2 }) const before = st.calls.length await changeStorage('c1', 0, null) assert.equal(subscriptionUpdates(before)[0].body['items[0][quantity]'], '0') - const storageItem = () => st.subscriptions.get('sub_h').items.data.find((i) => i.price.lookup_key === config.mailStorage.lookupKey) assert.equal(storageItem().quantity, 0) - assert.equal(aw.docs('subscriptions').get('sub_h').storageUnits, 0) + assert.equal(doc().storageUnits, 0) const before2 = st.calls.length await changeStorage('c1', 4, consent) const upd = subscriptionUpdates(before2) @@ -441,72 +559,167 @@ test('Minus auf 0: Position bleibt mit Menge 0, ein späteres Plus nutzt sie wie assert.equal(st.subscriptions.get('sub_h').items.data.length, 2, 'kein zweiter Speicher-Posten') }) -// F21: aeltere offene Aufgaben desselben Abos sind ueberholt; fuers Team zaehlt eine Aufgabe je Abo -test('Neue Änderung ersetzt die offene Aufgabe: eine Aufgabe je Abo mit Zielgröße und Datum', async () => { +// --------------------------------------------------------------------------- +// Aufgaben fuers Team nach Zeitpunkt (Fix 1, 2; F21): hoechstens eine offene Aufgabe je Art und Abo +// --------------------------------------------------------------------------- + +test('Plus noch nicht eingerichtet, dann Minus: Einrichtung bleibt offen, dazu die Verringerung zum Periodenende', async () => { await resetSubH({ units: 0 }) await changeStorage('c1', 2, consent) - const [plusEvent] = await listContractEvents('c1') await changeStorage('c1', 1, null) - const events = await listContractEvents('c1') - assert.equal(events.length, 2) - const superseded = events.find((e) => e.$id === plusEvent.$id) + const tasks = await openTasks() + assert.equal(tasks.length, 2) + const setup = tasks.find((t) => storageTaskKind(t) === 'einrichten') + const reduction = tasks.find((t) => storageTaskKind(t) === 'periodenende') + assert.equal(setup.details.toUnits, 2) + assert.equal(setup.details.totalGb, 15) + assert.ok(Math.abs(new Date(setup.dueAt).getTime() - Date.now()) < 10000) + assert.equal(reduction.details.toUnits, 1) + assert.equal(reduction.details.totalGb, 10) + assert.equal(reduction.dueAt, hostingDoc.currentPeriodEnd) + assert.equal((await listContractEvents('c1')).filter((e) => e.status === 'offen').length, 2) +}) + +test('Minus, dann Plus bis zur bezahlten Menge: eine offene Aufgabe zum Periodenende mit der neuen Zielgröße', async () => { + await resetSubH({ units: 3, paidUnits: 3 }) + await changeStorage('c1', 1, null) + const [minusEvent] = await listContractEvents('c1') + await changeStorage('c1', 2, consent) + const tasks = await openTasks() + assert.equal(tasks.length, 1) + assert.equal(storageTaskKind(tasks[0]), 'periodenende') + assert.equal(tasks[0].details.toUnits, 2) + assert.equal(tasks[0].dueAt, hostingDoc.currentPeriodEnd) + const superseded = (await listContractEvents('c1')).find((e) => e.$id === minusEvent.$id) assert.equal(superseded.status, 'erledigt') assert.equal(superseded.doneBy, 'ersetzt') assert.ok(superseded.doneAt) - assert.deepEqual(superseded.details, plusEvent.details, 'details des ersetzten Ereignisses bleiben') - const open = (await listOpenStorageTasks()).filter((t) => t.subscriptionId === 'sub_h') - assert.equal(open.length, 1) - assert.equal(open[0].details.toUnits, 1) - assert.equal(open[0].dueAt, hostingDoc.currentPeriodEnd) - // Ein fremdes Abo (c2) bleibt unberuehrt + assert.deepEqual(superseded.details, minusEvent.details, 'details des ersetzten Ereignisses bleiben') +}) + +test('Plus über die geplante Verringerung hinaus hebt sie auf: nur noch die Einrichtung', async () => { + await resetSubH({ units: 3, paidUnits: 3 }) + await changeStorage('c1', 1, null) + await changeStorage('c1', 5, consent) + const tasks = await openTasks() + assert.equal(tasks.length, 1) + assert.equal(storageTaskKind(tasks[0]), 'einrichten') + assert.equal(tasks[0].details.toUnits, 5) + const events = await listContractEvents('c1') + assert.equal(events.filter((e) => e.status === 'erledigt' && e.doneBy === 'ersetzt').length, 1) +}) + +test('Neues Plus ersetzt die offene Einrichtung; ein fremdes Abo bleibt unberührt', async () => { + await resetSubH({ units: 0 }) + await changeStorage('c1', 2, consent) + const [first] = await listContractEvents('c1') await recordContractEvent({ customerId: 'c2', subscriptionId: 'sub_h2', type: 'speicher', productKey: 'hosting', - details: { fromUnits: 0, toUnits: 1 }, consent, status: 'offen', dueAt: new Date().toISOString(), + details: { kind: 'einrichten', fromUnits: 0, toUnits: 1 }, consent, status: 'offen', dueAt: new Date().toISOString(), }) - await changeStorage('c1', 2, consent) + await changeStorage('c1', 3, consent) + const tasks = await openTasks() + assert.equal(tasks.length, 1) + assert.equal(tasks[0].details.toUnits, 3) + const superseded = (await listContractEvents('c1')).find((e) => e.$id === first.$id) + assert.equal(superseded.status, 'erledigt') + assert.equal(superseded.doneBy, 'ersetzt') assert.equal((await listContractEvents('c2'))[0].status, 'offen') await clearEvents('c2') }) // F20 / Rechtsrunde: ueber das Kuendigungsformular (viaCancellation) sind Verringerungen bei jedem // laufenden Status und auch bei gekuendigtem Abo erlaubt, Erhoehungen nie; K13 entfaellt dort (F31) -test('Kündigungsweg: Verringerung auch bei gekündigtem Abo, Erhöhung nie', async () => { +test('Kündigungsweg: Verringerung auch bei gekündigtem Abo, Erhöhung nie, K13 entfällt', async () => { await resetSubH({ units: 2, patch: { cancelAtPeriodEnd: true, cancelAt: hostingDoc.currentPeriodEnd }, stripe: { cancel_at_period_end: true }, }) + const result = await changeStorage('c1', 1, null, { viaCancellation: true }) + assert.equal(result.change.direction, 'minus') + assert.equal(doc().storageUnits, 1) + const [ev] = await listContractEvents('c1') + assert.equal(ev.type, 'speicher') + assert.equal(ev.status, 'offen') + assert.equal(ev.details.viaCancellation, true) + assert.equal(ev.consentIp ?? null, null) + assert.equal(k13(), null, 'K13 entfaellt beim Kuendigungsformular (K03 bestaetigt)') + assert.ok(t13(), 'T13 geht trotzdem raus') + assert.equal(mails.sent.length, 1) + + await assert.rejects(changeStorage('c1', 3, consent, { viaCancellation: true }), (e) => e.status === 409) + // Der Spiegel bleibt gekuendigt (aus Stripe); ohne viaCancellation bleibt er gesperrt, auch fuer ein Minus + assert.equal(doc().cancelAtPeriodEnd, true) + await assert.rejects(changeStorage('c1', 0, null), (e) => e.status === 409) + + // past_due: Verringerung erlaubt; beendetes Abo (canceled): nicht mehr + await resetSubH({ units: 2, patch: { status: 'past_due' }, stripe: { status: 'past_due' } }) + assert.equal((await changeStorage('c1', 0, null, { viaCancellation: true })).change.toUnits, 0) + assert.equal(doc().status, 'past_due') + await resetSubH({ units: 2, patch: { status: 'canceled', endedAt: new Date().toISOString() } }) + await assert.rejects(changeStorage('c1', 1, null, { viaCancellation: true }), (e) => e.status === 409) +}) + +// --------------------------------------------------------------------------- +// Fehlerpfade (Fix 1, 3/7/8): nach der Stripe-Aenderung bricht nichts mehr stillschweigend ab +// --------------------------------------------------------------------------- + +test('Stripe lehnt die Änderung ab: Ereignis gescheitert, Fehler an den Kunden, nichts gebucht, keine Mails', async () => { + await resetSubH({ units: 0 }) + st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, message: 'No such price: price_weg' }) + await assert.rejects(changeStorage('c1', 2, consent), (e) => e.status === 502 && /Stripe/.test(e.message) && /nichts berechnet/.test(e.message)) + const events = await listContractEvents('c1') + assert.equal(events.length, 1) + assert.equal(events[0].status, 'gescheitert') + assert.equal(events[0].details.kind, 'einrichten') + assert.match(events[0].details.error, /No such price/) + assert.equal(events[0].termsVersion, consent.termsVersion, 'Nachweis der Bestellung bleibt') + assert.equal(storageItem(), undefined, 'nichts bei Stripe') + assert.equal(doc().storageUnits, 0) + assert.equal(doc().storagePaidUnits, 0) + assert.deepEqual(mails.sent, []) + assert.deepEqual(await openTasks(), []) + // Danach geht eine neue Aenderung wieder (Sperre freigegeben) + assert.equal((await changeStorage('c1', 2, consent)).change.toUnits, 2) +}) + +test('Spiegel nicht aktualisierbar nach der Stripe-Änderung: Aufruf gelingt, Ereignis offen, K13 und T13', async () => { + await resetSubH({ units: 0 }) const errors = [] const original = console.error console.error = (...args) => errors.push(args.join(' ')) try { - const result = await changeStorage('c1', 1, null, { viaCancellation: true }) - assert.equal(result.change.direction, 'minus') - assert.equal(aw.docs('subscriptions').get('sub_h').storageUnits, 1) - const [ev] = await listContractEvents('c1') - assert.equal(ev.type, 'speicher') - assert.equal(ev.status, 'offen') - assert.equal(ev.details.viaCancellation, true) - assert.equal(ev.consentIp ?? null, null) - assert.ok(!errors.some((e) => e.includes('K13')), 'K13 entfaellt beim Kuendigungsformular (K03 bestaetigt)') + // Erste PATCH nach der Stripe-Aenderung ist der Spiegel (syncSubscription) + aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 500 }) + const result = await changeStorage('c1', 2, consent) + assert.equal(result.subscription.storageUnits, 2, 'Rueckgabe zeigt die neue Menge') + assert.equal(result.event.status, 'offen') + assert.equal(storageItem().quantity, 2) + assert.ok(errors.some((e) => /Spiegel/.test(e) && /sub_h/.test(e))) + assert.equal(mails.sent.length, 2) + assert.ok(k13() && t13()) + } finally { + console.error = original + } +}) - await assert.rejects(changeStorage('c1', 3, consent, { viaCancellation: true }), (e) => e.status === 409) - // Der Spiegel bleibt gekuendigt (aus Stripe); ohne viaCancellation bleibt er gesperrt, auch fuer ein Minus - assert.equal(aw.docs('subscriptions').get('sub_h').cancelAtPeriodEnd, true) - await assert.rejects(changeStorage('c1', 0, null), (e) => e.status === 409) - - // past_due: Verringerung erlaubt; beendetes Abo (canceled): nicht mehr - await resetSubH({ units: 2, patch: { status: 'past_due' }, stripe: { status: 'past_due' } }) - assert.equal((await changeStorage('c1', 0, null, { viaCancellation: true })).change.toUnits, 0) - assert.equal(aw.docs('subscriptions').get('sub_h').status, 'past_due') - await resetSubH({ units: 2, patch: { status: 'canceled', endedAt: new Date().toISOString() } }) - await assert.rejects(changeStorage('c1', 1, null, { viaCancellation: true }), (e) => e.status === 409) - - // Zum Vergleich: ein Minus im Portal versucht K13 (scheitert hier nur am fehlenden SMTP) - errors.length = 0 - await resetSubH({ units: 2 }) - await changeStorage('c1', 1, null) - assert.ok(errors.some((e) => e.includes('K13')), 'K13 wird im Portal versucht') +test('Ereignis nicht speicherbar: Stripe-Änderung läuft, T13 mit Block nachweis_fehlt', async () => { + await resetSubH({ units: 0 }) + const errors = [] + const original = console.error + console.error = (...args) => errors.push(args.join(' ')) + try { + aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 }) + const result = await changeStorage('c1', 1, consent) + assert.equal(result.event, null) + assert.equal(result.subscription.storageUnits, 1) + assert.equal(storageItem().quantity, 1) + assert.ok(errors.some((e) => /Ereignis/.test(e) && /sub_h/.test(e))) + assert.deepEqual(await listContractEvents('c1'), []) + assert.ok(k13(), 'K13 geht raus') + assert.match(t13().text, /Nachweis nicht gespeichert/) + assert.match(t13().text, /contractEvents/) } finally { console.error = original } @@ -524,33 +737,59 @@ test('Bezahlte Menge nicht speicherbar: Stripe-Änderung bleibt, Ereignis und Au assert.equal(result.change.direction, 'plus') assert.ok(result.event) assert.equal(result.subscription.storageUnits, 2) - const doc = aw.docs('subscriptions').get('sub_h') - assert.equal(doc.storageUnits, 2) - assert.equal(doc.storagePaidUnits, 0, 'Buchung fehlt, der Rest lief durch') + assert.equal(doc().storageUnits, 2) + assert.equal(doc().storagePaidUnits, 0, 'Buchung fehlt, der Rest lief durch') assert.ok(errors.some((e) => /Bezahlte Menge/.test(e) && /sub_h/.test(e) && /400/.test(e))) assert.equal((await listContractEvents('c1')).length, 1) + assert.equal(mails.sent.length, 2) } finally { console.error = original } }) -test('Ereignis nicht speicherbar: Stripe-Änderung bleibt, Aufruf gelingt, Fehler im Log', async () => { - await resetSubH({ units: 0 }) +// Fix 1 (7): scheitert Schritt 2 nach Schritt 1, bleibt die Bestellung offen (partial), das Team setzt die +// Menge in Stripe von Hand; nichts wird stillschweigend verworfen und dem Kunden nichts Falsches gemeldet +test('Zwei Schritte, zweiter scheitert: Menge bleibt bei der bezahlten, Ereignis offen mit partial, T13 mit Handgriff', async () => { + await resetSubH({ units: 1, paidUnits: 3 }) const errors = [] const original = console.error console.error = (...args) => errors.push(args.join(' ')) try { - aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 }) - const result = await changeStorage('c1', 1, consent) - assert.equal(result.event, null) - assert.equal(result.subscription.storageUnits, 1) - assert.ok(errors.some((e) => /Ereignis/.test(e) && /sub_h/.test(e))) - assert.deepEqual(await listContractEvents('c1'), []) + st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, after: 1, message: 'Nachbau: Schritt 2 abgelehnt' }) + const result = await changeStorage('c1', 5, consent) + assert.equal(result.change.partial, true) + assert.equal(result.change.toUnits, 5) + assert.equal(storageItem().quantity, 3, 'Schritt 1 gilt') + assert.equal(doc().storageUnits, 3) + assert.equal(doc().storagePaidUnits, 3, 'nichts ueber die bezahlte Menge hinaus gebucht') + const [ev] = await listContractEvents('c1') + assert.equal(ev.status, 'offen') + assert.equal(ev.details.partial, true) + assert.equal(ev.details.toUnits, 5) + assert.match(ev.details.error, /Schritt 2 abgelehnt/) + assert.ok(errors.some((e) => /Schritt 2/.test(e))) + assert.match(t13().text, /Schritt 2 fehlgeschlagen/) + assert.match(t13().text, /von Hand auf 5 setzen/) + assert.ok(k13(), 'Bestellung bestaetigt') } finally { console.error = original } }) +// Fix 1 (8): Doppelklick oder zwei Tabs - die zweite gleichzeitige Aenderung desselben Abos bekommt 409 +test('Doppelte Änderung gleichzeitig: die zweite bekommt 409, danach geht es wieder', async () => { + await resetSubH({ units: 0 }) + const results = await Promise.allSettled([changeStorage('c1', 2, consent), changeStorage('c1', 3, consent)]) + const ok = results.filter((r) => r.status === 'fulfilled') + const failed = results.filter((r) => r.status === 'rejected') + assert.equal(ok.length, 1) + assert.equal(failed.length, 1) + assert.equal(failed[0].reason.status, 409) + assert.match(failed[0].reason.message, /läuft bereits/) + assert.equal((await listContractEvents('c1')).length, 1) + assert.equal((await changeStorage('c1', 4, consent)).change.toUnits, 4, 'Sperre freigegeben') +}) + // --------------------------------------------------------------------------- // Bezahlte Menge aus Rechnungen (Webhook invoice.paid) // --------------------------------------------------------------------------- @@ -570,9 +809,8 @@ test('invoice.paid: bezahlte Menge aus regulärer Speicher-Zeile', async () => { ], }, }) - const doc = aw.docs('subscriptions').get('sub_h') - assert.equal(doc.storagePaidUnits, 3) - assert.equal(doc.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString()) + assert.equal(doc().storagePaidUnits, 3) + assert.equal(doc().storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString()) }) test('invoice.paid: anteilige Speicher-Zeile oder Rechnung ohne Speicher ändert nichts', async () => { @@ -581,9 +819,26 @@ test('invoice.paid: anteilige Speicher-Zeile oder Rechnung ohne Speicher ändert assert.ok(r1.skipped) const r2 = await recordPaidStorage({ id: 'in_y', lines: { data: [line('price_hosting', 1500)] } }) assert.ok(r2.skipped) - const doc = aw.docs('subscriptions').get('sub_h') - assert.equal(doc.storagePaidUnits, 2) - assert.equal(doc.storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) + assert.equal(doc().storagePaidUnits, 2) + assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) +}) + +// Fix 1 (4): eine verspaetete Rechnung des laufenden Monats senkt die nach einem Plus gebuchte Menge nicht, +// eine wiederholte aeltere Rechnung setzt das Periodenende nie zurueck, eine neuere Periode setzt neu +test('invoice.paid: verspätet nach einem Plus (max), ältere Rechnung (übersprungen), neuere Periode (neu)', async () => { + await resetSubH({ units: 5, paidUnits: 5 }) + const late = await recordPaidStorage({ id: 'in_late', lines: { data: [line('price_mailStorage', 300, { quantity: 3 })] } }) + assert.equal(late.recorded, true) + assert.equal(doc().storagePaidUnits, 5) + assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) + const older = await recordPaidStorage({ id: 'in_alt', lines: { data: [line('price_mailStorage', 300, { quantity: 3, periodEnd: PERIOD_END_UNIX - 30 * DAY })] } }) + assert.ok(older.skipped) + assert.equal(doc().storagePaidUnits, 5) + assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) + const newer = await recordPaidStorage({ id: 'in_neu', lines: { data: [line('price_mailStorage', 300, { quantity: 3, periodEnd: PERIOD_END_UNIX + 30 * DAY })] } }) + assert.equal(newer.recorded, true) + assert.equal(doc().storagePaidUnits, 3) + assert.equal(doc().storagePaidPeriodEnd, new Date((PERIOD_END_UNIX + 30 * DAY) * 1000).toISOString()) }) test('invoice.paid: Spiegel fehlt noch → aus Stripe angelegt, dann gebucht', async () => { @@ -592,16 +847,47 @@ test('invoice.paid: Spiegel fehlt noch → aus Stripe angelegt, dann gebucht', a try { const r = await recordPaidStorage({ id: 'in_z', lines: { data: [line('price_mailStorage', 100, { quantity: 1, subscription: subId, periodEnd: 1793000000 })] } }) assert.equal(r.recorded, true) - const doc = aw.docs('subscriptions').get(subId) - assert.equal(doc.customerId, 'c2') - assert.equal(doc.storageUnits, 1) - assert.equal(doc.storagePaidUnits, 1) - assert.equal(doc.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString()) + const created = aw.docs('subscriptions').get(subId) + assert.equal(created.customerId, 'c2') + assert.equal(created.storageUnits, 1) + assert.equal(created.storagePaidUnits, 1) + assert.equal(created.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString()) } finally { await deleteDocument(config.collections.subscriptions, subId).catch(() => {}) } }) +// Fix 1 (5): wie beim Webhook aus Aufgabe 7 - ein dauerhafter Appwrite-Fehler (4xx) wird protokolliert und +// uebersprungen (200), ein voruebergehender (5xx) gibt 500, Stripe wiederholt +test('Webhook invoice.paid: Appwrite 4xx → 200 und übersprungen, 5xx → 500', async () => { + await resetSubH({ units: 2, paidUnits: 2 }) + const now = Math.floor(Date.now() / 1000) + st.invoices.set('in_h2', { + id: 'in_h2', object: 'invoice', number: 'WK-h2', created: now, status_transitions: { paid_at: now }, + parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } }, + lines: { object: 'list', data: [line('price_hosting', 1500, { periodEnd: PERIOD_END_UNIX + 30 * DAY }), line('price_mailStorage', 300, { quantity: 3, periodEnd: PERIOD_END_UNIX + 30 * DAY })] }, + }) + const event = { id: 'evt_in_h2', object: 'event', type: 'invoice.paid', created: now, data: { object: { id: 'in_h2', object: 'invoice', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } } } } } + const errors = [] + const original = console.error + console.error = (...args) => errors.push(args.join(' ')) + try { + aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 400 }) + const permanent = await postWebhook(event) + assert.equal(permanent.status, 200, JSON.stringify(permanent.body)) + assert.equal(doc().storagePaidUnits, 2, 'uebersprungen') + assert.ok(errors.some((e) => /Bezahlte Menge|Speicher/.test(e) && /400/.test(e))) + aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 500 }) + assert.equal((await postWebhook(event)).status, 500) + assert.equal(doc().storagePaidUnits, 2) + // Wiederholung ohne Stoerung bucht + assert.equal((await postWebhook(event)).status, 200) + assert.equal(doc().storagePaidUnits, 3) + } finally { + console.error = original + } +}) + // F18: der Webhook laedt die Rechnung genau einmal frisch (mit allen Zeilen, auch ueber die 10 eingebetteten // hinaus) und bucht daraus Stundengutschrift und bezahlte Speichermenge test('Webhook invoice.paid: Rechnung einmal frisch geladen, alle Zeilen, Stunde und bezahlte Speichermenge', async () => { @@ -635,11 +921,11 @@ test('Webhook invoice.paid: Rechnung einmal frisch geladen, alle Zeilen, Stunde assert.equal(calls.filter((c) => c.method === 'GET' && c.path === '/v1/invoices/in_w12').length, 1, 'genau einmal frisch geladen') assert.ok(calls.some((c) => c.method === 'GET' && c.path === '/v1/invoices/in_w12/lines'), 'weitere Zeilen nachgeladen') assert.equal(aw.docs('hourCredits').get('in_w12').minutes, 60) - const doc = aw.docs('subscriptions').get('sub_w') - assert.equal(doc.productKey, 'wartung') - assert.equal(doc.storageUnits, 2) - assert.equal(doc.storagePaidUnits, 2) - assert.equal(doc.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString()) + const mirrored = aw.docs('subscriptions').get('sub_w') + assert.equal(mirrored.productKey, 'wartung') + assert.equal(mirrored.storageUnits, 2) + assert.equal(mirrored.storagePaidUnits, 2) + assert.equal(mirrored.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString()) } finally { await deleteDocument(config.collections.subscriptions, 'sub_w').catch(() => {}) await deleteDocument(config.collections.hourCredits, 'in_w12').catch(() => {}) @@ -663,7 +949,8 @@ test('Routen: ohne Session 401, im Modus „Als Kunde ansehen“ 403, ohne Strip test('Route Vorschau: { units } → Vorschau; Grenzen 400', async () => { await resetSubH({ units: 0 }) - st.invoicePreview = previewInvoice(line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Math.floor(Date.now() / 1000) })) + st.invoicePreview = (body) => + previewInvoice(line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Number(body.subscription_details.proration_date) })) const res = await post('/api/abo/storage/preview', { units: 2 }, { cookie: customerCookie() }) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(res.body.direction, 'plus') @@ -692,6 +979,7 @@ test('Route Änderung: Plus braucht acceptTerms und die aktuelle Fassung, Antwor assert.equal(plus.body.subscription.withdrawalOpen, true) assert.deepEqual(plus.body.subscription.withdrawals.map((w) => w.type), ['speicher']) assert.equal(plus.body.change.direction, 'plus') + assert.equal(plus.body.change.kind, 'einrichten') assert.equal(plus.body.change.toUnits, 2) assert.equal(subscriptionUpdates(before).length, 1) const [ev] = await listContractEvents('c1') @@ -699,15 +987,16 @@ test('Route Änderung: Plus braucht acceptTerms und die aktuelle Fassung, Antwor assert.equal(ev.consentIp, '127.0.0.1') assert.ok(ev.consentAt) - // Minus ohne Haekchen: Speichern; Antwort zeigt die geplante Verringerung + // Minus ohne Haekchen: Speichern; die Einrichtung ist noch offen, dazu die geplante Verringerung const minus = await post('/api/abo/storage', { units: 1 }, { cookie }) assert.equal(minus.status, 200, JSON.stringify(minus.body)) assert.deepEqual(minus.body.subscription.storage, { units: 1, - pendingSetup: false, + pendingSetup: true, pendingReduction: { units: 1, effectiveAt: minus.body.subscription.currentPeriodEnd }, }) assert.equal(minus.body.change.direction, 'minus') + assert.equal(minus.body.change.kind, 'periodenende') assert.equal(minus.body.change.effectiveAt, minus.body.subscription.currentPeriodEnd) // Gekuendigt: 409 mit Fehlertext @@ -717,3 +1006,29 @@ test('Route Änderung: Plus braucht acceptTerms und die aktuelle Fassung, Antwor assert.ok(cancelled.body.error) await clearEvents('c1') }) + +// Fix 1 (3): Stripe-Fehler → 5xx mit Fehlertext, nichts gebucht; Spiegel-Fehler nach Stripe → 200 mit neuer Menge +test('Route Änderung: Stripe-Fehler 502 ohne Mails, Spiegel-Fehler nach der Stripe-Änderung 200', async () => { + await resetSubH({ units: 0 }) + const cookie = customerCookie() + st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, message: 'Nachbau: abgelehnt' }) + const failed = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie }) + assert.equal(failed.status, 502) + assert.match(failed.body.error, /Stripe/) + assert.deepEqual(mails.sent, []) + assert.equal((await listContractEvents('c1'))[0].status, 'gescheitert') + + const errors = [] + const original = console.error + console.error = (...args) => errors.push(args.join(' ')) + try { + aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 500 }) + const ok = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie }) + assert.equal(ok.status, 200, JSON.stringify(ok.body)) + assert.deepEqual(ok.body.subscription.storage, { units: 2, pendingSetup: true, pendingReduction: null }) + assert.equal((await listContractEvents('c1')).find((e) => e.status === 'offen').details.toUnits, 2) + assert.equal(mails.sent.length, 2) + } finally { + console.error = original + } +}) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 9854ad7..42731bf 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -4,13 +4,16 @@ 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 +// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); +// die Endfassung der Texte folgt in Task 12 const EXPECTED = [ ...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`), 'K12', + 'K13', 'K14', ...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`), 'T12', + 'T13', ] const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`])) @@ -26,7 +29,8 @@ for (const id of EXPECTED) { const source = readFileSync(new URL(tpl.file, DIR), 'utf8') const title = source.match(/([\s\S]*?)<\/title>/)[1] assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich') - assert.equal(id.startsWith('T'), tpl.subject.startsWith('[Abo]') || id === 'T01', 'Team-Hinweise ab T02 mit [Abo]') + // F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben) oder [Datenschutz] (Loeschfristen) + assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher] oder [Datenschutz]') const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1])) assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter') @@ -200,3 +204,37 @@ test('Wechsel-Mails nennen keine feste Frist von 23 Stunden', () => { assert.doesNotMatch(renderMail(id, sampleValues(tpl), allBlocks(tpl, true)).text, /23 Stunden/, id) } }) + +// Task 8 (Fix 1): K13 (Kunde) und T13 (Team) fuer den Zusatzspeicher, Aufbau wie K12/T12; Endfassung der Texte in Aufgabe 12 +test('K13, T13: Betreff, Platzhalter und Blöcke des Zusatzspeichers', () => { + assert.equal(loadTemplate('K13').subject, 'Ihr E-Mail-Zusatzspeicher – Bestätigung') + assert.deepEqual(loadTemplate('K13').placeholders, ['name', 'pakete', 'gesamt_gb', 'monatspreis_zusatz', 'wirksam_ab']) + assert.deepEqual(loadTemplate('K13').blocks, ['plus', 'minus']) + assert.equal(loadTemplate('T13').subject, '[Speicher] {{aktion}}: {{email}}') + for (const p of ['aktion', 'email', 'name', 'stripe_abo_id', 'postfaecher_hinweis', 'neue_groesse_gb', 'pakete', 'faellig']) { + assert.ok(loadTemplate('T13').placeholders.includes(p), p) + } + assert.deepEqual([...loadTemplate('T13').blocks].sort(), ['einrichten', 'nachweis_fehlt', 'periodenende', 'teilweise']) + + const values = sampleValues(loadTemplate('K13')) + const plus = renderMail('K13', values, { plus: true }) + assert.deepEqual(plus.problems, []) + assert.match(plus.text, /gemäß § 19 UStG ohne Umsatzsteuer/) + assert.match(plus.text, /wird eingerichtet/) + assert.match(plus.text, /14 Tagen widerrufen/) + assert.match(plus.text, /Vertragsunterlagen im Anhang/) + assert.doesNotMatch(plus.text, /ohne anteilige Erstattung/) + const minus = renderMail('K13', values, { minus: true }) + assert.match(minus.text, /ohne anteilige Erstattung/) + assert.match(minus.text, /keine neuen E-Mails/) + assert.doesNotMatch(minus.text, /wird eingerichtet|14 Tagen widerrufen|im Anhang/) + + const t = loadTemplate('T13') + const teilweise = renderMail('T13', { ...sampleValues(t), pakete: 5 }, { einrichten: true, teilweise: true }) + assert.deepEqual(teilweise.problems, []) + assert.match(teilweise.text, /Schritt 2 fehlgeschlagen/) + assert.match(teilweise.text, /von Hand auf 5 setzen/) + assert.match(renderMail('T13', sampleValues(t), { nachweis_fehlt: true }).text, /Nachweis nicht gespeichert/) + assert.match(renderMail('T13', sampleValues(t), { periodenende: true }).text, /Periodenende|zum Wert-faellig/) + assert.doesNotMatch(renderMail('T13', sampleValues(t), {}).text, /Schritt 2 fehlgeschlagen|Nachweis nicht gespeichert/) +}) diff --git a/test/products.test.js b/test/products.test.js index fdabc81..5beece3 100644 --- a/test/products.test.js +++ b/test/products.test.js @@ -196,3 +196,11 @@ test('regularStorageLine: reguläre Zusatzspeicher-Zeile, keine anteilige, kein assert.equal(regularStorageLine(inv({ amount: 5 }), keys), null) assert.equal(regularStorageLine({}, keys), null) }) + +// Task 8 (Fix 1, Testluecke 9): eine Rechnung nur mit Zusatzspeicher bringt keine Stunde, auch nicht als +// "Altbestand" (der Speicherpreis ist bekannt, nur kein Abo-Produkt) +test('Nur Zusatzspeicher auf der Rechnung: keine Stunde', () => { + const inv = (...lines) => ({ lines: { data: lines } }) + assert.equal(creditMinutesForInvoice(inv({ ...line('price_s', 300), quantity: 3 }), keys), 0) + assert.equal(creditMinutesForInvoice(inv(line('price_s', 100), line('price_s', 33, true)), keys), 0) +}) diff --git a/test/support.test.js b/test/support.test.js index 67c3735..0f00fd7 100644 --- a/test/support.test.js +++ b/test/support.test.js @@ -257,3 +257,22 @@ test('Stripe-Nachbau: Rechnungsvorschau aus dem Seed', async () => { await assert.rejects(stripe.invoices.createPreview({ subscription: 'sub_nix' }), (e) => e.statusCode === 404) st.invoicePreview = null }) + +// F41d / Task 8 (Fix 1): Fehler im Stripe-Nachbau einplanen - einmalig, nur fuer den passenden Aufruf +// (method, path als String oder RegExp); after passende Aufrufe gehen vorher durch. Der gescheiterte Aufruf +// steht trotzdem in calls; 4xx wiederholt stripe-node nicht (5xx schon, maxNetworkRetries). +test('Stripe-Nachbau: failNext lässt den nächsten passenden Aufruf scheitern', async () => { + const stripe = getStripe() + st.subscriptions.set('sub_f', { id: 'sub_f', object: 'subscription', status: 'active', metadata: {}, items: { object: 'list', data: [] } }) + st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_f', status: 400, message: 'Nachbau: abgelehnt' }) + await assert.rejects(stripe.subscriptions.update('sub_f', { metadata: { a: 'b' } }), (e) => e.statusCode === 400 && /abgelehnt/.test(e.message)) + assert.equal(st.calls.at(-1).body['metadata[a]'], 'b', 'gescheiterter Aufruf steht in calls') + assert.equal((await stripe.subscriptions.retrieve('sub_f')).metadata.a, undefined, 'gescheiterter Aufruf aendert nichts') + assert.equal((await stripe.subscriptions.update('sub_f', { metadata: { a: 'b' } })).metadata.a, 'b', 'einmalig') + // Nur der passende Pfad (RegExp) und erst nach `after` passenden Aufrufen + st.failNext({ method: 'POST', path: /\/v1\/subscriptions\/sub_f$/, status: 402, after: 1 }) + assert.equal((await stripe.subscriptions.retrieve('sub_f')).id, 'sub_f') + assert.equal((await stripe.subscriptions.update('sub_f', { metadata: { a: 'c' } })).metadata.a, 'c') + await assert.rejects(stripe.subscriptions.update('sub_f', { metadata: { a: 'd' } }), (e) => e.statusCode === 402) + assert.equal((await stripe.subscriptions.retrieve('sub_f')).metadata.a, 'c') +}) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index 3b46926..a416364 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -38,8 +38,24 @@ export async function startFakeStripe() { let seq = 0 let nextSubscriptionUpdate = null let invoicePreview = null + // Eingeplanter Fehler (failNext, F41d): der naechste passende Aufruf scheitert einmalig + let pendingFailure = null const nextId = (prefix) => `${prefix}_test_${(seq += 1)}` + const matchesPath = (path, pathname) => (path instanceof RegExp ? path.test(pathname) : path === pathname) + const plannedFailure = (method, pathname) => { + if (!pendingFailure) return null + if (pendingFailure.method && pendingFailure.method !== method) return null + if (pendingFailure.path && !matchesPath(pendingFailure.path, pathname)) return null + if (pendingFailure.after > 0) { + pendingFailure.after -= 1 + return null + } + const { status, message } = pendingFailure + pendingFailure = null + return [status, { error: { type: status >= 500 ? 'api_error' : 'invalid_request_error', message: message || `Nachbau: eingeplanter Fehler ${status}` } }] + } + const flat = (params) => Object.fromEntries(params.entries()) const noSuch = (what, id) => [404, { error: { type: 'invalid_request_error', code: 'resource_missing', message: `No such ${what}: '${id}'` } }] const invalid = (param, message) => [400, { error: { type: 'invalid_request_error', param, message } }] @@ -337,7 +353,7 @@ export async function startFakeStripe() { const url = new URL(req.url, 'http://127.0.0.1') const body = await readBody(req) calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body) }) - const [status, payload] = route(req.method, url.pathname, url.searchParams, body) + const [status, payload] = plannedFailure(req.method, url.pathname) || route(req.method, url.pathname, url.searchParams, body) res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` }) res.end(JSON.stringify(payload)) }) @@ -380,6 +396,14 @@ export async function startFakeStripe() { set invoicePreview(value) { invoicePreview = value }, + /** + * Naechster passender Aufruf (method, path als String oder RegExp) scheitert einmalig mit status; after + * passende Aufrufe gehen vorher durch. Der Aufruf steht trotzdem in calls. 4xx wiederholt stripe-node + * nicht, 5xx schon (maxNetworkRetries) - fuer "Stripe lehnt ab" also 4xx einplanen. + */ + failNext: ({ method = null, path = null, status = 500, message = '', after = 0 }) => { + pendingFailure = { method, path, status, message, after } + }, close: () => new Promise((resolve) => server.close(resolve)), } }