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') })