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' const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions'), hourCredits: schemaKeys('hourCredits') }, }) const st = await startFakeStripe() after(() => Promise.all([aw.close(), st.close()])) useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { grantCreditForInvoice, loadInvoice, invoiceLines } = await import('../server/services/stripeBilling.js') const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', lookup_key, unit_amount, active: true }) st.prices.set('price_w', price('price_w', 'webklar_wartung_monatlich_5000', 5000)) st.prices.set('price_h', price('price_h', 'webklar_hosting_monatlich_2000', 2000)) const sub = (id, priceObj) => ({ id, object: 'subscription', status: 'active', customer: 'cus_1', start_date: 1790000000, current_period_end: 1792600000, metadata: { webklarCustomerId: 'c1', email: 'k@example.invalid', termsVersion: '2026-09-25-v3' }, items: { object: 'list', data: [{ id: `si_${id}`, object: 'subscription_item', quantity: 1, price: priceObj }] }, }) st.subscriptions.set('sub_w', sub('sub_w', st.prices.get('price_w'))) st.subscriptions.set('sub_h', sub('sub_h', st.prices.get('price_h'))) // Frisch geladene Rechnung in der Form der SDK-API-Version (2025-10-29.clover) const inv = (id, subscription, lines) => ({ id, object: 'invoice', number: `WK-${id}`, created: 1790000000, status_transitions: { paid_at: 1790000100 }, parent: { type: 'subscription_details', subscription_details: { subscription } }, lines: { object: 'list', data: lines }, }) const regular = (priceId, amount) => ({ amount, pricing: { type: 'price_details', price_details: { price: priceId } }, parent: { type: 'subscription_item_details', subscription_item_details: { proration: false } }, }) // Webhook-Nutzlast (Auszug), wie sie heute ankommt const payload = (id, subscription, subtotal) => ({ id, subtotal, created: 1790000000, status_transitions: { paid_at: 1790000100 }, parent: { subscription_details: { subscription } }, }) test('Wartungs-Rechnung: 60 Minuten, genau einmal', async () => { st.invoices.set('in_1', inv('in_1', 'sub_w', [regular('price_w', 5000)])) const r = await grantCreditForInvoice(payload('in_1', 'sub_w', 5000)) assert.equal(r.granted, true) const credit = aw.docs('hourCredits').get('in_1') assert.equal(credit.minutes, 60) assert.equal(credit.customerId, 'c1') assert.equal(credit.grantedAt, new Date(1790000100 * 1000).toISOString()) assert.deepEqual(await grantCreditForInvoice(payload('in_1', 'sub_w', 5000)), { skipped: 'bereits gutgeschrieben' }) }) test('Hosting-Rechnung: keine Stunde', async () => { st.invoices.set('in_2', inv('in_2', 'sub_h', [regular('price_h', 2000)])) assert.deepEqual(await grantCreditForInvoice(payload('in_2', 'sub_h', 2000)), { skipped: 'keine Wartungs-Leistung' }) assert.equal(aw.docs('hourCredits').has('in_2'), false) }) test('Frisch geladene Rechnung zählt, nicht die Webhook-Nutzlast', async () => { // Nutzlast in alter Form (ohne pricing/parent) behauptet Wartung, frisch geladen ist es Hosting st.invoices.set('in_3', inv('in_3', 'sub_h', [regular('price_h', 2000)])) const oldPayload = { id: 'in_3', subscription: 'sub_h', subtotal: 5000, created: 1790000000, status_transitions: { paid_at: 1790000100 }, lines: { data: [{ amount: 5000, price: { id: 'price_w' }, proration: false }] }, } assert.deepEqual(await grantCreditForInvoice(oldPayload), { skipped: 'keine Wartungs-Leistung' }) assert.equal(aw.docs('hourCredits').has('in_3'), false) }) test('Preis-Zuordnung wird zwischengespeichert', async () => { st.invoices.set('in_4', inv('in_4', 'sub_w', [regular('price_w', 5000)])) await grantCreditForInvoice(payload('in_4', 'sub_w', 5000)) assert.equal(aw.docs('hourCredits').get('in_4').minutes, 60) assert.equal(st.calls.filter((c) => c.path === '/v1/prices/price_w').length, 1) }) test('Unbekannte Preis-ID (404) neben Hosting: keine Stunde, kein Absturz', async () => { st.invoices.set('in_5', inv('in_5', 'sub_h', [regular('price_h', 2000), regular('price_weg', 800)])) assert.deepEqual(await grantCreditForInvoice(payload('in_5', 'sub_h', 2300)), { skipped: 'keine Wartungs-Leistung' }) assert.equal(aw.docs('hourCredits').has('in_5'), false) }) test('Altbestand: Abo ohne bekannten Preis bekommt weiter seine Stunde, auch bei alter Nutzlast', async () => { st.prices.set('price_alt', price('price_alt', null, 5000)) st.subscriptions.set('sub_alt', sub('sub_alt', st.prices.get('price_alt'))) st.invoices.set('in_6', inv('in_6', 'sub_alt', [regular('price_alt', 5000)])) const oldPayload = { id: 'in_6', subscription: 'sub_alt', subtotal: 5000, created: 1790000000, status_transitions: { paid_at: 1790000100 }, lines: { data: [{ amount: 5000, price: { id: 'price_alt' }, proration: false }] }, } const r = await grantCreditForInvoice(oldPayload) assert.equal(r.granted, true) assert.equal(aw.docs('hourCredits').get('in_6').minutes, 60) assert.equal(aw.docs('subscriptions').get('sub_alt').productKey, 'wartung') }) // F18 / Hinweis aus dem Review von Plan 1: invoices.retrieve bettet hoechstens 10 Zeilen ein. Anteilige // Zeilen stehen vor den Abo-Zeilen; mehrere Speicheraenderungen in einem Monat schieben die Wartungs-Zeile // hinaus. Der Nachbau bettet wie Stripe 10 Zeilen ein (has_more), der Rest kommt ueber /lines. test('Rechnung mit 11 Zeilen: die Wartungs-Zeile als elfte wird nachgeladen → 60 Minuten', async () => { st.prices.set('price_s', price('price_s', 'webklar_mailspeicher_5gb_monatlich_100', 100)) const proration = (amount) => ({ amount, pricing: { type: 'price_details', price_details: { price: 'price_s' } }, parent: { type: 'subscription_item_details', subscription_item_details: { proration: true } }, }) const prorations = Array.from({ length: 10 }, (_, i) => proration(10 + i)) st.invoices.set('in_11', inv('in_11', 'sub_w', [...prorations, regular('price_w', 5000)])) const before = st.calls.length const r = await grantCreditForInvoice(payload('in_11', 'sub_w', 5000)) assert.equal(r.granted, true) assert.equal(aw.docs('hourCredits').get('in_11').minutes, 60) const calls = st.calls.slice(before) assert.equal(calls.filter((c) => c.method === 'GET' && c.path === '/v1/invoices/in_11').length, 1) assert.ok(calls.some((c) => c.method === 'GET' && c.path === '/v1/invoices/in_11/lines'), 'weitere Zeilen ueber /lines geladen') }) test('invoiceLines: eingebettete Zeilen ohne has_more, sonst alle Seiten; loadInvoice liefert alle Zeilen', async () => { const embedded = { id: 'in_egal', lines: { data: [{ amount: 1 }, { amount: 2 }], has_more: false } } const before = st.calls.length assert.deepEqual(await invoiceLines(embedded), [{ amount: 1 }, { amount: 2 }]) assert.equal(st.calls.length, before, 'kein Stripe-Aufruf ohne has_more') const loaded = await loadInvoice('in_11') assert.equal(loaded.lines.data.length, 11) assert.equal(loaded.lines.has_more, false) assert.equal(loaded.lines.data[10].amount, 5000) // Vorschau-Rechnungen haben keine ID: has_more laesst sich dann nicht nachladen, die eingebetteten zaehlen assert.deepEqual(await invoiceLines({ lines: { data: [{ amount: 3 }], has_more: true } }), [{ amount: 3 }]) }) // F18: der Webhook laedt die Rechnung einmal (loadInvoice) und reicht sie als fresh weiter - kein zweiter Abruf test('Schon frisch geladene Rechnung (fresh: true) wird nicht noch einmal geladen', async () => { st.invoices.set('in_7', inv('in_7', 'sub_w', [regular('price_w', 5000)])) const fresh = await loadInvoice('in_7') const before = st.calls.length const r = await grantCreditForInvoice(fresh, { fresh: true }) assert.equal(r.granted, true) assert.equal(aw.docs('hourCredits').get('in_7').minutes, 60) assert.equal(aw.docs('hourCredits').get('in_7').note, 'Rechnung WK-in_7') assert.deepEqual(st.calls.slice(before).filter((c) => c.path.startsWith('/v1/invoices')), []) })