diff --git a/scripts/setup-abo-collections.mjs b/scripts/setup-abo-collections.mjs index a5d21ee..1e0ff0e 100644 --- a/scripts/setup-abo-collections.mjs +++ b/scripts/setup-abo-collections.mjs @@ -117,6 +117,8 @@ await ensureCollection( // Individuelles Angebot, mit dem das Abo abgeschlossen wurde (Nachweis der Preisabrede) { type: 'string', key: 'offerId', size: 64 }, { type: 'string', key: 'offerText', size: 300 }, + // Plan 1: Produkt des Abos (wartung | hosting) + { type: 'string', key: 'productKey', size: 16 }, ], [ { key: 'idx_customer', type: 'key', attributes: ['customerId'] }, diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index f2cd682..d6424a1 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -8,6 +8,7 @@ import { updateDocument, } from './appwriteAdmin.js' import { lateStartAt } from './deadlines.js' +import { describeItems } from './products.js' /** * Wartungs-Abo ueber Stripe Billing. @@ -160,14 +161,21 @@ export async function syncSubscription(subscriptionId) { return null } const customer = typeof sub.customer === 'string' ? null : sub.customer + // Abo-Posten (Hosting/Wartung) unabhängig von der Reihenfolge; ohne bekannten Preis gilt das Abo + // als Wartungs-Abo (Altbestand bzw. von Hand angelegt) + const items = describeItems(sub.items?.data) + if (!items.productKey) { + console.warn(`[stripe] Abo ${sub.id}: kein bekannter Abo-Preis – als Wartungs-Abo gespiegelt`) + } const doc = await upsertSubscriptionDoc(sub.id, { customerId, email: meta.email || customer?.email || '', stripeCustomerId: typeof sub.customer === 'string' ? sub.customer : sub.customer?.id, stripeSubscriptionId: sub.id, status: sub.status, - planKey: config.stripe.priceLookupKey, - priceAmount: sub.items?.data?.[0]?.price?.unit_amount ?? config.stripe.priceAmountCents, + productKey: items.productKey || 'wartung', + planKey: items.lookupKey || config.products.wartung.lookupKey, + priceAmount: items.priceAmount ?? sub.items?.data?.[0]?.price?.unit_amount ?? config.products.wartung.amountCents, startedAt: toIso(sub.start_date), currentPeriodEnd: toIso(periodEndOf(sub)), cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end || sub.cancel_at), diff --git a/test/stripeBilling.test.js b/test/stripeBilling.test.js new file mode 100644 index 0000000..26365f3 --- /dev/null +++ b/test/stripeBilling.test.js @@ -0,0 +1,62 @@ +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') } }) +const st = await startFakeStripe() +after(() => Promise.all([aw.close(), st.close()])) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { syncSubscription } = await import('../server/services/stripeBilling.js') + +const sub = (id, items, extra = {}) => ({ + id, + object: 'subscription', + status: 'active', + customer: 'cus_1', + start_date: 1790000000, + current_period_end: 1792600000, + trial_end: null, + cancel_at_period_end: false, + cancel_at: null, + canceled_at: null, + ended_at: null, + metadata: { webklarCustomerId: 'c1', email: 'k@example.invalid', termsVersion: '2026-09-25-v3' }, + items: { object: 'list', data: items }, + ...extra, +}) +const item = (id, lookup_key, unit_amount, quantity = 1) => ({ + id: `si_${id}`, + object: 'subscription_item', + quantity, + price: { id: `price_${id}`, object: 'price', lookup_key, unit_amount }, +}) + +test('Setup-Skript legt productKey an', () => { + assert.ok(schemaKeys('subscriptions').includes('productKey')) +}) + +test('Spiegel: Hosting-Abo, Zusatzspeicher steht vorne', async () => { + st.subscriptions.set( + 'sub_h', + sub('sub_h', [item('s', 'webklar_mailspeicher_5gb_monatlich_100', 100, 2), item('h', 'webklar_hosting_monatlich_1500', 1500)]) + ) + const doc = await syncSubscription('sub_h') + assert.equal(doc.productKey, 'hosting') + assert.equal(doc.planKey, 'webklar_hosting_monatlich_1500') + assert.equal(doc.priceAmount, 1500) +}) + +test('Spiegel: Wartungs-Abo wie bisher', async () => { + st.subscriptions.set('sub_w', sub('sub_w', [item('w', 'webklar_wartung_monatlich_5000', 5000)])) + const doc = await syncSubscription('sub_w') + assert.deepEqual([doc.productKey, doc.planKey, doc.priceAmount], ['wartung', 'webklar_wartung_monatlich_5000', 5000]) +}) + +test('Spiegel: unbekannter Preis gilt als Wartungs-Abo, Betrag vom ersten Posten', async () => { + st.subscriptions.set('sub_x', sub('sub_x', [item('x', null, 4900)])) + const doc = await syncSubscription('sub_x') + assert.deepEqual([doc.productKey, doc.planKey, doc.priceAmount], ['wartung', 'webklar_wartung_monatlich_5000', 4900]) +}) diff --git a/test/support/schema.js b/test/support/schema.js new file mode 100644 index 0000000..7c133e0 --- /dev/null +++ b/test/support/schema.js @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs' + +/** + * Attribut-Schlüssel einer Collection, wie sie scripts/setup-abo-collections.mjs in Appwrite + * anlegt. Tests geben sie dem Appwrite-Nachbau als Schema: Schreibt der Code ein Feld, das das + * Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in Produktion. + */ +export function schemaKeys(collection) { + const src = readFileSync(new URL('../../scripts/setup-abo-collections.mjs', import.meta.url), 'utf8') + const start = src.indexOf(`'${collection}',`) + if (start < 0) throw new Error(`Collection ${collection} fehlt im Setup-Skript`) + const block = src.slice(start, src.indexOf('\n ],', start)) + return [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1]) +}