import { test, after } from 'node:test' import assert from 'node:assert/strict' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' import { mailSink } from './support/mailSink.js' // Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents'), hourCredits: schemaKeys('hourCredits'), }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) 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, storageTaskKind } = await import('../server/services/mailStorage.js') const { listContractEvents, listOpenStorageTasks, recordContractEvent } = await import('../server/services/contractEvents.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const app = await startTestApp({ '/api/abo': aboRoutes }) // F25: Mails landen in der Senke statt bei SMTP - K13 (Kunde, beim Plus mit PDF) und T13 (Team) sind pruefbar const mails = mailSink() setMailTransportForTests(mails) after(() => { setMailTransportForTests(null) return Promise.all([app.close(), aw.close(), st.close()]) }) seedFixtures(aw) // Preise im Stripe-Nachbau mit den lookup_keys des Katalogs: ensurePrice findet sie, die Fixture // 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) const DAY = 86400 const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' }) const PERIOD_END_LABEL = fmtDate(hostingDoc.currentPeriodEnd) const K13_SUBJECT = 'Ihr E-Mail-Zusatzspeicher – Bestätigung' async function clearEvents(customerId) { for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id) } /** * 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). Leert die Senke. */ async function resetSubH({ units = 0, paidUnits = null, patch = {}, stripe = {} } = {}) { await clearEvents('c1') mails.sent.length = 0 aw.seed('subscriptions', { ...hostingDoc, storageUnits: units, 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') const storageItem = () => st.subscriptions.get('sub_h').items.data.find((i) => i.price.lookup_key === config.mailStorage.lookupKey) const doc = () => aw.docs('subscriptions').get('sub_h') const openTasks = async () => (await listOpenStorageTasks()).filter((t) => t.subscriptionId === 'sub_h') const k13 = () => mails.sent.find((m) => m.subject === K13_SUBJECT) || null const t13 = () => mails.sent.find((m) => m.subject.startsWith('[Speicher]')) || null // --------------------------------------------------------------------------- // Reine Funktionen: Plan, bezahlte Menge, Aufgabenart // --------------------------------------------------------------------------- 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) // Fix 1 (4): frisches Periodenende aus Stripe statt des Spiegels assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'B' }, 'A'), 3) assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'A' }, 'C'), 1) }) // Fix 1 (2): Aufgabenart aus details.kind; aeltere Ereignisse ohne kind (Aufgabe 7) nach der Richtung test('storageTaskKind: details.kind, sonst Richtung', () => { assert.equal(storageTaskKind({ details: { kind: 'einrichten', fromUnits: 3, toUnits: 1 } }), 'einrichten') assert.equal(storageTaskKind({ details: { kind: 'periodenende', fromUnits: 1, toUnits: 2 } }), 'periodenende') assert.equal(storageTaskKind({ details: { fromUnits: 0, toUnits: 2 } }), 'einrichten') assert.equal(storageTaskKind({ details: { fromUnits: 2, toUnits: 1 } }), 'periodenende') assert.equal(storageTaskKind({}), 'periodenende') }) // --------------------------------------------------------------------------- // 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 = (body) => previewInvoice( line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Number(body.subscription_details.proration_date) }), line('price_hosting', 2000), 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), []) }) // Fix 1 (6): nur die Zeilen dieser Vorschau (period.start = proration_date, ±2 s); eine Aenderung von vor // einer Minute steckt schon als offene Rechnungsposition in der naechsten Rechnung und zaehlt nicht mit test('Vorschau Plus mit Posten: Gutschrift und Belastung ab proration_date, ältere anteilige Zeilen zählen nicht', async () => { await resetSubH({ units: 1 }) st.invoicePreview = (body) => { const at = Number(body.subscription_details.proration_date) return previewInvoice( line('price_mailStorage', 40, { quantity: 1, proration: true, periodStart: at - 60 }), line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at - 1 }), line('price_mailStorage', 200, { quantity: 3, proration: true, periodStart: at + 1 }), line('price_hosting', 2000), 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) }) // Fix 1 (1): Abos aus dem Checkout laufen im flexiblen Abrechnungsmodus (billing_mode.type 'flexible', bei // Stripe der Standard). Dort schreibt Stripe den zuletzt berechneten Betrag gut - die Vorschau hat die im // Monat schon bezahlten Pakete also selbst verrechnet und wird unveraendert angezeigt. test('Vorschau Plus in zwei Schritten, flexibler Modus (Standard): Betrag der Stripe-Vorschau unverändert', async () => { await resetSubH({ units: 1, paidUnits: 3 }) // Gutschrift der letzten Berechnung (3 Pakete, -200), Belastung fuer 5 Pakete (333) = 133 fuer die 2 Pakete darueber st.invoicePreview = (body) => { const at = Number(body.subscription_details.proration_date) return previewInvoice( line('price_mailStorage', -200, { quantity: 3, proration: true, periodStart: at }), line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at }), line('price_hosting', 2000), line('price_mailStorage', 500, { quantity: 5 }) ) } const p = await previewStorageChange('c1', 5) assert.equal(p.prorationCents, 133) assert.equal(p.direction, 'plus') assert.equal(p.deltaMonthlyCents, 400) // Ausdruecklich flexible: ebenfalls keine Umrechnung (F19 gilt nur im klassischen Modus) await resetSubH({ units: 1, paidUnits: 3, stripe: { billing_mode: { type: 'flexible' } } }) st.invoicePreview = (body) => { const at = Number(body.subscription_details.proration_date) return previewInvoice( line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at }), line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at }) ) } assert.equal((await previewStorageChange('c1', 5)).prorationCents, 266) }) // F19 (klassischer Modus): Stripe rechnet die Vorschau von der heutigen Stueckzahl (1) aus; bis zur schon // bezahlten Menge (3) laeuft der erste Schritt ohne Verrechnung, berechnet werden nur die 2 Pakete darueber test('Vorschau Plus in zwei Schritten, klassischer Modus: anteilig nur für die Pakete über der bezahlten Menge', async () => { await resetSubH({ units: 1, paidUnits: 3, stripe: { billing_mode: { type: 'classic' } } }) // Vorschau 1 -> 5: Gutschrift fuer 1 Paket (-67), Belastung fuer 5 Pakete (333) = 266 fuer 4 Pakete st.invoicePreview = (body) => { const at = Number(body.subscription_details.proration_date) return previewInvoice( line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at }), line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at }), line('price_hosting', 2000), 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(doc().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 assert.equal(storageItem().quantity, 2) assert.equal(doc().storageUnits, 2) assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) // Ereignis: Nachweis mit Zustimmung, Aufgabe "einrichten" faellig jetzt, Ziel und Wirksamkeit in den details assert.equal(ev.subscriptionId, 'sub_h') assert.equal(ev.customerId, 'c1') assert.equal(ev.productKey, 'hosting') 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.kind, 'einrichten') 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, kind: result.change.kind, fromUnits: result.change.fromUnits, toUnits: result.change.toUnits, partial: result.change.partial }, { direction: 'plus', kind: 'einrichten', fromUnits: 0, toUnits: 2, partial: false } ) // K13 an den Kunden mit den Vertragsunterlagen der zugestimmten Fassung (F22), T13 ans Team assert.equal(mails.sent.length, 2) const customerMail = k13() assert.deepEqual(customerMail.to.map((t) => t.address), ['k@example.invalid']) assert.deepEqual(customerMail.attachments.map((a) => [a.filename, a.contentType]), [['WEBklar-Abo-Vertragsunterlagen.pdf', 'application/pdf']]) assert.ok(customerMail.attachments[0].content.length > 1000, 'PDF-Inhalt angehaengt') for (const text of ['Guten Tag Kunde Eins,', '2 Pakete', '15 GB', '2,00 €', 'gemäß § 19 UStG ohne Umsatzsteuer', 'wird eingerichtet', '14 Tagen widerrufen']) { assert.ok(customerMail.text.includes(text), `K13 ohne "${text}":\n${customerMail.text}`) } assert.doesNotMatch(customerMail.text, /ohne anteilige Erstattung/) const teamMail = t13() assert.equal(teamMail.subject, '[Speicher] einrichten: k@example.invalid') for (const text of ['Kunde Eins', 'sub_h', 'Hosting-Abo: 1 Postfach', '15 GB', '2 Pakete']) { assert.ok(teamMail.text.includes(text), `T13 ohne "${text}":\n${teamMail.text}`) } assert.doesNotMatch(teamMail.text, /Nachweis nicht gespeichert|Schritt 2 fehlgeschlagen/) }) test('Plus in zwei Schritten: erst die bezahlte Menge ohne Verrechnung, dann anteilig', async () => { await resetSubH({ units: 1, paidUnits: 3 }) const before = st.calls.length const result = await changeStorage('c1', 5, consent) const upd = subscriptionUpdates(before) assert.equal(upd.length, 2) assert.equal(upd[0].body['items[0][id]'], 'si_sub_h_speicher') 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}$/) assert.equal(doc().storageUnits, 5) assert.equal(doc().storagePaidUnits, 5) assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) assert.equal(result.change.kind, 'einrichten') assert.equal(result.change.partial, false) }) // Fix 1 (2b): ein Plus bis zur bezahlten Menge kostet nichts und braucht keine Einrichtung - konsoleH steht // noch auf der bezahlten Menge; zum Periodenende gilt dann die neue Zielgroesse statt der frueheren Verringerung test('Plus bis zur bezahlten Menge: ein Schritt ohne Verrechnung, Aufgabe zum Periodenende, kein Einrichten', async () => { await resetSubH({ units: 1, paidUnits: 3 }) const before = st.calls.length const result = await changeStorage('c1', 2, consent) const upd = subscriptionUpdates(before) assert.equal(upd.length, 1) assert.equal(upd[0].body['items[0][quantity]'], '2') assert.equal(upd[0].body['proration_behavior'], 'none') assert.equal(doc().storageUnits, 2) assert.equal(doc().storagePaidUnits, 3) const [ev] = await listContractEvents('c1') assert.equal(ev.status, 'offen') assert.equal(ev.details.kind, 'periodenende') assert.equal(ev.details.toUnits, 2) assert.equal(ev.dueAt, hostingDoc.currentPeriodEnd) assert.equal(ev.termsVersion, consent.termsVersion, 'Bestellung mit Zustimmung') assert.equal(result.change.kind, 'periodenende') assert.ok(Math.abs(new Date(result.change.effectiveAt).getTime() - Date.now()) < 5000, 'fuer den Kunden sofort wirksam') assert.equal(t13().subject, `[Speicher] reduzieren zum ${PERIOD_END_LABEL}: k@example.invalid`) // Fix 2 (3): K13 als Bestellung (PDF, Widerruf), aber ohne Einrichtung/anteilige Berechnung - schon bezahlt const customerMail = k13() assert.equal(customerMail.attachments.length, 1, 'K13 mit PDF (Bestellung)') for (const text of ['schon bezahlt', 'keine zusätzlichen Kosten', '2 Pakete', '14 Tagen widerrufen', 'Vertragsunterlagen im Anhang']) { assert.ok(customerMail.text.includes(text), `K13 ohne "${text}":\n${customerMail.text}`) } assert.doesNotMatch(customerMail.text, /wird eingerichtet|anteilig|ohne anteilige Erstattung/) }) // Fix 2 (5): ein Plus genau zurueck auf die bezahlte Menge - konsoleH steht schon darauf, zum Periodenende ist // nichts zu tun: die offene Verringerung wird geschlossen, keine neue Aufgabe, das Ereignis bleibt als Nachweis test('Plus genau zurück auf die bezahlte Menge: Verringerung aufgehoben, keine neue Aufgabe, Nachweis bleibt', async () => { await resetSubH({ units: 3, paidUnits: 3 }) await changeStorage('c1', 1, null) const [minusEvent] = await listContractEvents('c1') mails.sent.length = 0 const before = st.calls.length const result = await changeStorage('c1', 3, consent) assert.equal(subscriptionUpdates(before)[0].body['proration_behavior'], 'none') assert.equal(doc().storageUnits, 3) assert.deepEqual(await openTasks(), []) const events = await listContractEvents('c1') const superseded = events.find((e) => e.$id === minusEvent.$id) assert.equal(superseded.status, 'erledigt') assert.equal(superseded.doneBy, 'ersetzt') const plusEvent = events.find((e) => e.$id === result.event.$id) assert.equal(plusEvent.status, 'erledigt') assert.equal(plusEvent.doneBy, 'keine Aufgabe') assert.ok(plusEvent.doneAt) assert.equal(plusEvent.details.kind, 'periodenende') assert.equal(plusEvent.details.toUnits, 3) assert.equal(plusEvent.termsVersion, consent.termsVersion, 'Nachweis der Bestellung') assert.equal(result.change.task, false) // K13 bestaetigt die Bestellung (nichts berechnet), T13 entfaellt: nichts zu tun assert.equal(mails.sent.length, 1) assert.ok(k13().text.includes('keine zusätzlichen Kosten')) assert.equal(t13(), null) }) // Fix 2 (1): weichen Spiegel und Stripe ab, wird der Plan nach dem Abgleich erneut geprueft - ein Plus ohne // Zustimmung oder ueber das Kuendigungsformular darf nicht durchrutschen test('Spiegel und Stripe weichen ab: Plan nach dem Abgleich erneut geprüft (400/409), nichts angelegt', async () => { await resetSubH({ units: 3 }) // Spiegel sagt 3 Pakete, Stripe nur 1: aus dem vermeintlichen Minus auf 2 wird ein Plus st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', storageUnits: 1 })) const before = st.calls.length await assert.rejects(changeStorage('c1', 2, null), (e) => e.status === 400 && /zustimmen/.test(e.message)) assert.deepEqual(await listContractEvents('c1'), [], 'kein Ereignis') assert.deepEqual(subscriptionUpdates(before), [], 'keine Stripe-Aenderung') assert.deepEqual(mails.sent, []) assert.equal(storageItem().quantity, 1) await assert.rejects(changeStorage('c1', 2, null, { viaCancellation: true }), (e) => e.status === 409) assert.deepEqual(await listContractEvents('c1'), []) assert.deepEqual(subscriptionUpdates(before), []) // Mit Zustimmung geht das Plus, mit dem frischen Stand aus Stripe const result = await changeStorage('c1', 2, consent) assert.equal(result.change.fromUnits, 1) assert.equal(result.change.kind, 'einrichten') assert.equal(doc().storageUnits, 2) }) test('Plus ohne Zustimmung: 400; gekündigt: 409', async () => { await assert.rejects(changeStorage('c1', 4, null), (e) => e.status === 400) aw.seed('subscriptions', { ...hostingDoc, $id: 'sub_h', cancelAtPeriodEnd: true }) await assert.rejects(changeStorage('c1', 4, 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'), []) assert.deepEqual(mails.sent, []) }) 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.equal(ev.details.kind, 'periodenende') assert.deepEqual([ev.details.fromUnits, ev.details.toUnits, ev.details.effectiveAt], [3, 1, hostingDoc.currentPeriodEnd]) assert.equal(ev.consentIp ?? null, null) assert.equal(ev.consentUserAgent ?? null, null) assert.equal(ev.termsVersion ?? null, null) assert.equal(ev.consentAt ?? null, null) // Spiegel: Stripe-Menge sofort 1, bezahlt bleiben 3 fuer diesen Monat (ein Plus zurueck auf 3 kostet nichts) assert.equal(doc().storageUnits, 1) assert.equal(doc().storagePaidUnits, 3) assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) assert.equal(result.change.direction, 'minus') assert.equal(result.change.kind, 'periodenende') assert.equal(result.change.effectiveAt, hostingDoc.currentPeriodEnd) // K13 ohne Anhang mit dem Hinweis zum Monatsende, T13 "reduzieren zum" const customerMail = k13() assert.deepEqual(customerMail.attachments, []) for (const text of [`zum ${PERIOD_END_LABEL}`, 'ohne anteilige Erstattung', 'keine neuen E-Mails', '10 GB', '1,00 €']) { assert.ok(customerMail.text.includes(text), `K13 ohne "${text}":\n${customerMail.text}`) } assert.doesNotMatch(customerMail.text, /wird eingerichtet|14 Tagen widerrufen/) assert.equal(t13().subject, `[Speicher] reduzieren zum ${PERIOD_END_LABEL}: k@example.invalid`) assert.ok(t13().text.includes(PERIOD_END_LABEL)) // Plus zurueck auf 3 im selben Monat: ohne Verrechnung const before2 = st.calls.length await changeStorage('c1', 3, consent) assert.equal(subscriptionUpdates(before2).length, 1) assert.equal(subscriptionUpdates(before2)[0].body['proration_behavior'], 'none') }) // Fix 1 (4): jede Position war fuer den laufenden Monat berechnet - ein Minus haelt das fest, auch ohne // fruehere Buchung (Altbestand); ein Plus zurueck bis dahin kostet dann nichts test('Minus bucht die bezahlte Menge des Monats (max aus gebucht und aktuell), auch ohne frühere Buchung', async () => { await resetSubH({ units: 3 }) // storagePaidPeriodEnd null: bisher galt nur die aktuelle Menge await changeStorage('c1', 1, null) assert.equal(doc().storagePaidUnits, 3) assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) const before = st.calls.length await changeStorage('c1', 3, consent) assert.equal(subscriptionUpdates(before)[0].body['proration_behavior'], 'none') // Eine hoehere Buchung bleibt beim Minus erhalten await resetSubH({ units: 2, paidUnits: 4 }) await changeStorage('c1', 1, null) assert.equal(doc().storagePaidUnits, 4) }) test('Minus auf 0: Position bleibt mit Menge 0, ein späteres Plus nutzt sie wieder', async () => { await resetSubH({ units: 2, paidUnits: 2 }) const before = st.calls.length await changeStorage('c1', 0, null) assert.equal(subscriptionUpdates(before)[0].body['items[0][quantity]'], '0') assert.equal(storageItem().quantity, 0) assert.equal(doc().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') }) // --------------------------------------------------------------------------- // Aufgaben fuers Team nach Zeitpunkt (Fix 1, 2; F21): hoechstens eine offene Aufgabe je Art und Abo // --------------------------------------------------------------------------- test('Plus noch nicht eingerichtet, dann Minus: Einrichtung bleibt offen, dazu die Verringerung zum Periodenende', async () => { await resetSubH({ units: 0 }) await changeStorage('c1', 2, consent) await changeStorage('c1', 1, null) const tasks = await openTasks() assert.equal(tasks.length, 2) const setup = tasks.find((t) => storageTaskKind(t) === 'einrichten') const reduction = tasks.find((t) => storageTaskKind(t) === 'periodenende') assert.equal(setup.details.toUnits, 2) assert.equal(setup.details.totalGb, 15) assert.ok(Math.abs(new Date(setup.dueAt).getTime() - Date.now()) < 10000) assert.equal(reduction.details.toUnits, 1) assert.equal(reduction.details.totalGb, 10) assert.equal(reduction.dueAt, hostingDoc.currentPeriodEnd) assert.equal((await listContractEvents('c1')).filter((e) => e.status === 'offen').length, 2) }) test('Minus, dann Plus bis zur bezahlten Menge: eine offene Aufgabe zum Periodenende mit der neuen Zielgröße', async () => { await resetSubH({ units: 3, paidUnits: 3 }) await changeStorage('c1', 1, null) const [minusEvent] = await listContractEvents('c1') await changeStorage('c1', 2, consent) const tasks = await openTasks() assert.equal(tasks.length, 1) assert.equal(storageTaskKind(tasks[0]), 'periodenende') assert.equal(tasks[0].details.toUnits, 2) assert.equal(tasks[0].dueAt, hostingDoc.currentPeriodEnd) const superseded = (await listContractEvents('c1')).find((e) => e.$id === minusEvent.$id) assert.equal(superseded.status, 'erledigt') assert.equal(superseded.doneBy, 'ersetzt') assert.ok(superseded.doneAt) assert.deepEqual(superseded.details, minusEvent.details, 'details des ersetzten Ereignisses bleiben') }) test('Plus über die geplante Verringerung hinaus hebt sie auf: nur noch die Einrichtung', async () => { await resetSubH({ units: 3, paidUnits: 3 }) await changeStorage('c1', 1, null) await changeStorage('c1', 5, consent) const tasks = await openTasks() assert.equal(tasks.length, 1) assert.equal(storageTaskKind(tasks[0]), 'einrichten') assert.equal(tasks[0].details.toUnits, 5) const events = await listContractEvents('c1') assert.equal(events.filter((e) => e.status === 'erledigt' && e.doneBy === 'ersetzt').length, 1) }) test('Neues Plus ersetzt die offene Einrichtung; ein fremdes Abo bleibt unberührt', async () => { await resetSubH({ units: 0 }) await changeStorage('c1', 2, consent) const [first] = await listContractEvents('c1') await recordContractEvent({ customerId: 'c2', subscriptionId: 'sub_h2', type: 'speicher', productKey: 'hosting', details: { kind: 'einrichten', fromUnits: 0, toUnits: 1 }, consent, status: 'offen', dueAt: new Date().toISOString(), }) await changeStorage('c1', 3, consent) const tasks = await openTasks() assert.equal(tasks.length, 1) assert.equal(tasks[0].details.toUnits, 3) const superseded = (await listContractEvents('c1')).find((e) => e.$id === first.$id) assert.equal(superseded.status, 'erledigt') assert.equal(superseded.doneBy, 'ersetzt') assert.equal((await listContractEvents('c2'))[0].status, 'offen') await clearEvents('c2') }) // F20 / Rechtsrunde: ueber das Kuendigungsformular (viaCancellation) sind Verringerungen bei jedem // laufenden Status und auch bei gekuendigtem Abo erlaubt, Erhoehungen nie; K13 entfaellt dort (F31) test('Kündigungsweg: Verringerung auch bei gekündigtem Abo, Erhöhung nie, K13 entfällt', async () => { await resetSubH({ units: 2, patch: { cancelAtPeriodEnd: true, cancelAt: hostingDoc.currentPeriodEnd }, stripe: { cancel_at_period_end: true }, }) const result = await changeStorage('c1', 1, null, { viaCancellation: true }) assert.equal(result.change.direction, 'minus') assert.equal(doc().storageUnits, 1) const [ev] = await listContractEvents('c1') assert.equal(ev.type, 'speicher') assert.equal(ev.status, 'offen') assert.equal(ev.details.viaCancellation, true) assert.equal(ev.consentIp ?? null, null) assert.equal(k13(), null, 'K13 entfaellt beim Kuendigungsformular (K03 bestaetigt)') assert.ok(t13(), 'T13 geht trotzdem raus') assert.equal(mails.sent.length, 1) await assert.rejects(changeStorage('c1', 3, consent, { viaCancellation: true }), (e) => e.status === 409) // Der Spiegel bleibt gekuendigt (aus Stripe); ohne viaCancellation bleibt er gesperrt, auch fuer ein Minus assert.equal(doc().cancelAtPeriodEnd, true) await assert.rejects(changeStorage('c1', 0, null), (e) => e.status === 409) // past_due: Verringerung erlaubt; beendetes Abo (canceled): nicht mehr await resetSubH({ units: 2, patch: { status: 'past_due' }, stripe: { status: 'past_due' } }) assert.equal((await changeStorage('c1', 0, null, { viaCancellation: true })).change.toUnits, 0) assert.equal(doc().status, 'past_due') await resetSubH({ units: 2, patch: { status: 'canceled', endedAt: new Date().toISOString() } }) await assert.rejects(changeStorage('c1', 1, null, { viaCancellation: true }), (e) => e.status === 409) }) // --------------------------------------------------------------------------- // Fehlerpfade (Fix 1, 3/7/8): nach der Stripe-Aenderung bricht nichts mehr stillschweigend ab // --------------------------------------------------------------------------- test('Stripe lehnt die Änderung ab: Ereignis gescheitert, Fehler an den Kunden, nichts gebucht, keine Mails', async () => { await resetSubH({ units: 0 }) st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, message: 'No such price: price_weg' }) await assert.rejects(changeStorage('c1', 2, consent), (e) => e.status === 502 && /Stripe/.test(e.message) && /nichts berechnet/.test(e.message)) const events = await listContractEvents('c1') assert.equal(events.length, 1) assert.equal(events[0].status, 'gescheitert') assert.equal(events[0].details.kind, 'einrichten') assert.match(events[0].details.error, /No such price/) assert.equal(events[0].termsVersion, consent.termsVersion, 'Nachweis der Bestellung bleibt') assert.equal(storageItem(), undefined, 'nichts bei Stripe') assert.equal(doc().storageUnits, 0) assert.equal(doc().storagePaidUnits, 0) assert.deepEqual(mails.sent, []) assert.deepEqual(await openTasks(), []) // Danach geht eine neue Aenderung wieder (Sperre freigegeben) assert.equal((await changeStorage('c1', 2, consent)).change.toUnits, 2) }) test('Spiegel nicht aktualisierbar nach der Stripe-Änderung: Aufruf gelingt, Ereignis offen, K13 und T13', async () => { await resetSubH({ units: 0 }) const errors = [] const original = console.error console.error = (...args) => errors.push(args.join(' ')) try { // Erste PATCH nach der Stripe-Aenderung ist der Spiegel (syncSubscription) aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 500 }) const result = await changeStorage('c1', 2, consent) assert.equal(result.subscription.storageUnits, 2, 'Rueckgabe zeigt die neue Menge') assert.equal(result.event.status, 'offen') assert.equal(storageItem().quantity, 2) assert.ok(errors.some((e) => /Spiegel/.test(e) && /sub_h/.test(e))) assert.equal(mails.sent.length, 2) assert.ok(k13() && t13()) } finally { console.error = original } }) // Fix 2 (4): aeltere offene Aufgaben werden nur ersetzt, wenn die neue Aufgabe existiert und Stripe die Aenderung // uebernommen hat - sonst bliebe das Team ohne Aufgabe test('Ältere offene Aufgabe bleibt, wenn das Ereignis nicht speicherbar ist oder Stripe Schritt 1 ablehnt', async () => { await resetSubH({ units: 0 }) await changeStorage('c1', 2, consent) const [first] = await listContractEvents('c1') const original = console.error console.error = () => {} try { aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 }) await changeStorage('c1', 3, consent) let events = await listContractEvents('c1') assert.equal(events.length, 1) assert.equal(events[0].$id, first.$id) assert.equal(events[0].status, 'offen', 'aeltere Aufgabe bleibt offen') assert.equal(storageItem().quantity, 3) st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, message: 'Nachbau: abgelehnt' }) await assert.rejects(changeStorage('c1', 4, consent), (e) => e.status === 502) events = await listContractEvents('c1') assert.equal(events.length, 2) assert.equal(events.find((e) => e.$id === first.$id).status, 'offen', 'aeltere Aufgabe bleibt offen') assert.equal(events.find((e) => e.$id !== first.$id).status, 'gescheitert') assert.equal((await openTasks()).length, 1) } finally { console.error = original } }) test('Ereignis nicht speicherbar: Stripe-Änderung läuft, T13 mit Block nachweis_fehlt', async () => { await resetSubH({ units: 0 }) const errors = [] const original = console.error console.error = (...args) => errors.push(args.join(' ')) try { aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 }) const result = await changeStorage('c1', 1, consent) assert.equal(result.event, null) assert.equal(result.subscription.storageUnits, 1) assert.equal(storageItem().quantity, 1) assert.ok(errors.some((e) => /Ereignis/.test(e) && /sub_h/.test(e))) assert.deepEqual(await listContractEvents('c1'), []) assert.ok(k13(), 'K13 geht raus') assert.match(t13().text, /Nachweis nicht gespeichert/) assert.match(t13().text, /contractEvents/) } finally { console.error = original } }) 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) assert.equal(doc().storageUnits, 2) assert.equal(doc().storagePaidUnits, 0, 'Buchung fehlt, der Rest lief durch') assert.ok(errors.some((e) => /Bezahlte Menge/.test(e) && /sub_h/.test(e) && /400/.test(e))) assert.equal((await listContractEvents('c1')).length, 1) assert.equal(mails.sent.length, 2) } finally { console.error = original } }) // Fix 1 (7): scheitert Schritt 2 nach Schritt 1, bleibt die Bestellung offen (partial), das Team setzt die // Menge in Stripe von Hand; nichts wird stillschweigend verworfen und dem Kunden nichts Falsches gemeldet test('Zwei Schritte, zweiter scheitert: Menge bleibt bei der bezahlten, Ereignis offen mit partial, T13 mit Handgriff', async () => { await resetSubH({ units: 1, paidUnits: 3 }) const errors = [] const original = console.error console.error = (...args) => errors.push(args.join(' ')) try { st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, after: 1, message: 'Nachbau: Schritt 2 abgelehnt' }) const result = await changeStorage('c1', 5, consent) assert.equal(result.change.partial, true) assert.equal(result.change.toUnits, 5) assert.equal(storageItem().quantity, 3, 'Schritt 1 gilt') assert.equal(doc().storageUnits, 3) assert.equal(doc().storagePaidUnits, 3, 'nichts ueber die bezahlte Menge hinaus gebucht') const [ev] = await listContractEvents('c1') assert.equal(ev.status, 'offen') assert.equal(ev.details.partial, true) assert.equal(ev.details.toUnits, 5) assert.match(ev.details.error, /Schritt 2 abgelehnt/) assert.ok(errors.some((e) => /Schritt 2/.test(e))) assert.match(t13().text, /Schritt 2 fehlgeschlagen/) assert.match(t13().text, /von Hand auf 5 setzen/) assert.ok(k13(), 'Bestellung bestaetigt') } finally { console.error = original } }) // Fix 1 (8): Doppelklick oder zwei Tabs - die zweite gleichzeitige Aenderung desselben Abos bekommt 409 test('Doppelte Änderung gleichzeitig: die zweite bekommt 409, danach geht es wieder', async () => { await resetSubH({ units: 0 }) const results = await Promise.allSettled([changeStorage('c1', 2, consent), changeStorage('c1', 3, consent)]) const ok = results.filter((r) => r.status === 'fulfilled') const failed = results.filter((r) => r.status === 'rejected') assert.equal(ok.length, 1) assert.equal(failed.length, 1) assert.equal(failed[0].reason.status, 409) assert.match(failed[0].reason.message, /läuft bereits/) assert.equal((await listContractEvents('c1')).length, 1) assert.equal((await changeStorage('c1', 4, consent)).change.toUnits, 4, 'Sperre freigegeben') }) // --------------------------------------------------------------------------- // Bezahlte Menge aus Rechnungen (Webhook invoice.paid) // --------------------------------------------------------------------------- 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' } }, }, ], }, }) 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', 2000)] } }) assert.ok(r2.skipped) assert.equal(doc().storagePaidUnits, 2) assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) }) // Fix 1 (4): eine verspaetete Rechnung des laufenden Monats senkt die nach einem Plus gebuchte Menge nicht, // eine wiederholte aeltere Rechnung setzt das Periodenende nie zurueck, eine neuere Periode setzt neu test('invoice.paid: verspätet nach einem Plus (max), ältere Rechnung (übersprungen), neuere Periode (neu)', async () => { await resetSubH({ units: 5, paidUnits: 5 }) const late = await recordPaidStorage({ id: 'in_late', lines: { data: [line('price_mailStorage', 300, { quantity: 3 })] } }) assert.equal(late.recorded, true) assert.equal(doc().storagePaidUnits, 5) assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) const older = await recordPaidStorage({ id: 'in_alt', lines: { data: [line('price_mailStorage', 300, { quantity: 3, periodEnd: PERIOD_END_UNIX - 30 * DAY })] } }) assert.ok(older.skipped) assert.equal(doc().storagePaidUnits, 5) assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd) const newer = await recordPaidStorage({ id: 'in_neu', lines: { data: [line('price_mailStorage', 300, { quantity: 3, periodEnd: PERIOD_END_UNIX + 30 * DAY })] } }) assert.equal(newer.recorded, true) assert.equal(doc().storagePaidUnits, 3) assert.equal(doc().storagePaidPeriodEnd, new Date((PERIOD_END_UNIX + 30 * DAY) * 1000).toISOString()) }) test('invoice.paid: Spiegel fehlt noch → aus Stripe angelegt, dann gebucht', async () => { 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 created = aw.docs('subscriptions').get(subId) assert.equal(created.customerId, 'c2') assert.equal(created.storageUnits, 1) assert.equal(created.storagePaidUnits, 1) assert.equal(created.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString()) } finally { await deleteDocument(config.collections.subscriptions, subId).catch(() => {}) } }) // Fix 1 (5): wie beim Webhook aus Aufgabe 7 - ein dauerhafter Appwrite-Fehler (4xx) wird protokolliert und // uebersprungen (200), ein voruebergehender (5xx) gibt 500, Stripe wiederholt test('Webhook invoice.paid: Appwrite 4xx → 200 und übersprungen, 5xx → 500', async () => { await resetSubH({ units: 2, paidUnits: 2 }) const now = Math.floor(Date.now() / 1000) st.invoices.set('in_h2', { id: 'in_h2', object: 'invoice', number: 'WK-h2', created: now, status_transitions: { paid_at: now }, parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } }, lines: { object: 'list', data: [line('price_hosting', 2000, { periodEnd: PERIOD_END_UNIX + 30 * DAY }), line('price_mailStorage', 300, { quantity: 3, periodEnd: PERIOD_END_UNIX + 30 * DAY })] }, }) const event = { id: 'evt_in_h2', object: 'event', type: 'invoice.paid', created: now, data: { object: { id: 'in_h2', object: 'invoice', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } } } } } const errors = [] const original = console.error console.error = (...args) => errors.push(args.join(' ')) try { aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 400 }) const permanent = await postWebhook(event) assert.equal(permanent.status, 200, JSON.stringify(permanent.body)) assert.equal(doc().storagePaidUnits, 2, 'uebersprungen') assert.ok(errors.some((e) => /Bezahlte Menge|Speicher/.test(e) && /400/.test(e))) aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 500 }) assert.equal((await postWebhook(event)).status, 500) assert.equal(doc().storagePaidUnits, 2) // Wiederholung ohne Stoerung bucht assert.equal((await postWebhook(event)).status, 200) assert.equal(doc().storagePaidUnits, 3) } finally { console.error = original } }) // F18: der Webhook laedt die Rechnung genau einmal frisch (mit allen Zeilen, auch ueber die 10 eingebetteten // hinaus) und bucht daraus Stundengutschrift und bezahlte Speichermenge test('Webhook invoice.paid: Rechnung einmal frisch geladen, alle Zeilen, Stunde und bezahlte Speichermenge', async () => { 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 mirrored = aw.docs('subscriptions').get('sub_w') assert.equal(mirrored.productKey, 'wartung') assert.equal(mirrored.storageUnits, 2) assert.equal(mirrored.storagePaidUnits, 2) assert.equal(mirrored.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString()) } finally { await deleteDocument(config.collections.subscriptions, 'sub_w').catch(() => {}) await deleteDocument(config.collections.hourCredits, 'in_w12').catch(() => {}) } }) // --------------------------------------------------------------------------- // 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 = (body) => previewInvoice(line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Number(body.subscription_details.proration_date) })) const res = await post('/api/abo/storage/preview', { units: 2 }, { cookie: customerCookie() }) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(res.body.direction, 'plus') 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.kind, 'einrichten') 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; die Einrichtung ist noch offen, dazu die geplante Verringerung const minus = await post('/api/abo/storage', { units: 1 }, { cookie }) assert.equal(minus.status, 200, JSON.stringify(minus.body)) assert.deepEqual(minus.body.subscription.storage, { units: 1, pendingSetup: true, pendingReduction: { units: 1, effectiveAt: minus.body.subscription.currentPeriodEnd }, }) assert.equal(minus.body.change.direction, 'minus') assert.equal(minus.body.change.kind, 'periodenende') assert.equal(minus.body.change.effectiveAt, minus.body.subscription.currentPeriodEnd) // Gekuendigt: 409 mit Fehlertext 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') }) // Fix 1 (3): Stripe-Fehler → 5xx mit Fehlertext, nichts gebucht; Spiegel-Fehler nach Stripe → 200 mit neuer Menge test('Route Änderung: Stripe-Fehler 502 ohne Mails, Spiegel-Fehler nach der Stripe-Änderung 200', async () => { await resetSubH({ units: 0 }) const cookie = customerCookie() st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, message: 'Nachbau: abgelehnt' }) const failed = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie }) assert.equal(failed.status, 502) assert.match(failed.body.error, /Stripe/) assert.deepEqual(mails.sent, []) assert.equal((await listContractEvents('c1'))[0].status, 'gescheitert') const errors = [] const original = console.error console.error = (...args) => errors.push(args.join(' ')) try { aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 500 }) const ok = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie }) assert.equal(ok.status, 200, JSON.stringify(ok.body)) assert.deepEqual(ok.body.subscription.storage, { units: 2, pendingSetup: true, pendingReduction: null }) assert.equal((await listContractEvents('c1')).find((e) => e.status === 'offen').details.toUnits, 2) assert.equal(mails.sent.length, 2) } finally { console.error = original } })