From 379cc26dcdcf5b83609a90260cbc45e8d8e7732d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 10:48:29 +0000 Subject: [PATCH] =?UTF-8?q?Zusatzspeicher:=20Plus=20sofort=20anteilig,=20M?= =?UTF-8?q?inus=20zum=20Monatsende,=20Aufgaben=20f=C3=BCr=20konsoleH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- server/routes/abo.js | 63 ++- server/services/mailStorage.js | 384 +++++++++++++++++ server/services/products.js | 35 +- server/services/stripeBilling.js | 44 +- test/hourCredits.test.js | 48 ++- test/mailStorage.test.js | 719 +++++++++++++++++++++++++++++++ test/products.test.js | 39 +- test/support.test.js | 97 +++++ test/support/appwriteMock.js | 21 +- test/support/fakeStripe.js | 104 ++++- test/support/fixtures.js | 4 +- 11 files changed, 1531 insertions(+), 27 deletions(-) create mode 100644 server/services/mailStorage.js create mode 100644 test/mailStorage.test.js diff --git a/server/routes/abo.js b/server/routes/abo.js index 3da5de5..fdbcf49 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -9,6 +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 { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js' import { PRODUCT_KEYS, productView, termsDocFor } from '../services/products.js' import { @@ -20,6 +21,7 @@ import { createBillingPortalUrl, getCurrentSubscription, grantCreditForInvoice, + loadInvoice, minTermMonthsFor, isStripeConfigured, syncSubscription, @@ -256,6 +258,59 @@ router.post('/portal', requireSession, async (req, res) => { } }) +// --------------------------------------------------------------------------- +// E-Mail-Zusatzspeicher (Spec 3.4, 4.3): Vorschau und Aenderung (mailStorage.js) +// --------------------------------------------------------------------------- + +/** Vorschau fuer { units }: Richtung, Gesamtgroesse, Mehrpreis, anteiliger Betrag, naechste Abbuchung, Wirksamkeit. */ +router.post('/storage/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 previewStorageChange(getSessionCustomerId(req), req.body?.units)) + } catch (err) { + return res.status(err.status || 500).json({ error: err.message || 'Vorschau fehlgeschlagen' }) + } +}) + +/** + * Aenderung { units, acceptTerms, termsVersion }: ein Plus ist eine Bestellung ("Zahlungspflichtig bestellen") + * und braucht acceptTerms zur aktuellen Fassung - der Nachweis (Fassung, Zeitpunkt, IP, Browser) landet im + * Ereignis `speicher`; ein Minus ("Speichern") braucht kein Haekchen und speichert keine Zustimmungsdaten. + * Antwort: die Abo-Ansicht wie in GET /api/abo (mit Speicherstand) und die Aenderung selbst. + */ +router.post('/storage', requireSession, async (req, res) => { + try { + if (req.session.viewAs) { + return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann der Zusatzspeicher nicht geändert werden.' }) + } + const customerId = getSessionCustomerId(req) + const { units, acceptTerms, termsVersion } = req.body || {} + let consent = null + if (acceptTerms === true) { + if (termsVersion !== config.abo.termsVersion) { + return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' }) + } + consent = { + termsVersion, + consentAt: new Date().toISOString(), + consentIp: req.ip, + consentUserAgent: req.get('user-agent') || '', + } + } + const { subscription, change } = await changeStorage(customerId, units, consent) + const events = await listContractEvents(customerId).catch((e) => { + console.error('[abo] Vertragsereignisse nicht lesbar:', e.message) + return [] + }) + return res.json({ subscription: publicSubscription(subscription, events), change }) + } catch (err) { + console.error('[abo] Zusatzspeicher-Änderung fehlgeschlagen:', err.message) + return res.status(err.status || 500).json({ error: err.message || 'Änderung fehlgeschlagen' }) + } +}) + // --------------------------------------------------------------------------- // Stripe-Webhook // --------------------------------------------------------------------------- @@ -396,10 +451,16 @@ async function handleStripeEvent(event) { return } case 'invoice.paid': { - const result = await grantCreditForInvoice(obj) + // Einmal frisch laden, mit allen Zeilen (F18): Stundengutschrift (Wartung) und bezahlte Speichermenge + const fresh = await loadInvoice(obj.id) + const result = await grantCreditForInvoice(fresh, { fresh: true }) if (result.granted) { console.log(`[abo] 60 min gutgeschrieben: Kunde ${result.customerId}, Rechnung ${obj.id}`) } + const storage = await recordPaidStorage(fresh) + if (storage.recorded) { + console.log(`[abo] Zusatzspeicher bezahlt: Abo ${storage.subscriptionId}, ${storage.units} Pakete bis ${storage.periodEnd}`) + } return } case 'invoice.payment_failed': { diff --git a/server/services/mailStorage.js b/server/services/mailStorage.js new file mode 100644 index 0000000..27dcb86 --- /dev/null +++ b/server/services/mailStorage.js @@ -0,0 +1,384 @@ +import { readFile } from 'node:fs/promises' +import { config } from '../config.js' +import { getDocument, updateDocument } from './appwriteAdmin.js' +import { listContractEvents, recordContractEvent, updateContractEvent } from './contractEvents.js' +import { sendAdminNotice, sendCustomerMail } from './mailer.js' +import { contractAttachmentFor, describeItems, priceIdsOfInvoice, regularStorageLine } from './products.js' +import { + ACTIVE_STATUSES, + ensurePrice, + getCurrentSubscription, + getStripe, + invoiceLines, + resolvePriceKeys, + subscriptionIdOfInvoice, + syncSubscription, +} from './stripeBilling.js' + +/** + * E-Mail-Zusatzspeicher (Spec 3.4, Bedingungen Fassung 4 § 10): Pakete zu 5 GB fuer 1,00 € je Monat als + * zweite Position (Stueckzahl) im Stripe-Abo des Kunden, 0 bis 10 Pakete, in beiden Abos. + * + * - Plus (Bestellung, "Zahlungspflichtig bestellen"): sofort. Den Rest des laufenden Monats berechnet + * Stripe anteilig mit der naechsten Abbuchung (proration_behavior create_prorations). Pakete, die fuer + * den laufenden Monat schon bezahlt sind (nach einem Minus im selben Monat), kosten nicht noch einmal: + * 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). + */ + +function httpError(status, message) { + const err = new Error(message) + err.status = status + return err +} + +const fmtDate = (iso) => + iso + ? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) + : '–' +// 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 unitsOf = (doc) => Number(doc?.storageUnits) || 0 +const totalGbOf = (units) => config.mailStorage.includedGb + units * config.mailStorage.gbPerUnit +const monthlyCentsOf = (units) => units * config.mailStorage.amountCents + +/** + * Stripe-Schritte fuer eine Aenderung auf newUnits Pakete: { direction: 'plus' | 'minus' | 'none', steps: + * [{ quantity, proration_behavior }] }. Ein Plus bis zur schon bezahlten Menge (paidUnits) laeuft ohne + * Verrechnung (none); darueber hinaus anteilig (create_prorations), bei currentUnits < paidUnits in zwei + * Schritten. Ein Minus laeuft ohne Erstattung (none). newUnits muss eine ganze Zahl von 0 bis maxUnits sein. + */ +export function planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits }) { + if (!Number.isInteger(newUnits) || newUnits < 0 || newUnits > maxUnits) { + throw httpError(400, `Bitte wählen Sie zwischen 0 und ${maxUnits} Paketen Zusatzspeicher.`) + } + const current = Number(currentUnits) || 0 + const paid = Number(paidUnits) || 0 + if (newUnits === current) return { direction: 'none', steps: [] } + if (newUnits < current) return { direction: 'minus', steps: [{ quantity: newUnits, proration_behavior: 'none' }] } + if (newUnits <= paid) return { direction: 'plus', steps: [{ quantity: newUnits, proration_behavior: 'none' }] } + if (current < paid) { + return { + direction: 'plus', + steps: [ + { quantity: paid, proration_behavior: 'none' }, + { quantity: newUnits, proration_behavior: 'create_prorations' }, + ], + } + } + return { direction: 'plus', steps: [{ quantity: newUnits, proration_behavior: 'create_prorations' }] } +} + +/** + * 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). + */ +export function paidUnitsOf(doc) { + const paidEnd = doc?.storagePaidPeriodEnd + const periodEnd = doc?.currentPeriodEnd + const samePeriod = + Boolean(paidEnd && periodEnd) && (paidEnd === periodEnd || new Date(paidEnd).getTime() === new Date(periodEnd).getTime()) + return samePeriod ? Number(doc.storagePaidUnits) || 0 : unitsOf(doc) +} + +async function requireSubscription(customerId) { + const doc = await getCurrentSubscription(customerId) + if (!doc) throw httpError(404, 'Kein Abo vorhanden.') + return doc +} + +// Position fuer subscriptions.update / invoices.createPreview: vorhandener Speicher-Posten per ID, sonst neu per Preis +const storageItemParam = (storageItemId, priceId, quantity) => (storageItemId ? { id: storageItemId, quantity } : { price: priceId, quantity }) + +/** + * Ein Minus wirkt zum Ende des Abrechnungsmonats; endet das Abo frueher (cancelAt), endet der Speicher + * mit ihm (Spec 3.4 "Der Zusatzspeicher endet automatisch mit dem Abo"). + */ +function reductionEffectiveAt(doc) { + const periodEnd = doc.currentPeriodEnd || null + const cancelAt = doc.cancelAt || null + if (periodEnd && cancelAt && new Date(cancelAt) < new Date(periodEnd)) return cancelAt + return periodEnd || cancelAt +} + +/** + * 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. + */ +function storageProrationCents(preview, priceIds, prorationDate) { + return (preview?.lines?.data || []) + .filter((line) => { + const parent = line.parent || {} + 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 + }) + .reduce((sum, line) => sum + (Number(line.amount) || 0), 0) +} + +/** + * 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. + */ +export async function previewStorageChange(customerId, newUnits) { + const doc = await requireSubscription(customerId) + const currentUnits = unitsOf(doc) + const paidUnits = paidUnitsOf(doc) + const plan = planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits: config.mailStorage.maxUnits }) + const now = new Date() + let prorationCents = 0 + if (plan.direction === 'plus' && newUnits > paidUnits) { + const stripe = getStripe() + const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)]) + const items = stripeSub.items?.data || [] + const { storageItemId } = describeItems(items) + const prorationDate = Math.floor(now.getTime() / 1000) + const preview = await stripe.invoices.createPreview({ + subscription: doc.$id, + subscription_details: { + items: [storageItemParam(storageItemId, priceId, newUnits)], + proration_behavior: 'create_prorations', + proration_date: prorationDate, + }, + }) + // 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)) + } + return { + direction: plan.direction, + units: newUnits, + totalGb: totalGbOf(newUnits), + monthlyCents: monthlyCentsOf(newUnits), + deltaMonthlyCents: monthlyCentsOf(newUnits) - monthlyCentsOf(currentUnits), + prorationCents, + nextChargeAt: doc.currentPeriodEnd || null, + effectiveAt: plan.direction === 'minus' ? reductionEffectiveAt(doc) : now.toISOString(), + } +} + +/** + * 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 + * (viaCancellation, Task 10; Bedingungen § 10 Abs. 5 "jederzeit") ist eine Verringerung in jedem laufenden + * Status erlaubt, auch bei gekuendigtem Abo oder offener Zahlung - eine Erhoehung dort nie (F20). + */ +function assertChangeAllowed(doc, events, direction, viaCancellation) { + if (viaCancellation) { + if (direction !== 'minus') throw httpError(409, 'Über die Kündigung lässt sich der Zusatzspeicher nur verringern.') + if (!ACTIVE_STATUSES.has(doc.status)) throw httpError(409, 'Das Abo läuft nicht mehr.') + return + } + if (doc.status !== 'active') { + throw httpError( + 409, + 'Der Zusatzspeicher lässt sich nur bei laufendem Abo ändern, nicht in der Wartezeit bis zum Leistungsbeginn und nicht bei einem Zahlungsproblem.' + ) + } + if (doc.cancelAtPeriodEnd || doc.cancelAt) throw httpError(409, 'Ihr Abo ist gekündigt. Der Zusatzspeicher endet mit dem Abo.') + if (events.some((e) => e.type === 'wechsel' && e.status === 'offen')) { + throw httpError(409, 'Ihr Wechsel wartet noch auf die Zahlung. Bitte versuchen Sie es danach erneut.') + } +} + +/** + * 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. + */ +async function recordStorageEvent({ doc, events, change, consent, dueAt, 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: { + fromUnits: change.fromUnits, + toUnits: change.toUnits, + effectiveAt: change.effectiveAt, + totalGb: change.totalGb, + monthlyCents: change.monthlyCents, + deltaMonthlyCents: change.deltaMonthlyCents, + ...(viaCancellation ? { viaCancellation: true } : {}), + }, + // Nur eine Bestellung (Plus) traegt Zustimmungsdaten; ein Minus keine (Rechtsrunde 28.09.2026) + consent: + change.direction === 'plus' + ? { + termsVersion: consent.termsVersion, + consentAt: consent.consentAt, + consentIp: consent.consentIp || null, + consentUserAgent: consent.consentUserAgent || null, + } + : null, + status: 'offen', + dueAt, + }) + } catch (err) { + console.error(`[speicher] Ereignis für Abo ${doc.$id} nicht gespeichert:`, err.message) + return null + } +} + +/** 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) + const content = await readFile(path).catch((err) => { + console.error(`[speicher] Vertragsunterlagen ${filename} nicht lesbar:`, err.message) + return null + }) + return content ? [{ filename, content, contentType: 'application/pdf' }] : [] +} + +/** + * 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. + */ +async function sendStorageMails({ doc, change, dueAt, 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) + if (!viaCancellation && email) { + const attachments = plus ? await contractPdfAttachment(consent.termsVersion) : [] + await sendCustomerMail(email, { + template: 'K13', + values: { + name: customer?.name || '', + pakete: change.toUnits, + gesamt_gb: change.totalGb, + monatspreis_zusatz: fmtEuro(change.monthlyCents), + wirksam_ab: wirksamAb, + }, + blocks: { plus, minus: !plus }, + attachments, + }).catch((e) => console.error(`[speicher] K13 an ${email} fehlgeschlagen:`, e.message)) + } + await sendAdminNotice({ + template: 'T13', + values: { + aktion: plus ? 'einrichten' : `reduzieren zum ${wirksamAb}`, + email, + 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), + }, + blocks: proofMissing ? { nachweis_fehlt: true } : {}, + }) +} + +/** + * 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 }. + */ +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) + 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 change = { + direction: plan.direction, + fromUnits: currentUnits, + toUnits: newUnits, + effectiveAt, + totalGb: totalGbOf(newUnits), + monthlyCents: monthlyCentsOf(newUnits), + deltaMonthlyCents: monthlyCentsOf(newUnits) - monthlyCentsOf(currentUnits), + } + // 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 }) + 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. + */ +export async function recordPaidStorage(invoice) { + const lines = await invoiceLines(invoice) + const loaded = { ...invoice, lines: { ...(invoice?.lines || {}), data: lines, has_more: false } } + const line = regularStorageLine(loaded, await resolvePriceKeys(priceIdsOfInvoice(loaded))) + if (!line) return { skipped: 'keine reguläre Speicher-Zeile' } + 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) } + 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) + } + return { recorded: true, subscriptionId, units: data.storagePaidUnits, periodEnd: data.storagePaidPeriodEnd } +} diff --git a/server/services/products.js b/server/services/products.js index 2cd33fa..ab50d00 100644 --- a/server/services/products.js +++ b/server/services/products.js @@ -1,3 +1,5 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { config } from '../config.js' /** @@ -34,6 +36,19 @@ export function termsDocFor(termsVersion) { return { url: '/vertragsunterlagen/v3/abo-bedingungen.html', pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf' } } +const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public') + +/** + * Vertragsunterlagen als Mail-Anhang in der Fassung, der der Kunde zugestimmt hat (§ 312f BGB): + * { filename, path } mit dem absoluten Pfad unter public/. Ein Speicher-Plus haengt die Fassung aus der + * Zustimmung an (consent.termsVersion), nicht die des Abos (F22): Bestandskunden mit Fassung 3 bestellen + * den Zusatzspeicher nach Fassung 4. + */ +export function contractAttachmentFor(termsVersion) { + const { pdfFile } = termsDocFor(termsVersion) + return { filename: pdfFile, path: path.join(PUBLIC_DIR, pdfFile) } +} + /** Ansicht eines Produkts fuers Kundenportal (Vergleich, Bestellseite): Preis und Leistungen. */ export function productView(key) { const product = getProduct(key) @@ -78,12 +93,27 @@ export function describeItems(items) { const priceIdOfLine = (line) => line?.pricing?.price_details?.price || null +// Regulaere Abo-Zeile (Monatsposition): keine anteilige Verrechnung, keine einzelne Rechnungsposition +const isRegularSubscriptionLine = (line) => + line?.parent?.type === 'subscription_item_details' && line.parent.subscription_item_details?.proration === false + /** Preis-IDs aller Rechnungszeilen, jede nur einmal. */ export function priceIdsOfInvoice(invoice) { const ids = (invoice?.lines?.data || []).map(priceIdOfLine).filter(Boolean) return [...new Set(ids)] } +/** + * Regulaere Zusatzspeicher-Zeile einer bezahlten Rechnung (Stueckzahl des Abrechnungsmonats) oder null. + * Anteilige Zeilen (Plus im laufenden Monat) zaehlen nicht. priceKeys wie bei creditMinutesForInvoice. + */ +export function regularStorageLine(invoice, priceKeys) { + return ( + (invoice?.lines?.data || []).find((line) => priceKeys.get(priceIdOfLine(line)) === 'mailStorage' && isRegularSubscriptionLine(line)) || + null + ) +} + /** * Arbeitszeit, die eine bezahlte Rechnung gutschreibt: die Minuten des Produkts der ersten * regulären (nicht anteiligen) Abo-Zeile über 0 €, deren Produkt Arbeitszeit enthält. Anteilige @@ -100,10 +130,7 @@ export function creditMinutesForInvoice(invoice, priceKeys) { const key = legacy && known == null ? 'wartung' : known if (!PRODUCT_KEYS.includes(key)) continue const minutes = config.products[key].minutesPerMonth - const regular = - line.parent?.type === 'subscription_item_details' && - line.parent.subscription_item_details?.proration === false - if (minutes > 0 && regular && line.amount > 0) return minutes + if (minutes > 0 && isRegularSubscriptionLine(line) && line.amount > 0) return minutes } return 0 } diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index 00c6303..c5ffaed 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -326,7 +326,8 @@ export function termStartOf(sub) { return new Date(((sub.trial_end || sub.start_date) || 0) * 1000) } -function subscriptionIdOfInvoice(invoice) { +/** Stripe-Abo-ID (oder -Objekt) einer Rechnung: Webhook-Nutzlast (alt/neu) oder frisch geladene Rechnung. */ +export function subscriptionIdOfInvoice(invoice) { return ( invoice.subscription || invoice.parent?.subscription_details?.subscription || @@ -356,8 +357,33 @@ export async function resolvePriceKeys(priceIds) { return keys } -/** Bezahlte Wartungs-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). */ -export async function grantCreditForInvoice(invoice) { +/** + * Alle Zeilen einer Rechnung. invoices.retrieve bettet hoechstens 10 Zeilen ein (lines.has_more); anteilige + * Zeilen stehen vor den Abo-Zeilen, mehrere Speicheraenderungen in einem Monat schieben also die Wartungs- + * oder Speicher-Zeile hinaus. Bei has_more kommen alle Seiten ueber invoices.listLineItems (F18). Eine + * Vorschau-Rechnung hat keine ID und laesst sich nicht nachladen: dort zaehlen die eingebetteten Zeilen. + */ +export async function invoiceLines(invoice) { + const embedded = invoice?.lines?.data || [] + if (!invoice?.lines?.has_more || !invoice.id) return embedded + return getStripe().invoices.listLineItems(invoice.id, { limit: 100 }).autoPagingToArray({ limit: 1000 }) +} + +/** + * Rechnung frisch laden (in der API-Version des SDK, nicht der des Webhook-Endpunkts), mit allen Zeilen. + * Der Webhook invoice.paid laedt sie genau einmal und reicht sie an Stundengutschrift und Speicher-Buchung weiter. + */ +export async function loadInvoice(invoiceId) { + const fresh = await getStripe().invoices.retrieve(invoiceId) + const data = await invoiceLines(fresh) + return { ...fresh, lines: { ...(fresh.lines || {}), data, has_more: false } } +} + +/** + * Bezahlte Wartungs-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). invoice ist die + * Webhook-Nutzlast (wird frisch geladen) oder, mit fresh: true, eine schon per loadInvoice geladene Rechnung. + */ +export async function grantCreditForInvoice(invoice, { fresh = false } = {}) { const ref = subscriptionIdOfInvoice(invoice) const subscriptionId = typeof ref === 'string' ? ref : ref?.id if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' } @@ -365,28 +391,28 @@ export async function grantCreditForInvoice(invoice) { // Es zählt die reguläre Wartungs-Zeile vor Rabatt und Guthaben: Auch ein Monat, den ein Angebot // oder eine Übernahmegutschrift ganz deckt, ist ein bezahlter Leistungsmonat. Probezeit (0 €), // anteilige Verrechnungen, Hosting und Zusatzspeicher bringen keine Stunde. - const fresh = await getStripe().invoices.retrieve(invoice.id) - const minutes = creditMinutesForInvoice(fresh, await resolvePriceKeys(priceIdsOfInvoice(fresh))) + const loaded = fresh ? invoice : await loadInvoice(invoice.id) + const minutes = creditMinutesForInvoice(loaded, await resolvePriceKeys(priceIdsOfInvoice(loaded))) if (!minutes) return { skipped: 'keine Wartungs-Leistung' } const sub = await syncSubscription(subscriptionId) if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' } - const grantedAt = new Date((fresh.status_transitions?.paid_at || fresh.created) * 1000) + const grantedAt = new Date((loaded.status_transitions?.paid_at || loaded.created) * 1000) try { await createDocument( config.collections.hourCredits, { customerId: sub.customerId, stripeSubscriptionId: subscriptionId, - stripeInvoiceId: invoice.id, + stripeInvoiceId: loaded.id, minutes, grantedAt: grantedAt.toISOString(), expiresAt: addMonths(grantedAt, config.abo.creditValidityMonths).toISOString(), source: 'stripe_invoice', - note: fresh.number ? `Rechnung ${fresh.number}` : '', + note: loaded.number ? `Rechnung ${loaded.number}` : '', }, - invoice.id + loaded.id ) return { granted: true, customerId: sub.customerId, subscription: sub } } catch (err) { diff --git a/test/hourCredits.test.js b/test/hourCredits.test.js index 9f4ef56..3001ae5 100644 --- a/test/hourCredits.test.js +++ b/test/hourCredits.test.js @@ -11,7 +11,7 @@ const aw = await startAppwriteMock({ const st = await startFakeStripe() after(() => Promise.all([aw.close(), st.close()])) useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) -const { grantCreditForInvoice } = await import('../server/services/stripeBilling.js') +const { grantCreditForInvoice, loadInvoice, invoiceLines } = await import('../server/services/stripeBilling.js') const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', lookup_key, unit_amount, active: true }) st.prices.set('price_w', price('price_w', 'webklar_wartung_monatlich_5000', 5000)) @@ -116,3 +116,49 @@ test('Altbestand: Abo ohne bekannten Preis bekommt weiter seine Stunde, auch bei assert.equal(aw.docs('hourCredits').get('in_6').minutes, 60) assert.equal(aw.docs('subscriptions').get('sub_alt').productKey, 'wartung') }) + +// F18 / Hinweis aus dem Review von Plan 1: invoices.retrieve bettet hoechstens 10 Zeilen ein. Anteilige +// Zeilen stehen vor den Abo-Zeilen; mehrere Speicheraenderungen in einem Monat schieben die Wartungs-Zeile +// hinaus. Der Nachbau bettet wie Stripe 10 Zeilen ein (has_more), der Rest kommt ueber /lines. +test('Rechnung mit 11 Zeilen: die Wartungs-Zeile als elfte wird nachgeladen → 60 Minuten', async () => { + st.prices.set('price_s', price('price_s', 'webklar_mailspeicher_5gb_monatlich_100', 100)) + const proration = (amount) => ({ + amount, + pricing: { type: 'price_details', price_details: { price: 'price_s' } }, + parent: { type: 'subscription_item_details', subscription_item_details: { proration: true } }, + }) + const prorations = Array.from({ length: 10 }, (_, i) => proration(10 + i)) + st.invoices.set('in_11', inv('in_11', 'sub_w', [...prorations, regular('price_w', 5000)])) + const before = st.calls.length + const r = await grantCreditForInvoice(payload('in_11', 'sub_w', 5000)) + assert.equal(r.granted, true) + assert.equal(aw.docs('hourCredits').get('in_11').minutes, 60) + const calls = st.calls.slice(before) + assert.equal(calls.filter((c) => c.method === 'GET' && c.path === '/v1/invoices/in_11').length, 1) + assert.ok(calls.some((c) => c.method === 'GET' && c.path === '/v1/invoices/in_11/lines'), 'weitere Zeilen ueber /lines geladen') +}) + +test('invoiceLines: eingebettete Zeilen ohne has_more, sonst alle Seiten; loadInvoice liefert alle Zeilen', async () => { + const embedded = { id: 'in_egal', lines: { data: [{ amount: 1 }, { amount: 2 }], has_more: false } } + const before = st.calls.length + assert.deepEqual(await invoiceLines(embedded), [{ amount: 1 }, { amount: 2 }]) + assert.equal(st.calls.length, before, 'kein Stripe-Aufruf ohne has_more') + const loaded = await loadInvoice('in_11') + assert.equal(loaded.lines.data.length, 11) + assert.equal(loaded.lines.has_more, false) + assert.equal(loaded.lines.data[10].amount, 5000) + // Vorschau-Rechnungen haben keine ID: has_more laesst sich dann nicht nachladen, die eingebetteten zaehlen + assert.deepEqual(await invoiceLines({ lines: { data: [{ amount: 3 }], has_more: true } }), [{ amount: 3 }]) +}) + +// F18: der Webhook laedt die Rechnung einmal (loadInvoice) und reicht sie als fresh weiter - kein zweiter Abruf +test('Schon frisch geladene Rechnung (fresh: true) wird nicht noch einmal geladen', async () => { + st.invoices.set('in_7', inv('in_7', 'sub_w', [regular('price_w', 5000)])) + const fresh = await loadInvoice('in_7') + const before = st.calls.length + const r = await grantCreditForInvoice(fresh, { fresh: true }) + assert.equal(r.granted, true) + assert.equal(aw.docs('hourCredits').get('in_7').minutes, 60) + assert.equal(aw.docs('hourCredits').get('in_7').note, 'Rechnung WK-in_7') + assert.deepEqual(st.calls.slice(before).filter((c) => c.path.startsWith('/v1/invoices')), []) +}) diff --git a/test/mailStorage.test.js b/test/mailStorage.test.js new file mode 100644 index 0000000..420a25a --- /dev/null +++ b/test/mailStorage.test.js @@ -0,0 +1,719 @@ +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' + +// 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 }) +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 } = + 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 app = await startTestApp({ '/api/abo': aboRoutes }) +after(() => 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_mailStorage', price('price_mailStorage', config.mailStorage.lookupKey, config.mailStorage.amountCents)) +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)) + +const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000) +const PERIOD_END_UNIX = unix(hostingDoc.currentPeriodEnd) + +async function clearEvents(customerId) { + for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id) +} + +/** + * 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). + */ +async function resetSubH({ units = 0, paidUnits = null, patch = {}, stripe = {} } = {}) { + await clearEvents('c1') + aw.seed('subscriptions', { + ...hostingDoc, + storageUnits: units, + storagePaidUnits: paidUnits ?? 0, + storagePaidPeriodEnd: paidUnits === null ? null : hostingDoc.currentPeriodEnd, + ...patch, + }) + st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', storageUnits: units, ...stripe })) +} + +// Rechnungszeilen in der Form der SDK-API-Version (2025-10-29.clover) +const line = (priceId, amount, { quantity = 1, proration = false, subscription = 'sub_h', periodStart = null, periodEnd = PERIOD_END_UNIX } = {}) => ({ + amount, + quantity, + period: { start: periodStart ?? unix(hostingDoc.startedAt), end: periodEnd }, + 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), +}) +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') + +// --------------------------------------------------------------------------- +// Reine Funktionen: Plan und bezahlte Menge +// --------------------------------------------------------------------------- + +test('Plan: erstes Plus anteilig', () => { + assert.deepEqual(planStorageChange({ currentUnits: 0, paidUnits: 0, newUnits: 2, maxUnits: 10 }), { + direction: 'plus', + steps: [{ quantity: 2, proration_behavior: 'create_prorations' }], + }) +}) + +test('Plan: Minus ohne Erstattung', () => { + assert.deepEqual(planStorageChange({ currentUnits: 3, paidUnits: 3, newUnits: 1, maxUnits: 10 }).steps, [ + { quantity: 1, proration_behavior: 'none' }, + ]) + assert.equal(planStorageChange({ currentUnits: 3, paidUnits: 3, newUnits: 1, maxUnits: 10 }).direction, 'minus') +}) + +test('Plan: erneutes Plus bis zur bezahlten Menge kostet nichts', () => { + assert.deepEqual(planStorageChange({ currentUnits: 1, paidUnits: 3, newUnits: 3, maxUnits: 10 }).steps, [ + { quantity: 3, proration_behavior: 'none' }, + ]) + assert.deepEqual(planStorageChange({ currentUnits: 1, paidUnits: 3, newUnits: 5, maxUnits: 10 }).steps, [ + { quantity: 3, proration_behavior: 'none' }, + { quantity: 5, proration_behavior: 'create_prorations' }, + ]) + // Bezahlt weniger als aktuell (z. B. altes Dokument ohne Buchung): ein Schritt anteilig + assert.deepEqual(planStorageChange({ currentUnits: 2, paidUnits: 0, newUnits: 3, maxUnits: 10 }).steps, [ + { quantity: 3, proration_behavior: 'create_prorations' }, + ]) +}) + +test('Plan: Grenzen', () => { + for (const n of [-1, 11, 1.5, '2', null, undefined, NaN]) { + assert.throws(() => planStorageChange({ currentUnits: 0, paidUnits: 0, newUnits: n, maxUnits: 10 }), (e) => e.status === 400, String(n)) + } + assert.equal(planStorageChange({ currentUnits: 2, paidUnits: 2, newUnits: 2, maxUnits: 10 }).direction, 'none') + assert.deepEqual(planStorageChange({ currentUnits: 2, paidUnits: 2, newUnits: 2, maxUnits: 10 }).steps, []) + assert.equal(planStorageChange({ currentUnits: 0, paidUnits: 0, newUnits: 10, maxUnits: 10 }).direction, 'plus') + assert.equal(planStorageChange({ currentUnits: 1, paidUnits: 1, newUnits: 0, maxUnits: 10 }).direction, 'minus') +}) + +test('Bezahlte Menge gilt nur im laufenden Zeitraum', () => { + assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'A' }), 3) + assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'B' }), 1) + // Appwrite gibt Datumswerte mit +00:00 zurueck, Stripe-Spiegel schreibt Z: gleicher Zeitpunkt zaehlt + assert.equal( + paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: '2026-10-18T10:00:00.000+00:00', currentPeriodEnd: '2026-10-18T10:00:00.000Z' }), + 3 + ) + // Ohne Buchung (null) oder ohne Attribut (Altbestand) gilt die aktuelle Menge + assert.equal(paidUnitsOf({ storageUnits: 2, storagePaidUnits: 0, storagePaidPeriodEnd: null, currentPeriodEnd: null }), 2) + assert.equal(paidUnitsOf({ storageUnits: 2 }), 2) + assert.equal(paidUnitsOf({}), 0) +}) + +// --------------------------------------------------------------------------- +// Vorschau +// --------------------------------------------------------------------------- + +test('Vorschau Plus: anteiliger Betrag aus der Stripe-Vorschau, Gesamtgröße, nächste Abbuchung', async () => { + 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 }) + ) + const before = st.calls.length + const p = await previewStorageChange('c1', 2) + assert.equal(p.direction, 'plus') + assert.equal(p.units, 2) + assert.equal(p.totalGb, 15) + assert.equal(p.monthlyCents, 200) + assert.equal(p.deltaMonthlyCents, 200) + assert.equal(p.prorationCents, 133) + assert.equal(p.nextChargeAt, hostingDoc.currentPeriodEnd) + assert.ok(Math.abs(new Date(p.effectiveAt).getTime() - Date.now()) < 5000, 'wirksam jetzt') + const [call] = previewCalls(before) + assert.ok(call, 'invoices.createPreview aufgerufen') + assert.equal(call.body['subscription'], 'sub_h') + // Noch kein Speicher-Posten: Vorschau mit Preis statt Posten-ID + assert.equal(call.body['subscription_details[items][0][price]'], 'price_mailStorage') + assert.equal(call.body['subscription_details[items][0][quantity]'], '2') + assert.equal(call.body['subscription_details[proration_behavior]'], 'create_prorations') + assert.match(call.body['subscription_details[proration_date]'], /^\d{10}$/) + // Keine Aenderung bei Stripe durch eine Vorschau + assert.deepEqual(subscriptionUpdates(before), []) +}) + +test('Vorschau Plus mit Posten: Gutschrift und Belastung der Speicher-Zeilen, ä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 }) + ) + const before = st.calls.length + const p = await previewStorageChange('c1', 3) + assert.equal(p.prorationCents, 133) + assert.equal(p.deltaMonthlyCents, 200) + assert.equal(p.totalGb, 20) + const [call] = previewCalls(before) + assert.equal(call.body['subscription_details[items][0][id]'], 'si_sub_h_speicher') + assert.equal(call.body['subscription_details[items][0][price]'], undefined) + assert.equal(call.body['subscription_details[items][0][quantity]'], '3') +}) + +test('Vorschau Minus: keine Stripe-Vorschau, wirksam zum Periodenende, keine Erstattung', async () => { + await resetSubH({ units: 3 }) + const before = st.calls.length + const p = await previewStorageChange('c1', 1) + assert.deepEqual(p, { + direction: 'minus', + units: 1, + totalGb: 10, + monthlyCents: 100, + deltaMonthlyCents: -200, + prorationCents: 0, + nextChargeAt: hostingDoc.currentPeriodEnd, + effectiveAt: hostingDoc.currentPeriodEnd, + }) + assert.deepEqual(previewCalls(before), []) +}) + +test('Vorschau Minus bei gekündigtem Abo: wirksam mit dem Abo-Ende, wenn das früher liegt', async () => { + const cancelAt = new Date(new Date(hostingDoc.currentPeriodEnd).getTime() - 86400000).toISOString() + await resetSubH({ units: 3, patch: { cancelAtPeriodEnd: false, cancelAt } }) + 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 () => { + await resetSubH({ units: 1, paidUnits: 3 }) + const now = Math.floor(Date.now() / 1000) + // 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 }) + ) + const before = st.calls.length + const p = await previewStorageChange('c1', 5) + // 266 × (5 − max(1, 3)) / (5 − 1) = 133 + assert.equal(p.prorationCents, 133) + assert.equal(p.direction, 'plus') + assert.equal(p.deltaMonthlyCents, 400) + assert.equal(previewCalls(before)[0].body['subscription_details[items][0][quantity]'], '5') + + // Plus bis zur bezahlten Menge: nichts anteilig, keine Vorschau bei Stripe + const before2 = st.calls.length + const p2 = await previewStorageChange('c1', 3) + assert.equal(p2.prorationCents, 0) + assert.equal(p2.direction, 'plus') + assert.equal(p2.deltaMonthlyCents, 200) + assert.deepEqual(previewCalls(before2), []) +}) + +test('Vorschau: unveränderte Menge ohne Stripe-Aufruf, Grenzen 400, kein Abo 404', async () => { + await resetSubH({ units: 2 }) + const before = st.calls.length + const p = await previewStorageChange('c1', 2) + assert.equal(p.direction, 'none') + assert.equal(p.prorationCents, 0) + assert.equal(p.deltaMonthlyCents, 0) + assert.equal(st.calls.length, before) + await assert.rejects(previewStorageChange('c1', 11), (e) => e.status === 400) + await assert.rejects(previewStorageChange('c1', '2'), (e) => e.status === 400) + await assert.rejects(previewStorageChange('c3', 1), (e) => e.status === 404) +}) + +// --------------------------------------------------------------------------- +// Aenderung: Plus +// --------------------------------------------------------------------------- + +test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => { + await resetSubH({ units: 0 }) // Nachbauten: sub_h active, ohne Speicher + const before = st.calls.length + const result = await changeStorage('c1', 2, consent) + const upd = st.calls.filter((c) => c.path === '/v1/subscriptions/sub_h' && c.method === 'POST') + assert.equal(upd.at(-1).body['proration_behavior'], 'create_prorations') + assert.equal(upd.at(-1).body['items[0][quantity]'], '2') + 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) + + // Erste Position: mit Preis angelegt, nicht mit Posten-ID; anteilig ab jetzt (proration_date) + assert.equal(subscriptionUpdates(before).length, 1) + assert.equal(upd.at(-1).body['items[0][price]'], 'price_mailStorage') + 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(ev.subscriptionId, 'sub_h') + assert.equal(ev.customerId, 'c1') + assert.equal(ev.productKey, 'hosting') + assert.equal(ev.termsVersion, consent.termsVersion) + assert.equal(ev.consentAt, consent.consentAt) + assert.equal(ev.consentIp, '127.0.0.1') + assert.equal(ev.consentUserAgent, 'test') + assert.equal(ev.earlyStartConsentAt ?? null, null) + assert.equal(ev.details.fromUnits, 0) + assert.equal(ev.details.toUnits, 2) + assert.equal(ev.details.totalGb, 15) + assert.equal(ev.details.monthlyCents, 200) + assert.ok(Math.abs(new Date(ev.dueAt).getTime() - Date.now()) < 5000, 'faellig jetzt') + assert.equal(ev.details.effectiveAt, ev.dueAt) + // Rueckgabe + 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 } + ) +}) + +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 upd = subscriptionUpdates(before) + assert.equal(upd.length, 2) + assert.equal(upd[0].body['items[0][id]'], 'si_sub_h_speicher') + assert.equal(upd[0].body['items[0][quantity]'], '3') + assert.equal(upd[0].body['proration_behavior'], 'none') + assert.equal(upd[0].body['proration_date'], undefined) + assert.equal(upd[1].body['items[0][id]'], 'si_sub_h_speicher') + 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) +}) + +test('Plus bis zur bezahlten Menge: ein Schritt ohne Verrechnung, bezahlte Menge bleibt', async () => { + await resetSubH({ units: 1, paidUnits: 3 }) + const before = st.calls.length + 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) +}) + +test('Plus ohne Zustimmung: 400; gekündigt: 409', async () => { + await assert.rejects(changeStorage('c1', 3, 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) +}) + +test('Plus ohne Zustimmung nennt die Regeln zum Zusatzspeicher, ohne Stripe-Aufruf', async () => { + await resetSubH({ units: 0 }) + const before = st.calls.length + await assert.rejects(changeStorage('c1', 1, null), (e) => e.status === 400 && /Regeln zum Zusatzspeicher/.test(e.message)) + await assert.rejects(changeStorage('c1', 1, {}), (e) => e.status === 400) + assert.equal(st.calls.length, before) + assert.deepEqual(await listContractEvents('c1'), []) +}) + +test('Keine Änderung bei trialing, past_due, cancelAt oder offenem Wechsel (409)', async () => { + for (const patch of [ + { status: 'trialing', trialEnd: '2026-11-01T05:00:00.000Z', startChoice: 'nach_frist' }, + { status: 'past_due' }, + { cancelAtPeriodEnd: false, cancelAt: '2027-10-05T10:00:00.000Z' }, + ]) { + await resetSubH({ units: 1, patch }) + const before = st.calls.length + await assert.rejects(changeStorage('c1', 2, consent), (e) => e.status === 409, JSON.stringify(patch)) + await assert.rejects(changeStorage('c1', 0, null), (e) => e.status === 409, JSON.stringify(patch)) + assert.equal(st.calls.length, before, 'kein Stripe-Aufruf') + } + // F12: solange ein Wechsel auf die Zahlung wartet, keine Speicheraenderung (wie canChangeStorage) + await resetSubH({ units: 1 }) + await recordContractEvent({ + customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', + details: { from: 'hosting' }, consent, status: 'offen', + }) + await assert.rejects(changeStorage('c1', 2, consent), (e) => e.status === 409 && /Wechsel/.test(e.message)) + await clearEvents('c1') +}) + +test('Grenzen: 11 Pakete oder unveränderte Menge 400, kein Abo 404, ohne Stripe-Aufruf', async () => { + await resetSubH({ units: 2 }) + const before = st.calls.length + await assert.rejects(changeStorage('c1', 11, consent), (e) => e.status === 400) + await assert.rejects(changeStorage('c1', 2, consent), (e) => e.status === 400 && /unverändert/.test(e.message)) + await assert.rejects(changeStorage('c3', 1, consent), (e) => e.status === 404) + assert.equal(st.calls.length, before) +}) + +// --------------------------------------------------------------------------- +// Aenderung: Minus +// --------------------------------------------------------------------------- + +// Rechtsrunde 28.09.2026: ein Minus hat kein Pflicht-Haekchen, das Ereignis traegt keine Zustimmungsdaten +test('Minus: sofort bei Stripe ohne Erstattung, Aufgabe zum Periodenende, keine Zustimmungsdaten', async () => { + await resetSubH({ units: 3, paidUnits: 3 }) + const before = st.calls.length + const result = await changeStorage('c1', 1, null) + const upd = subscriptionUpdates(before) + assert.equal(upd.length, 1) + assert.equal(upd[0].body['items[0][id]'], 'si_sub_h_speicher') + assert.equal(upd[0].body['items[0][quantity]'], '1') + assert.equal(upd[0].body['proration_behavior'], 'none') + assert.equal(upd[0].body['proration_date'], undefined) + const [ev] = await listContractEvents('c1') + assert.equal(ev.type, 'speicher') + assert.equal(ev.status, 'offen') + assert.equal(ev.dueAt, hostingDoc.currentPeriodEnd) + 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(result.change.direction, 'minus') + assert.equal(result.change.effectiveAt, hostingDoc.currentPeriodEnd) + + // Plus zurueck auf 3 im selben Monat: ohne Verrechnung + const before2 = st.calls.length + await changeStorage('c1', 3, consent) + assert.equal(subscriptionUpdates(before2).length, 1) + assert.equal(subscriptionUpdates(before2)[0].body['proration_behavior'], 'none') +}) + +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) + const before2 = st.calls.length + await changeStorage('c1', 4, consent) + const upd = subscriptionUpdates(before2) + assert.equal(upd[0].body['items[0][id]'], 'si_sub_h_speicher') + assert.equal(upd[0].body['items[0][price]'], undefined) + assert.equal(storageItem().quantity, 4) + 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 () => { + 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) + 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 + await recordContractEvent({ + customerId: 'c2', subscriptionId: 'sub_h2', type: 'speicher', productKey: 'hosting', + details: { fromUnits: 0, toUnits: 1 }, consent, status: 'offen', dueAt: new Date().toISOString(), + }) + await changeStorage('c1', 2, consent) + 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 () => { + await resetSubH({ + units: 2, + patch: { cancelAtPeriodEnd: true, cancelAt: hostingDoc.currentPeriodEnd }, + stripe: { cancel_at_period_end: true }, + }) + 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)') + + 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') + } finally { + console.error = original + } +}) + +test('Bezahlte Menge nicht speicherbar: Stripe-Änderung bleibt, Ereignis und Aufruf gelingen, Fehler im Log', async () => { + await resetSubH({ units: 0 }) + const errors = [] + const original = console.error + console.error = (...args) => errors.push(args.join(' ')) + try { + // Erste PATCH ist der Spiegel (syncSubscription), die zweite die bezahlte Menge - nur die scheitert + aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 400, after: 1, message: 'Invalid document structure: Unknown attribute: "storagePaidUnits"' }) + const result = await changeStorage('c1', 2, consent) + 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.ok(errors.some((e) => /Bezahlte Menge/.test(e) && /sub_h/.test(e) && /400/.test(e))) + assert.equal((await listContractEvents('c1')).length, 1) + } finally { + console.error = original + } +}) + +test('Ereignis nicht speicherbar: Stripe-Änderung bleibt, Aufruf gelingt, Fehler im Log', 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.ok(errors.some((e) => /Ereignis/.test(e) && /sub_h/.test(e))) + assert.deepEqual(await listContractEvents('c1'), []) + } finally { + console.error = original + } +}) + +// --------------------------------------------------------------------------- +// Bezahlte Menge aus Rechnungen (Webhook invoice.paid) +// --------------------------------------------------------------------------- + +test('invoice.paid: bezahlte Menge aus regulärer Speicher-Zeile', async () => { + await resetSubH({ units: 3 }) + await recordPaidStorage({ + lines: { + data: [ + { + amount: 300, + quantity: 3, + period: { end: 1793000000 }, + pricing: { price_details: { price: 'price_mailStorage' } }, + parent: { type: 'subscription_item_details', subscription_item_details: { proration: false, subscription: 'sub_h' } }, + }, + ], + }, + }) + const doc = aw.docs('subscriptions').get('sub_h') + 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 () => { + await resetSubH({ units: 2, paidUnits: 2 }) + const r1 = await recordPaidStorage({ id: 'in_x', lines: { data: [line('price_mailStorage', 90, { quantity: 3, proration: true })] } }) + 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) +}) + +test('invoice.paid: Spiegel fehlt noch → aus Stripe angelegt, dann gebucht', async () => { + const subId = 'sub_1SD6yStorageNeuAbCdEfGhIj' + st.subscriptions.set(subId, stripeSubscription({ id: subId, customerId: 'c2', productKey: 'hosting', storageUnits: 1 })) + 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()) + } finally { + await deleteDocument(config.collections.subscriptions, subId).catch(() => {}) + } +}) + +// 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 () => { + st.subscriptions.set('sub_w', stripeSubscription({ id: 'sub_w', customerId: 'c1', productKey: 'wartung', storageUnits: 2 })) + const now = Math.floor(Date.now() / 1000) + // 10 anteilige Speicher-Zeilen (Aenderungen im Monat) stehen vor den regulaeren Zeilen + const prorations = Array.from({ length: 10 }, (_, i) => line('price_mailStorage', 10 + i, { proration: true, subscription: 'sub_w', periodStart: now - i * 60 })) + st.invoices.set('in_w12', { + id: 'in_w12', + object: 'invoice', + number: 'WK-12', + created: now, + status_transitions: { paid_at: now }, + parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_w' } }, + lines: { + object: 'list', + data: [...prorations, line('price_wartung', 5000, { subscription: 'sub_w', periodEnd: 1793000000 }), line('price_mailStorage', 200, { quantity: 2, subscription: 'sub_w', periodEnd: 1793000000 })], + }, + }) + const before = st.calls.length + try { + const res = await postWebhook({ + id: 'evt_in_w12', + object: 'event', + type: 'invoice.paid', + created: now, + data: { object: { id: 'in_w12', object: 'invoice', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_w' } } } }, + }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const calls = st.calls.slice(before) + 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()) + } finally { + await deleteDocument(config.collections.subscriptions, 'sub_w').catch(() => {}) + await deleteDocument(config.collections.hourCredits, 'in_w12').catch(() => {}) + } +}) + +// --------------------------------------------------------------------------- +// Routen +// --------------------------------------------------------------------------- + +test('Routen: ohne Session 401, im Modus „Als Kunde ansehen“ 403, ohne Stripe-Aufruf', async () => { + await resetSubH({ units: 0 }) + const before = st.calls.length + assert.equal((await post('/api/abo/storage/preview', { units: 2 })).status, 401) + assert.equal((await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion })).status, 401) + assert.equal((await post('/api/abo/storage/preview', { units: 2 }, { cookie: viewAsCookie() })).status, 403) + assert.equal((await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie: viewAsCookie() })).status, 403) + assert.equal(st.calls.length, before) + assert.equal((await rawGet('/api/abo/storage')).status, 404) +}) + +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) })) + 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') + assert.equal(res.body.prorationCents, 133) + assert.equal(res.body.totalGb, 15) + assert.equal(res.body.nextChargeAt, hostingDoc.currentPeriodEnd) + assert.equal((await post('/api/abo/storage/preview', { units: 11 }, { cookie: customerCookie() })).status, 400) + assert.equal((await post('/api/abo/storage/preview', { units: '2' }, { cookie: customerCookie() })).status, 400) + assert.equal((await post('/api/abo/storage/preview', {}, { cookie: customerCookie() })).status, 400) +}) + +test('Route Änderung: Plus braucht acceptTerms und die aktuelle Fassung, Antwort mit Abo-Ansicht', async () => { + await resetSubH({ units: 0 }) + const cookie = customerCookie() + const before = st.calls.length + const noTerms = await post('/api/abo/storage', { units: 2 }, { cookie }) + assert.equal(noTerms.status, 400) + assert.match(noTerms.body.error, /Regeln zum Zusatzspeicher/) + const stale = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: '2026-09-25-v3' }, { cookie }) + assert.equal(stale.status, 409) + assert.equal(subscriptionUpdates(before).length, 0) + + const plus = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie }) + assert.equal(plus.status, 200, JSON.stringify(plus.body)) + assert.deepEqual(plus.body.subscription.storage, { units: 2, pendingSetup: true, pendingReduction: null }) + 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.toUnits, 2) + assert.equal(subscriptionUpdates(before).length, 1) + const [ev] = await listContractEvents('c1') + assert.equal(ev.termsVersion, config.abo.termsVersion) + assert.equal(ev.consentIp, '127.0.0.1') + assert.ok(ev.consentAt) + + // Minus ohne Haekchen: Speichern; Antwort zeigt 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, + pendingReduction: { units: 1, effectiveAt: minus.body.subscription.currentPeriodEnd }, + }) + assert.equal(minus.body.change.direction, 'minus') + assert.equal(minus.body.change.effectiveAt, minus.body.subscription.currentPeriodEnd) + + // Gekuendigt: 409 mit Fehlertext + aw.seed('subscriptions', { ...hostingDoc, storageUnits: 1, cancelAtPeriodEnd: true }) + const cancelled = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie }) + assert.equal(cancelled.status, 409) + assert.ok(cancelled.body.error) + await clearEvents('c1') +}) diff --git a/test/products.test.js b/test/products.test.js index 17b7c25..fdabc81 100644 --- a/test/products.test.js +++ b/test/products.test.js @@ -1,9 +1,20 @@ import { test } from 'node:test' import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import path from 'node:path' const { config } = await import('../server/config.js') -const { getProduct, productKeyForLookupKey, describeItems, priceIdsOfInvoice, creditMinutesForInvoice, termsDocFor, productView } = - await import('../server/services/products.js') +const { + getProduct, + productKeyForLookupKey, + describeItems, + priceIdsOfInvoice, + creditMinutesForInvoice, + termsDocFor, + productView, + contractAttachmentFor, + regularStorageLine, +} = await import('../server/services/products.js') const line = (price, amount, proration = false) => ({ amount, @@ -161,3 +172,27 @@ test('Produktansicht: Domain-Zeile nennt die Endungen', () => { assert.ok(flat.includes('.de') && flat.includes('.com') && flat.includes('.eu') && flat.includes('.net') && flat.includes('.org')) } }) + +// F22 / Task 12: Vertragsunterlagen als Mail-Anhang in der Fassung, der der Kunde zugestimmt hat +test('contractAttachmentFor: Datei je Fassung mit absolutem Pfad unter public/', () => { + const v3 = contractAttachmentFor('2026-09-25-v3') + const v4 = contractAttachmentFor('2026-10-05-v4') + assert.equal(v3.filename, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf') + assert.equal(v4.filename, 'WEBklar-Abo-Vertragsunterlagen.pdf') + assert.ok(path.isAbsolute(v4.path) && v4.path.endsWith(path.join('public', v4.filename))) + assert.ok(existsSync(v4.path), v4.path) + assert.ok(existsSync(v3.path), v3.path) + assert.deepEqual(contractAttachmentFor(null), v3) + assert.deepEqual(Object.keys(v4).sort(), ['filename', 'path']) +}) + +// Task 8: die regulaere Zusatzspeicher-Zeile einer bezahlten Rechnung (Stueckzahl des Monats), nie eine anteilige +test('regularStorageLine: reguläre Zusatzspeicher-Zeile, keine anteilige, kein anderes Produkt', () => { + const inv = (...lines) => ({ lines: { data: lines } }) + const storage = { ...line('price_s', 300), quantity: 3 } + assert.equal(regularStorageLine(inv(line('price_s', 90, true), line('price_w', 5000), storage), keys), storage) + assert.equal(regularStorageLine(inv(line('price_s', 90, true)), keys), null) + assert.equal(regularStorageLine(inv(line('price_w', 5000), line('price_h', 1500)), keys), null) + assert.equal(regularStorageLine(inv({ amount: 5 }), keys), null) + assert.equal(regularStorageLine({}, keys), null) +}) diff --git a/test/support.test.js b/test/support.test.js index 756a132..337a366 100644 --- a/test/support.test.js +++ b/test/support.test.js @@ -48,6 +48,12 @@ test('Appwrite-Nachbau: failNext liefert einmal den gewuenschten Fehler', async aw.failNext({ collection: 'hourCredits', method: 'POST', status: 404 }) assert.equal((await getDocument('subscriptions', 'sub_fail')).$id, 'sub_fail') await assert.rejects(createDocument('hourCredits', { customerId: 'c9' }, 'hc_1'), (e) => e.status === 404) + // after: so viele passende Anfragen gehen vorher noch durch (Task 8: erst die zweite PATCH scheitert). + // adminFetch bildet jeden 5xx-Status auf status 500 ab. + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 503, after: 1 }) + assert.equal((await getDocument('subscriptions', 'sub_fail')).$id, 'sub_fail') + await assert.rejects(getDocument('subscriptions', 'sub_fail'), (e) => e.status === 500 && /503/.test(e.message)) + assert.equal((await getDocument('subscriptions', 'sub_fail')).$id, 'sub_fail') }) test('Stripe-Nachbau: getStripe spricht mit 127.0.0.1', async () => { @@ -64,3 +70,94 @@ test('useTestEnv lehnt fremde Hosts ab', () => { assert.throws(() => useTestEnv({ appwriteUrl: 'https://ticket.webklar.com/v1', stripeUrl: st.url })) assert.equal(new URL(process.env.APPWRITE_ENDPOINT).hostname, '127.0.0.1') }) + +// F17 (Task 8): POST /v1/subscriptions/:id mit Positionen (anlegen per Preis, aendern per ID), Metadaten +// (leerer Wert loescht wie bei Stripe) und einmaligem Zusatz nextSubscriptionUpdate fuer die Antwort +test('Stripe-Nachbau: Abo-Update mit Positionen, Metadaten, einmaliger Zusatz', async () => { + const stripe = getStripe() + st.prices.set('price_a', { id: 'price_a', object: 'price', active: true, lookup_key: 'lookup_a', unit_amount: 100 }) + st.subscriptions.set('sub_u', { + id: 'sub_u', + object: 'subscription', + status: 'active', + metadata: { keep: '1', drop: 'x' }, + items: { object: 'list', data: [{ id: 'si_1', object: 'subscription_item', quantity: 1, price: { id: 'price_0', object: 'price' } }] }, + }) + const added = await stripe.subscriptions.update('sub_u', { + items: [{ price: 'price_a', quantity: 2 }], + metadata: { drop: '', neu: 'y' }, + proration_behavior: 'none', + }) + assert.equal(added.items.data.length, 2) + const item = added.items.data[1] + assert.equal(item.quantity, 2) + assert.equal(item.price.id, 'price_a') + assert.equal(item.price.lookup_key, 'lookup_a') + assert.match(item.id, /^si_/) + assert.deepEqual(added.metadata, { keep: '1', neu: 'y' }) + assert.equal(added.status, 'active') + assert.equal(st.calls.at(-1).body['items[0][price]'], 'price_a') + + const changed = await stripe.subscriptions.update('sub_u', { items: [{ id: item.id, quantity: 0 }] }) + assert.equal(changed.items.data[1].quantity, 0) + assert.equal((await stripe.subscriptions.retrieve('sub_u')).items.data[1].quantity, 0, 'gespeichert') + + // Wie Stripe: derselbe Preis nicht zweimal, unbekannter Preis oder Posten 400/404, unbekanntes Abo 404 + await assert.rejects(stripe.subscriptions.update('sub_u', { items: [{ price: 'price_a', quantity: 1 }] }), (e) => e.statusCode === 400) + await assert.rejects(stripe.subscriptions.update('sub_u', { items: [{ price: 'price_gibt_es_nicht', quantity: 1 }] }), (e) => e.statusCode === 400) + await assert.rejects(stripe.subscriptions.update('sub_u', { items: [{ id: 'si_gibt_es_nicht', quantity: 1 }] }), (e) => e.statusCode === 400) + await assert.rejects(stripe.subscriptions.update('sub_gibt_es_nicht', { metadata: { a: 'b' } }), (e) => e.statusCode === 404) + + // Kuendigungsfelder wie cancelAtPeriodEnd/cancelSubscriptionAt sie setzen + const cancelled = await stripe.subscriptions.update('sub_u', { cancel_at_period_end: true, metadata: { cancelSource: 'test' } }) + assert.equal(cancelled.cancel_at_period_end, true) + assert.equal(cancelled.metadata.cancelSource, 'test') + const dated = await stripe.subscriptions.update('sub_u', { cancel_at: 1800000000, proration_behavior: 'none' }) + assert.equal(dated.cancel_at, 1800000000) + + // nextSubscriptionUpdate: einmalig in die naechste Antwort gemischt, nicht gespeichert + 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' } }) + 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) +}) + +// F17: GET /v1/invoices/:id bettet wie Stripe hoechstens 10 Zeilen ein, GET /v1/invoices/:id/lines blaettert +test('Stripe-Nachbau: Rechnung bettet höchstens 10 Zeilen ein, /lines blättert', async () => { + const stripe = getStripe() + st.invoices.set('in_p', { id: 'in_p', object: 'invoice', lines: { object: 'list', data: Array.from({ length: 12 }, (_, i) => ({ amount: i })) } }) + const invoice = await stripe.invoices.retrieve('in_p') + assert.equal(invoice.lines.data.length, 10) + assert.equal(invoice.lines.has_more, true) + assert.ok(invoice.lines.data.every((l) => l.id && l.object === 'line_item')) + const page = await stripe.invoices.listLineItems('in_p', { limit: 5 }) + assert.equal(page.data.length, 5) + assert.equal(page.has_more, true) + const all = await stripe.invoices.listLineItems('in_p', { limit: 5 }).autoPagingToArray({ limit: 100 }) + assert.deepEqual(all.map((l) => l.amount), [...Array(12).keys()]) + const small = await stripe.invoices.retrieve('in_p') // unveraendert seedbar: die Quelle behaelt alle Zeilen + assert.equal(small.lines.data.length, 10) + st.invoices.set('in_q', { id: 'in_q', object: 'invoice', lines: { object: 'list', data: [{ amount: 1 }] } }) + assert.equal((await stripe.invoices.retrieve('in_q')).lines.has_more, false) + await assert.rejects(stripe.invoices.listLineItems('in_nix'), (e) => e.statusCode === 404) +}) + +// F17: POST /v1/invoices/create_preview liefert die vorab hinterlegte Vorschau (Objekt oder Funktion des Aufrufs) +test('Stripe-Nachbau: Rechnungsvorschau aus dem Seed', async () => { + const stripe = getStripe() + const params = { subscription: 'sub_u', subscription_details: { items: [{ id: 'si_1', quantity: 2 }], proration_behavior: 'create_prorations', proration_date: 1 } } + const empty = await stripe.invoices.createPreview(params) + assert.deepEqual(empty.lines.data, []) + st.invoicePreview = { object: 'invoice', lines: { object: 'list', data: [{ amount: 42 }], has_more: false } } + assert.equal((await stripe.invoices.createPreview(params)).lines.data[0].amount, 42) + assert.equal(st.calls.at(-1).body['subscription_details[items][0][quantity]'], '2') + assert.equal(st.calls.at(-1).body['subscription_details[proration_behavior]'], 'create_prorations') + st.invoicePreview = (body) => ({ object: 'invoice', lines: { data: [{ amount: Number(body.subscription_details.items[0].quantity) * 100 }] } }) + assert.equal((await stripe.invoices.createPreview(params)).lines.data[0].amount, 200) + await assert.rejects(stripe.invoices.createPreview({ subscription: 'sub_nix' }), (e) => e.statusCode === 404) + st.invoicePreview = null +}) diff --git a/test/support/appwriteMock.js b/test/support/appwriteMock.js index a6650b4..968c81d 100644 --- a/test/support/appwriteMock.js +++ b/test/support/appwriteMock.js @@ -5,8 +5,9 @@ import http from 'node:http' * Speicher. Mit schema = { collection: ['attr', ...] } lehnt er unbekannte Attribute ab wie * Appwrite ("Invalid document structure: Unknown attribute"). Dokument-IDs prüft er wie Appwrite * (höchstens 36 Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich, kein Sonderzeichen am - * Anfang). failNext({ collection?, method?, status, message? }) lässt die nächste passende Anfrage - * mit diesem Status scheitern (Fehlerpfade wie "Appwrite nicht erreichbar" oder "Collection fehlt"). + * Anfang). failNext({ collection?, method?, status, message?, after? }) lässt die nächste passende Anfrage + * mit diesem Status scheitern (Fehlerpfade wie "Appwrite nicht erreichbar" oder "Collection fehlt"); + * after = Zahl passender Anfragen, die vorher noch durchgehen (z. B. erst die zweite PATCH scheitert). */ export async function startAppwriteMock({ schema = {} } = {}) { const collections = new Map() @@ -104,9 +105,13 @@ export async function startAppwriteMock({ schema = {} } = {}) { (!pendingFailure.collection || pendingFailure.collection === collection) && (!pendingFailure.method || pendingFailure.method === req.method) ) { - const { status, message } = pendingFailure - pendingFailure = null - return [status, { message: message || `Nachbau: eingeplanter Fehler ${status}`, code: status, type: 'general_server_error' }] + if (pendingFailure.after > 0) { + pendingFailure.after -= 1 + } else { + const { status, message } = pendingFailure + pendingFailure = null + return [status, { message: message || `Nachbau: eingeplanter Fehler ${status}`, code: status, type: 'general_server_error' }] + } } if (!id && req.method === 'GET') return list(collection, url.searchParams.getAll('queries[]')) @@ -155,9 +160,9 @@ export async function startAppwriteMock({ schema = {} } = {}) { url: `http://127.0.0.1:${server.address().port}/v1`, docs: (collection) => new Map([...docs(collection)].map(([id, doc]) => [id, publicDoc(doc)])), seed, - /** Naechste passende Dokument-Anfrage scheitert mit status (einmalig). */ - failNext: ({ collection = null, method = null, status = 500, message = '' }) => { - pendingFailure = { collection, method, status, message } + /** Naechste passende Dokument-Anfrage scheitert mit status (einmalig); after passende Anfragen gehen vorher durch. */ + failNext: ({ collection = null, method = null, status = 500, message = '', after = 0 }) => { + pendingFailure = { collection, method, status, message, after } }, close: () => new Promise((resolve) => server.close(resolve)), } diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index d305e39..43de0c0 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -4,6 +4,12 @@ import http from 'node:http' * Stripe-Nachbau für Tests. stripe-node spricht ihn über config.stripe.apiBase (STRIPE_API_BASE) * an. Objekte werden vorab in die Maps gelegt; jeder Aufruf landet in calls als * { method, path, query, body } (Formular-Schlüssel flach, z. B. 'items[0][price]'). + * + * 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 + * 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). */ export async function startFakeStripe() { const subscriptions = new Map() @@ -16,11 +22,18 @@ export async function startFakeStripe() { const checkoutSessions = new Map() const calls = [] let seq = 0 + let nextSubscriptionUpdate = null + let invoicePreview = null const nextId = (prefix) => `${prefix}_test_${(seq += 1)}` 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 } }] const listOf = (data) => ({ object: 'list', data, has_more: false, url: '/v1/list' }) + // Rechnungszeilen mit IDs (fuer starting_after), wie Stripe sie liefert; die Quelle behaelt alle Zeilen + const invoiceLinesOf = (invoiceId) => + (invoices.get(invoiceId)?.lines?.data || []).map((line, i) => ({ id: `li_${invoiceId}_${i + 1}`, object: 'line_item', ...line })) + const EMBEDDED_LINES = 10 const readBody = (req) => new Promise((resolve) => { @@ -53,6 +66,56 @@ export async function startFakeStripe() { if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) { return subscriptions.has(m[1]) ? [200, subscriptions.get(m[1])] : noSuch('subscription', m[1]) } + if (method === 'POST' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) { + const existing = subscriptions.get(m[1]) + if (!existing) return noSuch('subscription', m[1]) + const data = unflatten(body) + const items = (existing.items?.data || []).map((item) => ({ ...item })) + for (const [i, entry] of (data.items || []).entries()) { + if (entry.id) { + // Vorhandene Position: Menge/Preis aendern oder entfernen + const index = items.findIndex((item) => item.id === entry.id) + if (index < 0) return invalid(`items[${i}][id]`, `No such subscription_item: '${entry.id}'`) + if (entry.deleted === 'true') { + items.splice(index, 1) + continue + } + if (entry.price) { + if (!prices.has(entry.price)) return invalid(`items[${i}][price]`, `No such price: '${entry.price}'`) + items[index].price = prices.get(entry.price) + } + if (entry.quantity !== undefined) items[index].quantity = Number(entry.quantity) + } else if (entry.price) { + // Neue Position per Preis; wie Stripe: derselbe Preis hoechstens einmal je Abo + if (!prices.has(entry.price)) return invalid(`items[${i}][price]`, `No such price: '${entry.price}'`) + if (items.some((item) => item.price?.id === entry.price)) { + return invalid(`items[${i}][price]`, 'You cannot combine multiple subscription items with the same price.') + } + items.push({ + id: nextId('si'), + object: 'subscription_item', + quantity: entry.quantity !== undefined ? Number(entry.quantity) : 1, + price: prices.get(entry.price), + }) + } else { + return invalid(`items[${i}]`, 'Each subscription item must have an id or a price.') + } + } + // Metadaten wie bei Stripe zusammenfuehren: leerer Wert loescht den Schluessel + const metadata = { ...(existing.metadata || {}) } + for (const [key, value] of Object.entries(data.metadata || {})) { + if (value === '') delete metadata[key] + else metadata[key] = value + } + 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 + nextSubscriptionUpdate = null + return [200, response] + } if (method === 'GET' && (m = path.match(/^\/v1\/prices\/([^/]+)$/))) { return prices.has(m[1]) ? [200, prices.get(m[1])] : noSuch('price', m[1]) } @@ -90,7 +153,32 @@ export async function startFakeStripe() { return [200, obj] } if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) { - return invoices.has(m[1]) ? [200, invoices.get(m[1])] : noSuch('invoice', m[1]) + if (!invoices.has(m[1])) return noSuch('invoice', m[1]) + // Wie Stripe: hoechstens 10 Zeilen eingebettet, weitere ueber GET /v1/invoices/:id/lines (has_more) + const lines = invoiceLinesOf(m[1]) + return [ + 200, + { + ...invoices.get(m[1]), + lines: { object: 'list', data: lines.slice(0, EMBEDDED_LINES), has_more: lines.length > EMBEDDED_LINES, url: `/v1/invoices/${m[1]}/lines` }, + }, + ] + } + if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)\/lines$/))) { + if (!invoices.has(m[1])) return noSuch('invoice', m[1]) + const lines = invoiceLinesOf(m[1]) + const limit = Math.min(Number(query.get('limit')) || 10, 100) + const after = query.get('starting_after') + const afterIndex = after ? lines.findIndex((line) => line.id === after) : -1 + if (after && afterIndex < 0) return invalid('starting_after', `No such line item: '${after}'`) + const start = afterIndex + 1 + return [200, { object: 'list', data: lines.slice(start, start + limit), has_more: start + limit < lines.length, url: `/v1/invoices/${m[1]}/lines` }] + } + if (method === 'POST' && path === '/v1/invoices/create_preview') { + const data = unflatten(body) + if (data.subscription && !subscriptions.has(data.subscription)) return noSuch('subscription', data.subscription) + const preview = typeof invoicePreview === 'function' ? invoicePreview(data) : invoicePreview + return [200, preview || { object: 'invoice', lines: { object: 'list', data: [], has_more: false }, amount_due: 0, subtotal: 0, total: 0 }] } // scripts/stripe-products.mjs --pruefen liest das Produkt zurueck (Beschreibung, Bild) if (method === 'GET' && (m = path.match(/^\/v1\/products\/([^/]+)$/))) { @@ -234,6 +322,20 @@ export async function startFakeStripe() { billingPortalConfigurations, customers, checkoutSessions, + /** Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. pending_update). */ + get nextSubscriptionUpdate() { + return nextSubscriptionUpdate + }, + set nextSubscriptionUpdate(value) { + nextSubscriptionUpdate = value + }, + /** Antwort von POST /v1/invoices/create_preview: Objekt oder Funktion (body) => Objekt; null = leere Vorschau. */ + get invoicePreview() { + return invoicePreview + }, + set invoicePreview(value) { + invoicePreview = value + }, close: () => new Promise((resolve) => server.close(resolve)), } } diff --git a/test/support/fixtures.js b/test/support/fixtures.js index 1320c83..3315ec9 100644 --- a/test/support/fixtures.js +++ b/test/support/fixtures.js @@ -10,7 +10,9 @@ import { config } from '../../server/config.js' */ const DAY_MS = 86400000 -const daysFromNow = (days) => new Date(Date.now() + days * DAY_MS).toISOString() +// Sekundengenau wie Stripe (unix-Zeitstempel): der Spiegel schreibt toIso(unix) und trifft so die +// Fixture-Werte wortgleich (z. B. storagePaidPeriodEnd = currentPeriodEnd nach einem Speicher-Plus) +const daysFromNow = (days) => new Date(Math.floor((Date.now() + days * DAY_MS) / 1000) * 1000).toISOString() export const customers = { c1: { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true },