diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index d6424a1..1d7bbd0 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -8,7 +8,7 @@ import { updateDocument, } from './appwriteAdmin.js' import { lateStartAt } from './deadlines.js' -import { describeItems } from './products.js' +import { creditMinutesForInvoice, describeItems, priceIdsOfInvoice, productKeyForLookupKey } from './products.js' /** * Wartungs-Abo ueber Stripe Billing. @@ -255,20 +255,44 @@ function subscriptionIdOfInvoice(invoice) { ) } -/** Bezahlte Abo-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). */ +// Preis-ID -> Produkt ('wartung' | 'hosting' | 'mailStorage' | null); Preise ändern ihren lookup_key nicht +const priceKeyCache = new Map() + +/** Produkte zu Preis-IDs; unbekannte IDs einmal bei Stripe nachschlagen (gelöscht/404 -> null). */ +export async function resolvePriceKeys(priceIds) { + const keys = new Map() + for (const id of priceIds) { + if (!priceKeyCache.has(id)) { + const price = await getStripe() + .prices.retrieve(id) + .catch((err) => { + if (err?.statusCode === 404) return null + throw err + }) + priceKeyCache.set(id, productKeyForLookupKey(price?.lookup_key)) + } + keys.set(id, priceKeyCache.get(id)) + } + return keys +} + +/** Bezahlte Wartungs-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). */ export async function grantCreditForInvoice(invoice) { const ref = subscriptionIdOfInvoice(invoice) const subscriptionId = typeof ref === 'string' ? ref : ref?.id if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' } - // subtotal (vor Rabatt und Guthaben) statt amount_paid: Auch ein Monat, den ein Angebot oder eine - // Uebernahmegutschrift ganz deckt, ist ein bezahlter Leistungsmonat. Nur Probezeit-Rechnungen - // haben eine Zwischensumme von 0 € und bringen keine Stunde. - if (!(invoice.subtotal > 0)) return { skipped: 'Betrag 0' } + // Frisch laden: Die Webhook-Nutzlast hat die API-Version des Webhook-Endpunkts, nicht die des SDK. + // Es zählt die reguläre Wartungs-Zeile vor Rabatt und Guthaben: Auch ein Monat, den ein Angebot + // oder eine Übernahmegutschrift ganz deckt, ist ein bezahlter Leistungsmonat. Probezeit (0 €), + // anteilige Verrechnungen, Hosting und Zusatzspeicher bringen keine Stunde. + const fresh = await getStripe().invoices.retrieve(invoice.id) + const minutes = creditMinutesForInvoice(fresh, await resolvePriceKeys(priceIdsOfInvoice(fresh))) + if (!minutes) return { skipped: 'keine Wartungs-Leistung' } const sub = await syncSubscription(subscriptionId) if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' } - const grantedAt = new Date((invoice.status_transitions?.paid_at || invoice.created) * 1000) + const grantedAt = new Date((fresh.status_transitions?.paid_at || fresh.created) * 1000) try { await createDocument( config.collections.hourCredits, @@ -276,11 +300,11 @@ export async function grantCreditForInvoice(invoice) { customerId: sub.customerId, stripeSubscriptionId: subscriptionId, stripeInvoiceId: invoice.id, - minutes: config.abo.minutesPerMonth, + minutes, grantedAt: grantedAt.toISOString(), expiresAt: addMonths(grantedAt, config.abo.creditValidityMonths).toISOString(), source: 'stripe_invoice', - note: invoice.number ? `Rechnung ${invoice.number}` : '', + note: fresh.number ? `Rechnung ${fresh.number}` : '', }, invoice.id ) diff --git a/test/hourCredits.test.js b/test/hourCredits.test.js new file mode 100644 index 0000000..09cef03 --- /dev/null +++ b/test/hourCredits.test.js @@ -0,0 +1,100 @@ +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 } = 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_1500', 1500)) + +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', 1500)])) + assert.deepEqual(await grantCreditForInvoice(payload('in_2', 'sub_h', 1500)), { 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', 1500)])) + 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): keine Stunde, kein Absturz', async () => { + st.invoices.set('in_5', inv('in_5', 'sub_w', [regular('price_weg', 5000)])) + assert.deepEqual(await grantCreditForInvoice(payload('in_5', 'sub_w', 5000)), { skipped: 'keine Wartungs-Leistung' }) + assert.equal(aw.docs('hourCredits').has('in_5'), false) +})