From 98fa03aad77a473d50dfe36492e49a9f81eb1bc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:53:06 +0000 Subject: [PATCH] =?UTF-8?q?Stripe:=20Katalog=20Terminplaner-Einrichtung=20?= =?UTF-8?q?(149=20=E2=82=AC)=20und=20Checkout=20je=20Funktion=20aus=20dem?= =?UTF-8?q?=20Register?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- scripts/stripe-products.mjs | 7 +- server/config.js | 11 +++ server/services/stripeBilling.js | 44 +++++++++--- test/featureCheckout.test.js | 113 ++++++++++++++++++++++++++++++ test/stripeBilling.test.js | 21 ++++++ test/stripeProductsScript.test.js | 55 +++++++++++++++ 6 files changed, 238 insertions(+), 13 deletions(-) create mode 100644 test/featureCheckout.test.js create mode 100644 test/stripeProductsScript.test.js diff --git a/scripts/stripe-products.mjs b/scripts/stripe-products.mjs index 26925c9..dac61f5 100644 --- a/scripts/stripe-products.mjs +++ b/scripts/stripe-products.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News-Einrichtung als Einmalpreis): legt fehlende + * Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News- und Terminplaner-Einrichtung als Einmalpreise): legt fehlende * Preise an (ensurePrice), haelt die Wartungs-Produktbeschreibung bei Stripe auf dem Katalogtext * (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten"). * Idempotent: vorhandene Preise werden ueber lookup_key gefunden, nicht neu angelegt. @@ -17,7 +17,7 @@ import { config } from '../server/config.js' import { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js' import { PRODUCT_KEYS } from '../server/services/products.js' -const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup'] +export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup'] const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen' export function parseArgs(argv) { @@ -27,9 +27,10 @@ export function parseArgs(argv) { return null } -function catalogEntry(key) { +export function catalogEntry(key) { if (key === 'mailStorage') return config.mailStorage if (key === 'newsSetup') return config.newsSetup + if (key === 'termineSetup') return config.termineSetup return config.products[key] } diff --git a/server/config.js b/server/config.js index 2a57b28..520205c 100644 --- a/server/config.js +++ b/server/config.js @@ -106,6 +106,16 @@ const newsSetup = { priceNickname: 'News-Einrichtung 30 €', } +// Terminplaner: einmalige Einrichtung (Einmalpreis, kein Abo); der Terminplaner selbst laeuft, solange das Wartungs-Abo laeuft +const termineSetup = { + key: 'termineSetup', + name: 'Terminplaner – Einrichtung', + amountCents: 14900, + lookupKey: 'webklar_termine_einrichtung_14900', + oneTime: true, + priceNickname: 'Terminplaner-Einrichtung 149 €', +} + // Ticketsystem (WOMS); eigene Konstante, weil auch die Enable-Banking-Rueckkehradresse darauf aufbaut const womsBaseUrl = (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, '') @@ -313,6 +323,7 @@ export const config = { products, mailStorage, newsSetup, + termineSetup, ustHinweis, // Register der kaufbaren Funktionen (siehe oben) features, diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index 656a073..0fe6f28 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 { featureConfig } from './featureRegistry.js' import { creditMinutesForInvoice, describeItems, getProduct, priceIdsOfInvoice, productKeyForLookupKey } from './products.js' /** @@ -51,19 +52,26 @@ export function isStripeConfigured() { } // Produkt-Metadaten "webklar" je Katalog-Schluessel (F09/Produces: ensurePrice) -const PRODUCT_METADATA = { wartung: 'wartungs-abo', hosting: 'hosting-abo', mailStorage: 'mail-speicher', newsSetup: 'news-einrichtung' } +const PRODUCT_METADATA = { + wartung: 'wartungs-abo', + hosting: 'hosting-abo', + mailStorage: 'mail-speicher', + newsSetup: 'news-einrichtung', + termineSetup: 'termine-einrichtung', +} -/** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, der Zusatzspeicher aus config.mailStorage. */ +/** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, Zusatzspeicher und Einrichtungen aus config. */ function catalogFor(key) { if (key === 'mailStorage') return config.mailStorage if (key === 'newsSetup') return config.newsSetup + if (key === 'termineSetup') return config.termineSetup return getProduct(key) } const priceIdCache = new Map() /** * Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' | - * 'mailStorage' | 'newsSetup' (Einmalpreis). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe + * 'mailStorage' | 'newsSetup' | 'termineSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe * nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog. */ export async function ensurePrice(key) { @@ -639,14 +647,26 @@ export async function createAboCheckout(customer, consent, productKey, offer = n } /** - * Checkout fuer die einmalige Einrichtung einer Funktion (News-Pinnwand, 30 € brutto, kein Abo). Die Sitzung - * verfaellt nach 24 Stunden (Stripe-Hoechstwert; fuenf Minuten Luft fuer die Laufzeit des Aufrufs), die - * Rechnung erzeugt Stripe selbst. Nur die Zuordnung steht in den Metadaten, der Zustimmungswortlaut nicht. + * Checkout fuer die einmalige Einrichtung einer Funktion aus dem Register config.features (News-Pinnwand 30 €, + * Terminplaner 149 €; Einmalpreis, kein Abo). Preis, Rechnungstext und Rueckkehradresse kommen aus dem Register-Eintrag + * zu `key`; ein unbekannter Schluessel ist ein Fehler 400, bevor Stripe angesprochen wird. Die Sitzung verfaellt nach + * 24 Stunden (Stripe-Hoechstwert; fuenf Minuten Luft fuer die Laufzeit des Aufrufs), die Rechnung erzeugt Stripe + * selbst und traegt featureKey und projectId in ihren Metadaten. Nur die Zuordnung steht in den Metadaten der Sitzung, + * der Zustimmungswortlaut nicht. * @returns {Promise<{ id: string, url: string }>} */ export async function createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) { + const feature = featureConfig(key) + if (!feature) { + const err = new Error('Unbekannte Funktion.') + err.status = 400 + throw err + } const stripe = getStripe() - const [priceId, stripeCustomerId] = await Promise.all([ensurePrice('newsSetup'), getOrCreateStripeCustomer(customer)]) + const [priceId, stripeCustomerId] = await Promise.all([ensurePrice(feature.product), getOrCreateStripeCustomer(customer)]) + // Rueckkehr ins Dashboard: bei Funktionen mit returnProjekt (Terminplaner) mit dem Projekt, damit die Seite es oeffnet + const projekt = feature.returnProjekt ? encodeURIComponent(projectId) : '' + const returnUrl = `${config.publicBaseUrl}/dashboard.html#${feature.returnHash}` const params = { mode: 'payment', customer: stripeCustomerId, @@ -658,12 +678,16 @@ export async function createFeatureCheckout(customer, { projectId, key = 'news', customer_update: { address: 'auto', name: 'auto' }, invoice_creation: { enabled: true, - invoice_data: { description: config.newsSetup.name, footer: config.stripe.invoiceFooter }, + invoice_data: { + description: catalogFor(feature.product).name, + footer: config.stripe.invoiceFooter, + metadata: { featureKey: key, projectId }, + }, }, metadata: { customerId: customer.$id, projectId, featureKey: key, ...(consentAt ? { consentAt } : {}) }, expires_at: Math.floor(Date.now() / 1000) + 24 * 60 * 60 - 5 * 60, - success_url: `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`, - cancel_url: `${config.publicBaseUrl}/dashboard.html#news`, + success_url: `${returnUrl}?bestellt=1${projekt ? `&projekt=${projekt}` : ''}`, + cancel_url: `${returnUrl}${projekt ? `?projekt=${projekt}` : ''}`, } let session try { diff --git a/test/featureCheckout.test.js b/test/featureCheckout.test.js new file mode 100644 index 0000000..ac12e7f --- /dev/null +++ b/test/featureCheckout.test.js @@ -0,0 +1,113 @@ +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' + +// createFeatureCheckout (Stripe-Checkout je Funktion aus dem Register config.features), direkter Aufruf gegen den Stripe-Nachbau +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 { config } = await import('../server/config.js') +const { createFeatureCheckout } = await import('../server/services/stripeBilling.js') + +const customer = { $id: 'c1', name: 'Test Kunde', email: 'kunde@example.invalid', code: 'K-1' } +const CONSENT_AT = '2026-10-04T10:00:00.000Z' +const sessionCalls = () => st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions') +const lastCall = () => sessionCalls().at(-1) +const priceOf = (call) => st.prices.get(call.body['line_items[0][price]']) + +test('News: Preis 3000, URLs und Rechnungsdaten wie bisher', async () => { + const created = await createFeatureCheckout(customer, { projectId: 'p1', key: 'news', consentAt: CONSENT_AT }) + assert.match(created.id, /^cs_test_/) + assert.equal(created.url, `https://checkout.stripe.test/c/pay/${created.id}`) + const call = lastCall() + assert.equal(priceOf(call).unit_amount, 3000) + assert.equal(call.body['mode'], 'payment') + assert.equal(call.body['client_reference_id'], 'c1') + assert.equal(call.body['success_url'], `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`) + assert.equal(call.body['cancel_url'], `${config.publicBaseUrl}/dashboard.html#news`) + assert.equal(call.body['invoice_creation[enabled]'], 'true') + assert.equal(call.body['invoice_creation[invoice_data][description]'], 'News-Pinnwand – Einrichtung') + assert.equal(call.body['invoice_creation[invoice_data][footer]'], config.stripe.invoiceFooter) + assert.equal(call.body['invoice_creation[invoice_data][metadata][featureKey]'], 'news') + assert.equal(call.body['invoice_creation[invoice_data][metadata][projectId]'], 'p1') + // Metadaten der Sitzung unveraendert + assert.equal(call.body['metadata[customerId]'], 'c1') + assert.equal(call.body['metadata[projectId]'], 'p1') + assert.equal(call.body['metadata[featureKey]'], 'news') + assert.equal(call.body['metadata[consentAt]'], CONSENT_AT) + // Ablauf in hoechstens 24 Stunden, Karte + SEPA + const now = Math.floor(Date.now() / 1000) + assert.ok(Number(call.body['expires_at']) > now) + assert.ok(Number(call.body['expires_at']) < now + 24 * 60 * 60) + assert.equal(call.body['payment_method_types[0]'], 'card') + assert.equal(call.body['payment_method_types[1]'], 'sepa_debit') +}) + +test('News: ohne consentAt steht kein consentAt in den Metadaten', async () => { + await createFeatureCheckout(customer, { projectId: 'p1' }) + const call = lastCall() + assert.equal(call.body['metadata[featureKey]'], 'news', 'Standardschluessel news') + assert.ok(!('metadata[consentAt]' in call.body)) +}) + +test('News: SEPA nicht verfuegbar -> zweiter Versuch nur mit Karte', async () => { + const before = sessionCalls().length + st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'The payment method type "sepa_debit" is invalid.' }) + const created = await createFeatureCheckout(customer, { projectId: 'p1', key: 'news', consentAt: CONSENT_AT }) + const calls = sessionCalls().slice(before) + assert.equal(calls.length, 2) + assert.equal(calls[0].body['payment_method_types[1]'], 'sepa_debit') + assert.equal(calls[1].body['payment_method_types[0]'], 'card') + assert.ok(!('payment_method_types[1]' in calls[1].body), 'nur Karte') + assert.ok(created.id && created.url) +}) + +test('Termine: Preis 14900, Rechnungsdaten mit featureKey und projectId, URLs mit Projekt', async () => { + const created = await createFeatureCheckout(customer, { projectId: 'p/1', key: 'termine', consentAt: CONSENT_AT }) + assert.equal(created.url, `https://checkout.stripe.test/c/pay/${created.id}`) + const call = lastCall() + assert.equal(priceOf(call).unit_amount, 14900) + assert.equal(priceOf(call).lookup_key, 'webklar_termine_einrichtung_14900') + assert.equal(call.body['invoice_creation[invoice_data][description]'], 'Terminplaner – Einrichtung') + assert.equal(call.body['invoice_creation[invoice_data][footer]'], config.stripe.invoiceFooter) + assert.equal(call.body['invoice_creation[invoice_data][metadata][featureKey]'], 'termine') + assert.equal(call.body['invoice_creation[invoice_data][metadata][projectId]'], 'p/1') + assert.equal(call.body['success_url'], `${config.publicBaseUrl}/dashboard.html#termine?bestellt=1&projekt=p%2F1`) + assert.equal(call.body['cancel_url'], `${config.publicBaseUrl}/dashboard.html#termine?projekt=p%2F1`) + assert.equal(call.body['metadata[customerId]'], 'c1') + assert.equal(call.body['metadata[projectId]'], 'p/1') + assert.equal(call.body['metadata[featureKey]'], 'termine') + assert.equal(call.body['metadata[consentAt]'], CONSENT_AT) + const now = Math.floor(Date.now() / 1000) + assert.ok(Number(call.body['expires_at']) > now) + assert.ok(Number(call.body['expires_at']) < now + 24 * 60 * 60) + assert.equal(call.body['payment_method_types[1]'], 'sepa_debit') + const sessionObj = st.checkoutSessions.get(created.id) + assert.equal(sessionObj.metadata.featureKey, 'termine') +}) + +test('Termine: SEPA nicht verfuegbar -> zweiter Versuch nur mit Karte, gleiche URLs', async () => { + const before = sessionCalls().length + st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'The payment method type "sepa_debit" is invalid.' }) + await createFeatureCheckout(customer, { projectId: 'p2', key: 'termine', consentAt: CONSENT_AT }) + const calls = sessionCalls().slice(before) + assert.equal(calls.length, 2) + assert.ok(!('payment_method_types[1]' in calls[1].body), 'nur Karte') + assert.equal(calls[1].body['success_url'], `${config.publicBaseUrl}/dashboard.html#termine?bestellt=1&projekt=p2`) + assert.equal(calls[1].body['cancel_url'], `${config.publicBaseUrl}/dashboard.html#termine?projekt=p2`) +}) + +test('unbekannter Schluessel: Fehler 400 vor jedem Stripe-Aufruf', async () => { + const before = st.calls.length + for (const key of ['analyse', '__proto__', 'toString', '']) { + await assert.rejects( + createFeatureCheckout(customer, { projectId: 'p1', key, consentAt: CONSENT_AT }), + (err) => err.status === 400 + ) + } + assert.equal(st.calls.length, before, 'kein einziger Stripe-Aufruf') +}) diff --git a/test/stripeBilling.test.js b/test/stripeBilling.test.js index 083fbfc..d880a38 100644 --- a/test/stripeBilling.test.js +++ b/test/stripeBilling.test.js @@ -260,3 +260,24 @@ test('news_einrichtung_einmalpreis', async () => { assert.equal(await ensurePrice('newsSetup'), id) assert.equal(st.calls.slice(again).filter((c) => c.method === 'POST').length, 0) }) + +test('termine_einrichtung_einmalpreis', async () => { + const before = st.calls.length + const id = await ensurePrice('termineSetup') + const calls = st.calls.slice(before) + const created = calls.filter((c) => c.method === 'POST' && c.path === '/v1/prices') + assert.equal(created.length, 1) + assert.equal(created[0].body['unit_amount'], '14900') + assert.equal(created[0].body['lookup_key'], 'webklar_termine_einrichtung_14900') + assert.equal(created[0].body['nickname'], 'Terminplaner-Einrichtung 149 €') + assert.ok(!Object.keys(created[0].body).some((k) => k.startsWith('recurring')), 'kein recurring') + assert.equal(st.prices.get(id).recurring, null) + const products = calls.filter((c) => c.method === 'POST' && c.path === '/v1/products') + assert.equal(products.length, 1) + assert.equal(products[0].body['name'], 'Terminplaner – Einrichtung') + assert.equal(products[0].body['metadata[webklar]'], 'termine-einrichtung') + // zweiter Aufruf: kein zweiter Preis (auch ohne Cache per lookup_key gefunden) + const again = st.calls.length + assert.equal(await ensurePrice('termineSetup'), id) + assert.equal(st.calls.slice(again).filter((c) => c.method === 'POST').length, 0) +}) diff --git a/test/stripeProductsScript.test.js b/test/stripeProductsScript.test.js new file mode 100644 index 0000000..8c4de44 --- /dev/null +++ b/test/stripeProductsScript.test.js @@ -0,0 +1,55 @@ +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' + +// scripts/stripe-products.mjs: der Import darf weder main() starten (kein Stripe-Aufruf, kein process.exit) noch etwas anlegen; +// "--anlegen" macht der Inhaber im Livegang (Block G, Schritt 4). +const aw = await startAppwriteMock() +const st = await startFakeStripe() +after(() => Promise.all([aw.close(), st.close()])) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') + +const realExit = process.exit +let exitCalls = 0 +process.exit = () => { + exitCalls += 1 +} +let script +try { + script = await import('../scripts/stripe-products.mjs') + // main() liefe asynchron an: einen Moment warten, damit ein versehentlicher Start sichtbar wird + await new Promise((resolve) => setTimeout(resolve, 100)) +} finally { + process.exit = realExit +} + +test('Import startet main() nicht: kein Stripe-Aufruf, kein process.exit', () => { + assert.equal(exitCalls, 0) + assert.equal(st.calls.length, 0) +}) + +test('KEYS enthaelt den Terminplaner-Katalogschluessel', () => { + assert.deepEqual(script.KEYS, ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup']) +}) + +test('catalogEntry liefert die Katalogeintraege aus der Konfiguration', () => { + assert.equal(script.catalogEntry('termineSetup'), config.termineSetup) + assert.equal(script.catalogEntry('newsSetup'), config.newsSetup) + assert.equal(script.catalogEntry('mailStorage'), config.mailStorage) + assert.equal(script.catalogEntry('wartung'), config.products.wartung) + for (const key of script.KEYS) assert.ok(script.catalogEntry(key)?.lookupKey, `${key} hat einen lookupKey`) +}) + +test('config.termineSetup: Festwerte', () => { + assert.deepEqual(config.termineSetup, { + key: 'termineSetup', + name: 'Terminplaner – Einrichtung', + amountCents: 14900, + lookupKey: 'webklar_termine_einrichtung_14900', + oneTime: true, + priceNickname: 'Terminplaner-Einrichtung 149 €', + }) +})