From c4607e9c430fe257de5cc40e75fcc7bb003d8cf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:43:09 +0000 Subject: [PATCH] =?UTF-8?q?Abo-API:=20Bestellung=20je=20Produkt,=20Katalog?= =?UTF-8?q?,=20Speicherstand,=20Wechsel-M=C3=B6glichkeit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- server/routes/abo.js | 143 ++++++++++++-- server/services/stripeBilling.js | 60 +++--- test/aboRoutes.test.js | 309 +++++++++++++++++++++++++++++++ test/support/fakeStripe.js | 42 +++++ test/support/fixtures.js | 153 +++++++++++++++ test/support/testApp.js | 117 ++++++++++++ 6 files changed, 784 insertions(+), 40 deletions(-) create mode 100644 test/aboRoutes.test.js create mode 100644 test/support/fixtures.js create mode 100644 test/support/testApp.js diff --git a/server/routes/abo.js b/server/routes/abo.js index 07c2399..fe82083 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -5,10 +5,12 @@ import { Router } from 'express' import { config } from '../config.js' import { getSessionCustomerId, requireSession } from '../middleware/session.js' import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js' +import { listContractEvents, openWithdrawals, recordContractEvent } from '../services/contractEvents.js' import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js' import { getLedger } from '../services/hourLedger.js' import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js' import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js' +import { PRODUCT_KEYS, productView, termsDocFor } from '../services/products.js' import { ACTIVE_STATUSES, cancelAtPeriodEnd, @@ -45,17 +47,44 @@ function addMonthsIso(iso, months) { return d.toISOString() } +/** Mindestlaufzeit ab dem Wechseltag (termStartAt), sonst ab dem Leistungsbeginn (Probezeit-Ende bzw. Abo-Start). */ function minTermEndOf(sub) { - const start = sub?.trialEnd || sub?.startedAt + const start = sub?.termStartAt || sub?.trialEnd || sub?.startedAt const months = minTermMonthsFor(sub?.termsVersion) return start && months ? addMonthsIso(start, months) : null } -function publicSubscription(sub) { +// Alte Dokumente ohne productKey sind Wartungs-Abos (Spec 6) +const productKeyOf = (sub) => sub?.productKey || 'wartung' + +/** + * Abo fuers Portal. events: Vertragsereignisse des Kunden (listContractEvents); es zaehlen die + * dieses Abos (subscriptionId = Stripe-Abo-ID = Dokument-ID). Daraus kommen der Speicherstand + * (offene Aufgaben), ein Wechsel, der auf die Zahlung wartet (F12), und die Widerrufsfristen je + * Bestellung. Abos von vor Plan 2 haben kein Ereignis abschluss: fuer sie zaehlt die Frist wie + * bisher ab startedAt (F14). + */ +function publicSubscription(sub, events = []) { if (!sub) return null const active = ACTIVE_STATUSES.has(sub.status) - // Fristende 24 Uhr, bei Sa/So/Feiertag am nächsten Werktag (deadlines.js) - const withdrawalUntil = sub.startedAt ? withdrawalDeadline(sub.startedAt).endsAt : null + const productKey = productKeyOf(sub) + const own = events.filter((e) => e.subscriptionId === sub.$id) + const openOf = (type) => own.filter((e) => e.type === type && e.status === 'offen') + const pendingPlus = openOf('speicher').find((e) => e.details?.toUnits > e.details?.fromUnits) + const pendingMinus = openOf('speicher').find((e) => e.details?.toUnits < e.details?.fromUnits) + const pendingSwitch = openOf('wechsel')[0] || null + // Plus/Minus und Wechsel nur bei Status active, ohne Kuendigung und ohne offene Wechselzahlung + const cancelled = Boolean(sub.cancelAtPeriodEnd || sub.cancelAt) + const canChangeStorage = sub.status === 'active' && !cancelled && !pendingSwitch + // Widerrufsfristen je Bestellung (Abschluss, Wechsel, Speicher-Plus), Fristende 24 Uhr, bei + // Sa/So/Feiertag am naechsten Werktag (deadlines.js) + const withdrawals = active ? openWithdrawals(own) : [] + if (active && sub.startedAt && !own.some((e) => e.type === 'abschluss')) { + const until = withdrawalDeadline(sub.startedAt).endsAt + if (until > new Date()) withdrawals.push({ type: 'abschluss', eventId: null, until }) + } + const withdrawalUntil = withdrawals.reduce((latest, w) => (!latest || w.until > latest ? w.until : latest), null) + const terms = termsDocFor(sub.termsVersion) return { status: sub.status, active, @@ -70,11 +99,31 @@ function publicSubscription(sub) { startChoice: sub.startChoice || null, // Beginn nach Ablauf der Widerrufsfrist: Leistung startet mit Ende der Probezeit serviceStartsAt: sub.status === 'trialing' ? sub.trialEnd : null, - // Widerrufsbutton im Portal nur waehrend der Frist hervorheben - withdrawalOpen: Boolean(active && withdrawalUntil && withdrawalUntil > new Date()), - withdrawalUntil: withdrawalUntil ? withdrawalUntil.toISOString() : null, // Individuelles Angebot, mit dem das Abo laeuft (z. B. "Erste 2 Monate je 25,00 € ...") offerText: sub.offerText || null, + productKey, + termsVersion: sub.termsVersion || null, + termStartAt: sub.termStartAt || null, + // Unterlagen in der zugestimmten Fassung (Spec 4.2): Fassung 3 fuer Bestandskunden, sonst Fassung 4 + termsUrl: terms.url, + termsPdfUrl: `/${terms.pdfFile}`, + storage: { + units: Number(sub.storageUnits) || 0, + // Plus bestellt, konsoleH noch nicht umgestellt ("wird eingerichtet") + pendingSetup: Boolean(pendingPlus), + // Minus bestellt, wirkt zum Periodenende ("ab TT.MM. X GB") + pendingReduction: pendingMinus ? { units: pendingMinus.details.toUnits, effectiveAt: pendingMinus.dueAt || null } : null, + }, + canChangeStorage, + canSwitch: canChangeStorage && productKey === 'hosting', + // Wechsel bestellt, Zahlung offen (Karte mit 3-D Secure): Link zur Stripe-Zahlungsseite + switchPending: pendingSwitch + ? { since: pendingSwitch.consentAt || pendingSwitch.$createdAt, paymentUrl: pendingSwitch.details?.paymentUrl || null } + : null, + withdrawals: withdrawals.map((w) => ({ type: w.type, eventId: w.eventId, until: w.until.toISOString() })), + // Widerrufsbutton im Portal waehrend einer laufenden Frist hervorheben + withdrawalOpen: withdrawals.length > 0, + withdrawalUntil: withdrawalUntil ? withdrawalUntil.toISOString() : null, } } @@ -90,28 +139,43 @@ function isPilotAllowed(customer, offer = null) { // Kunde (eingeloggt) // --------------------------------------------------------------------------- -/** Abo-Status + Stundenkonto fuer die Startseite. */ +/** Katalog, Abo-Status mit Speicherstand und Wechsel-Moeglichkeit, Angebote je Produkt, Stundenkonto (nur Wartung). */ router.get('/', requireSession, async (req, res) => { try { const customerId = getSessionCustomerId(req) - const [sub, customer] = await Promise.all([ + const [sub, customer, events] = await Promise.all([ getCurrentSubscription(customerId), getDocument(config.collections.customers, customerId).catch(() => null), + // Ohne lesbare Ereignisse (z. B. Collection noch nicht angelegt) bleibt die Seite nutzbar: die + // Widerrufsfrist des Abschlusses zaehlt dann ab startedAt (F14), offene Aufgaben fehlen + listContractEvents(customerId).catch((e) => { + console.error('[abo] Vertragsereignisse nicht lesbar:', e.message) + return [] + }), ]) const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status)) const [ledger, offer] = await Promise.all([ - sub ? getLedger(customerId, { active }) : null, + // Arbeitszeit gibt es nur im Wartungs-Abo + sub && productKeyOf(sub) === 'wartung' ? getLedger(customerId, { active }) : null, active ? null : getOpenOffer(customerId).catch(() => null), ]) return res.json({ available: isStripeConfigured() && isPilotAllowed(customer, offer), testAccount: config.abo.testEmails.includes(String(customer?.email || '').toLowerCase()), termsVersion: config.abo.termsVersion, - subscription: publicSubscription(sub), - offer: offer ? offerView(offer) : null, - ledger, // Leistungsbeginn bei "Nach Ablauf der Widerrufsfrist" für die Bestellseite lateStartAt: lateStartAt().toISOString(), + products: [productView('hosting'), productView('wartung')], + mailStorage: { + includedGb: config.mailStorage.includedGb, + gbPerUnit: config.mailStorage.gbPerUnit, + unitPriceCents: config.mailStorage.amountCents, + maxUnits: config.mailStorage.maxUnits, + }, + // Angebote gibt es bis Aufgabe 11 nur fuer das Wartungs-Abo + offers: { hosting: null, wartung: offer ? offerView(offer) : null }, + ledger, + subscription: publicSubscription(sub, events), }) } catch (err) { return res.status(500).json({ error: err.message || 'Abo konnte nicht geladen werden' }) @@ -124,7 +188,10 @@ router.post('/checkout', requireSession, async (req, res) => { if (req.session.viewAs) { return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' }) } - const { acceptTerms, startChoice, termsVersion, offerId, lateStartAt: shownLateStart } = req.body || {} + const { productKey, acceptTerms, startChoice, termsVersion, offerId, lateStartAt: shownLateStart } = req.body || {} + if (!PRODUCT_KEYS.includes(productKey)) { + return res.status(400).json({ error: 'Bitte wählen Sie ein Abo (Hosting-Abo oder Wartungs-Abo).' }) + } if (acceptTerms !== true) { return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.' }) } @@ -135,10 +202,16 @@ router.post('/checkout', requireSession, async (req, res) => { return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' }) } const customer = await getDocument(config.collections.customers, getSessionCustomerId(req)) - // Bestellt wird genau der Preis, den die Bestelluebersicht gezeigt hat - const offer = await getOpenOffer(customer.$id) - if ((offer?.$id || '') !== String(offerId || '')) { - return res.status(409).json({ error: 'Ihr Angebot hat sich geändert. Bitte laden Sie die Seite neu.' }) + // Bestellt wird genau der Preis, den die Bestelluebersicht gezeigt hat. Angebote gibt es bis + // Aufgabe 11 nur fuer das Wartungs-Abo; ein Angebot bei einer Hosting-Bestellung passt nie. + let offer = null + if (productKey === 'wartung') { + offer = await getOpenOffer(customer.$id) + if ((offer?.$id || '') !== String(offerId || '')) { + return res.status(409).json({ error: 'Ihr Angebot hat sich geändert. Bitte laden Sie die Seite neu.' }) + } + } else if (offerId) { + return res.status(409).json({ error: 'Ihr Angebot gilt für ein anderes Abo. Bitte laden Sie die Seite neu.' }) } if (!isStripeConfigured() || !isPilotAllowed(customer, offer)) { return res.status(403).json({ error: 'Die Online-Bestellung ist noch nicht freigeschaltet.' }) @@ -160,6 +233,7 @@ router.post('/checkout', requireSession, async (req, res) => { consentIp: req.ip, consentUserAgent: req.get('user-agent') || '', }, + productKey, offer ? { id: offer.$id, stripeCouponId: offer.stripeCouponId, text: offerText(offer) } : null ) return res.json({ url }) @@ -246,12 +320,45 @@ async function sendPortalCancellationConfirmation(sub, createdUnix) { }) } +/** + * Nachweis des Neuabschlusses als Vertragsereignis. Der Vertrag kommt mit dem Abschluss bei Stripe + * zustande, die Widerrufsfrist zaehlt ab dann (details.contractAt = startedAt, F04); die Zustimmung + * (Fassung, Zeitpunkt, IP, Browser) kommt aus den Abo-Metadaten, die der Spiegel traegt. Feste + * Dokument-ID je Abo: eine Webhook-Wiederholung legt kein zweites Ereignis an (F05). + */ +async function recordAbschluss(sub, session) { + return recordContractEvent({ + id: `abschluss-${sub.$id}`, + customerId: sub.customerId, + subscriptionId: sub.$id, + type: 'abschluss', + productKey: productKeyOf(sub), + details: { + contractAt: sub.startedAt, + startChoice: sub.startChoice || null, + serviceStartAt: sub.trialEnd || sub.startedAt, + amountCents: sub.priceAmount ?? null, + checkoutSessionId: session.id || null, + ...(sub.offerId ? { offerId: sub.offerId, offerText: sub.offerText || '' } : {}), + }, + consent: { + termsVersion: sub.termsVersion || null, + consentAt: sub.termsAcceptedAt || null, + consentIp: sub.consentIp || null, + consentUserAgent: sub.consentUserAgent || null, + earlyStartConsentAt: sub.earlyStartConsentAt || null, + }, + }) +} + async function handleStripeEvent(event) { const obj = event.data.object switch (event.type) { case 'checkout.session.completed': { if (obj.mode !== 'subscription' || !obj.subscription) return const sub = await syncSubscription(obj.subscription) + // Ein Fehler hier (etwa Collection fehlt) gibt 500 -> Stripe wiederholt, das Ereignis wird nachgeholt + if (sub) await recordAbschluss(sub, obj) const offerProblem = await activateOfferFromCheckout(obj).then( () => false, (e) => { diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index 1c11b75..15e6754 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -431,34 +431,58 @@ export async function ensureTestCoupon(productKey) { return created.id } +// Preis wie in den Bedingungen: "15,00 €" (normales Leerzeichen, kein geschuetztes wie bei toLocaleString) +const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €` + /** - * Stripe Checkout fuer das Abo. Der Kunde hat seine Bestellung vorher im Portal - * abgegeben ("Zahlungspflichtig bestellen"); hier hinterlegt er die Zahlungsart. + * Hinweis ueber dem Bezahlknopf im Checkout (custom_text): Vertragsschluss, Leistungsbeginn, Preis + * und Mindestlaufzeit des bestellten Produkts; bei einem Angebot der vereinbarte Preis. + */ +function checkoutMessage(product, lateStart, offer) { + const intro = + `Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit ${lateStart ? 'dem Abschluss' : 'der ersten Zahlung'} kommt der Vertrag zustande.` + + (lateStart ? ' Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab.' : '') + const price = offer + ? `Preis wie vereinbart: ${offer.text}.` + : `${lateStart ? 'Danach ' : ''}${fmtEuro(product.amountCents)} monatlich,` + return `${intro} ${price} Mindestlaufzeit ${product.minTermMonths} Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.` +} + +/** + * Stripe Checkout fuer ein Abo (productKey 'wartung' | 'hosting'). Der Kunde hat seine Bestellung + * vorher im Portal abgegeben ("Zahlungspflichtig bestellen"); hier hinterlegt er die Zahlungsart. + * Position, Beschreibung, Hinweistext und Testrabatt richten sich nach dem Produkt. * startChoice: 'sofort' (Leistung und erste Abbuchung sofort) oder 'nach_frist' (Probezeit ohne Leistung * und ohne Abbuchung bis consent.serviceStartAt, dem Tag nach dem Ende der Widerrufsfrist, deadlines.js). * offer: individuelles Angebot { id, stripeCouponId, text } -> Coupon im Checkout; bei Testkonten * bleibt der Testrabatt im Checkout und das Angebot wird nach dem Abschluss angehaengt (offerDeferred). */ -export async function createAboCheckout(customer, consent, offer = null) { +export async function createAboCheckout(customer, consent, productKey, offer = null) { + const product = getProduct(productKey) // unbekanntes Produkt -> 400 + // Hoechstens ein laufendes Abo je Kunde (Spec 7); der Wechsel zwischen den Abos laeuft ueber planSwitch const current = await getCurrentSubscription(customer.$id) if (current && ACTIVE_STATUSES.has(current.status)) { - const err = new Error('Sie haben das Wartungs-Abo bereits abgeschlossen.') + const err = new Error('Sie haben bereits ein laufendes Abo abgeschlossen.') err.status = 409 throw err } const stripe = getStripe() const [priceId, stripeCustomerId] = await Promise.all([ - ensurePrice('wartung'), + ensurePrice(productKey), getOrCreateStripeCustomer(customer), ]) const lateStart = consent.startChoice === 'nach_frist' const serviceStartAt = lateStart ? consent.serviceStartAt || lateStartAt() : null const isTestAccount = config.abo.testEmails.includes(String(customer.email || '').toLowerCase()) - const testCoupon = isTestAccount ? await ensureTestCoupon('wartung') : null - // Stripe-Metadaten: max. 500 Zeichen je Wert + const testCoupon = isTestAccount ? await ensureTestCoupon(productKey) : null + const offerMeta = offer + ? { offerId: offer.id, offerText: offer.text.slice(0, 300), ...(testCoupon ? { offerDeferred: '1' } : {}) } + : {} + // Stripe-Metadaten am Abo (max. 500 Zeichen je Wert): Zuordnung, Produkt und Nachweis der Zustimmung const meta = { webklarCustomerId: customer.$id, email: customer.email || '', + productKey, termsVersion: config.abo.termsVersion, consentTextVersion: config.abo.termsVersion, termsAcceptedAt: consent.termsAcceptedAt, @@ -466,13 +490,10 @@ export async function createAboCheckout(customer, consent, offer = null) { earlyStartConsentAt: consent.earlyStartConsentAt || '', consentIp: String(consent.consentIp || '').slice(0, 64), consentUserAgent: String(consent.consentUserAgent || '').slice(0, 300), - ...(offer - ? { offerId: offer.id, offerText: offer.text.slice(0, 300), ...(testCoupon ? { offerDeferred: '1' } : {}) } - : {}), + ...offerMeta, } // Checkout erlaubt nur einen Rabatt: Testkonto -> Testrabatt, sonst der Angebots-Coupon const coupon = testCoupon || offer?.stripeCouponId || null - const offerPrice = offer ? ` Preis wie vereinbart: ${offer.text}.` : '' const params = { mode: 'subscription', customer: stripeCustomerId, @@ -484,20 +505,15 @@ export async function createAboCheckout(customer, consent, offer = null) { customer_update: { address: 'auto', name: 'auto' }, subscription_data: { metadata: meta, - description: config.stripe.productName, + description: product.name, ...(serviceStartAt ? { trial_end: Math.floor(serviceStartAt.getTime() / 1000) } : {}), }, - metadata: meta, + // An der Checkout-Sitzung nur, was der Webhook braucht (Zuordnung, Produkt, Angebot). Keine + // Zustimmungsdaten: abgeschlossene Sitzungen lassen sich spaeter nicht mehr bereinigen + // (Loeschfristen, Datenschutzerklaerung Abschnitt 7) - die stehen nur am Abo. + metadata: { webklarCustomerId: customer.$id, productKey, ...offerMeta }, ...(coupon ? { discounts: [{ coupon }] } : {}), - custom_text: { - submit: { - message: offer - ? `Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit ${lateStart ? 'dem Abschluss' : 'der ersten Zahlung'} kommt der Vertrag zustande.${lateStart ? ' Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab.' : ''}${offerPrice} Mindestlaufzeit 12 Monate, danach monatlich kündbar.` - : lateStart - ? 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab. Danach 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.' - : 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.', - }, - }, + custom_text: { submit: { message: checkoutMessage(product, lateStart, offer) } }, success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`, cancel_url: `${config.publicBaseUrl}/dashboard.html?abo=cancel`, } diff --git a/test/aboRoutes.test.js b/test/aboRoutes.test.js new file mode 100644 index 0000000..a002553 --- /dev/null +++ b/test/aboRoutes.test.js @@ -0,0 +1,309 @@ +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'), + aboOffers: schemaKeys('aboOffers'), + hourCredits: schemaKeys('hourCredits'), + }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, sessionCookie, get, rawGet, post, postWebhook } = await import('./support/testApp.js') +const { hostingDoc, wartungDoc, consent, customerCookie, adminCookie, seedFixtures, stripeSubscription } = await import('./support/fixtures.js') +const { default: aboRoutes } = await import('../server/routes/abo.js') +const { recordContractEvent, listContractEvents } = await import('../server/services/contractEvents.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { withdrawalDeadline } = await import('../server/services/deadlines.js') + +const app = await startTestApp({ '/api/abo': aboRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) +seedFixtures(aw) + +const DAY_MS = 86400000 +const daysAgo = (days) => new Date(Date.now() - days * DAY_MS).toISOString() +const orderBody = (productKey, extra = {}) => ({ + productKey, + acceptTerms: true, + startChoice: 'sofort', + termsVersion: config.abo.termsVersion, + offerId: '', + ...extra, +}) +async function clearEvents(customerId) { + for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id) +} + +test('GET /api/abo: Katalog, Hosting-Abo mit Speicher, kein Stundenkonto', async () => { + aw.seed('subscriptions', { ...hostingDoc, storageUnits: 2 }) + const r = await get('/api/abo', { cookie: sessionCookie({ customerId: 'c1', appwriteUserId: 'u1' }) }) + assert.deepEqual(r.products.map((p) => [p.key, p.amountCents]), [['hosting', 1500], ['wartung', 5000]]) + assert.equal(r.subscription.productKey, 'hosting'); assert.equal(r.subscription.storage.units, 2) + assert.equal(r.subscription.canSwitch, true); assert.equal(r.ledger, null) + assert.equal(r.subscription.termsUrl, '/abo-bedingungen.html') + // F13: PDF der zugestimmten Fassung; Katalogdaten fuer Speicher-Widget und Bestellseiten + assert.equal(r.subscription.termsPdfUrl, '/WEBklar-Abo-Vertragsunterlagen.pdf') + assert.equal(r.subscription.termsVersion, config.abo.termsVersion) + assert.deepEqual(r.mailStorage, { includedGb: 5, gbPerUnit: 5, unitPriceCents: 100, maxUnits: 10 }) + // Angebote je Produkt (Hosting bis Aufgabe 11 immer null); das alte Feld offer entfaellt (F15) + assert.deepEqual(r.offers, { hosting: null, wartung: null }) + assert.equal('offer' in r, false) + assert.deepEqual(r.subscription.storage, { units: 2, pendingSetup: false, pendingReduction: null }) + assert.equal(r.subscription.canChangeStorage, true) + assert.equal(r.subscription.switchPending, null) + assert.deepEqual(r.subscription.withdrawals, []) + assert.equal(r.subscription.withdrawalOpen, false) + assert.equal(r.subscription.withdrawalUntil, null) + assert.equal(r.available, true) + assert.equal(r.termsVersion, config.abo.termsVersion) + assert.ok(r.lateStartAt) +}) + +test('GET /api/abo: Wartung v3 → Link auf Fassung 3, Stundenkonto da', async () => { + aw.seed('subscriptions', { ...wartungDoc, $id: 'sub_h', customerId: 'c1', termsVersion: '2026-09-25-v3' }) + const r = await get('/api/abo', { cookie: customerCookie() }) + assert.equal(r.subscription.termsUrl, '/vertragsunterlagen/v3/abo-bedingungen.html'); assert.ok(r.ledger) + assert.equal(r.subscription.termsPdfUrl, '/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf') + assert.equal(r.subscription.productKey, 'wartung') + // Wechseln kann nur ein Hosting-Abo; Zusatzspeicher gibt es in beiden Abos + assert.equal(r.subscription.canSwitch, false) + assert.equal(r.subscription.canChangeStorage, true) +}) + +test('GET /api/abo: Altbestand ohne productKey gilt als Wartungs-Abo', async () => { + aw.seed('subscriptions', { ...wartungDoc, $id: 'sub_h', productKey: undefined, termsVersion: '2026-09-25-v3' }) + const r = await get('/api/abo', { cookie: customerCookie() }) + assert.equal(r.subscription.productKey, 'wartung') + assert.ok(r.ledger) + assert.equal(r.subscription.canSwitch, false) +}) + +test('Ohne Session 401, Admin-Session ist keine Kunden-Session', async () => { + assert.equal((await rawGet('/api/abo')).status, 401) + assert.equal((await rawGet('/api/abo', { cookie: adminCookie() })).status, 401) + await assert.rejects(get('/api/abo'), (e) => e.status === 401) +}) + +test('Checkout Hosting: Preis, Beschreibung, Hinweistext', async () => { + const res = await post('/api/abo/checkout', orderBody('hosting'), { cookie: customerCookie('c3') }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.match(res.body.url, /^https:\/\//) + const call = st.calls.find((c) => c.path === '/v1/checkout/sessions') + assert.equal(call.body['subscription_data[description]'], 'WEBklar Hosting-Abo') + assert.match(call.body['custom_text[submit][message]'], /15,00 € monatlich/) + assert.equal(call.body['subscription_data[metadata][productKey]'], 'hosting') + assert.match(call.body['custom_text[submit][message]'], /Mindestlaufzeit 12 Monate, danach monatlich kündbar/) + assert.match(call.body['custom_text[submit][message]'], /§ 19 UStG/) + const hostingPrice = [...st.prices.values()].find((p) => p.lookup_key === 'webklar_hosting_monatlich_1500') + assert.equal(call.body['line_items[0][price]'], hostingPrice.id) + assert.equal(call.body['discounts[0][coupon]'], undefined) + // Nachweis der Zustimmung nur am Abo (F41c): an der Checkout-Sitzung bleiben Zuordnung und Produkt + assert.equal(call.body['subscription_data[metadata][termsAcceptedAt]'] !== undefined, true) + assert.ok(call.body['subscription_data[metadata][consentIp]']) + assert.equal(call.body['subscription_data[metadata][termsVersion]'], config.abo.termsVersion) + for (const key of ['termsVersion', 'consentTextVersion', 'termsAcceptedAt', 'startChoice', 'earlyStartConsentAt', 'consentIp', 'consentUserAgent', 'email']) { + assert.equal(call.body[`metadata[${key}]`], undefined, `metadata[${key}] darf nicht an der Sitzung stehen`) + } + assert.equal(call.body['metadata[webklarCustomerId]'], 'c3') + assert.equal(call.body['metadata[productKey]'], 'hosting') +}) + +test('Checkout Wartung: 50,00 €, Beschreibung, Produkt in den Metadaten', async () => { + const before = st.calls.length + const res = await post('/api/abo/checkout', orderBody('wartung'), { cookie: customerCookie('c3') }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const call = st.calls.slice(before).find((c) => c.path === '/v1/checkout/sessions') + assert.equal(call.body['subscription_data[description]'], 'WEBklar Wartungs-Abo') + assert.match(call.body['custom_text[submit][message]'], /50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar/) + assert.equal(call.body['subscription_data[metadata][productKey]'], 'wartung') + const wartungPrice = [...st.prices.values()].find((p) => p.lookup_key === 'webklar_wartung_monatlich_5000') + assert.equal(call.body['line_items[0][price]'], wartungPrice.id) +}) + +test('Checkout: unbekanntes Produkt → 400', async () => { + assert.equal((await post('/api/abo/checkout', orderBody('gold'), { cookie: customerCookie('c3') })).status, 400) + assert.equal((await post('/api/abo/checkout', { ...orderBody('hosting'), productKey: undefined }, { cookie: customerCookie('c3') })).status, 400) +}) + +test('Checkout Hosting mit Angebot → 409; das Angebot gilt fuer die Wartung', async () => { + aw.seed('aboOffers', { + $id: 'o1', customerId: 'c3', status: 'offen', scope: 'neu', months: 2, monthlyPriceCents: 2500, regularPriceCents: 5000, + validUntil: '2099-01-01T00:00:00.000Z', stripeCouponId: 'WEBKLAR-ANGEBOT-O1', note: '', + }) + try { + const r = await get('/api/abo', { cookie: customerCookie('c3') }) + assert.equal(r.offers.hosting, null) + assert.equal(r.offers.wartung.id, 'o1') + const hosting = await post('/api/abo/checkout', orderBody('hosting', { offerId: 'o1' }), { cookie: customerCookie('c3') }) + assert.equal(hosting.status, 409) + assert.match(hosting.body.error, /anderes Abo/) + const before = st.calls.length + const wartung = await post('/api/abo/checkout', orderBody('wartung', { offerId: 'o1' }), { cookie: customerCookie('c3') }) + assert.equal(wartung.status, 200, JSON.stringify(wartung.body)) + const call = st.calls.slice(before).find((c) => c.path === '/v1/checkout/sessions') + assert.equal(call.body['discounts[0][coupon]'], 'WEBKLAR-ANGEBOT-O1') + assert.equal(call.body['subscription_data[metadata][offerId]'], 'o1') + // Der Webhook liest das Angebot von der Sitzung (activateOfferFromCheckout) + assert.equal(call.body['metadata[offerId]'], 'o1') + } finally { + await deleteDocument(config.collections.aboOffers, 'o1') + } +}) + +test('Checkout mit laufendem Abo → 409', async () => { + aw.seed('subscriptions', hostingDoc) + const res = await post('/api/abo/checkout', orderBody('wartung'), { cookie: customerCookie('c1') }) + assert.equal(res.status, 409) + assert.match(res.body.error, /bereits/) +}) + +test('Gekündigtes Abo: canSwitch und canChangeStorage false', async () => { + aw.seed('subscriptions', { ...hostingDoc, $id: 'sub_h', cancelAtPeriodEnd: true, cancelAt: '2027-10-05T10:00:00.000Z' }) + const r = await get('/api/abo', { cookie: customerCookie() }) + assert.equal(r.subscription.canSwitch, false); assert.equal(r.subscription.canChangeStorage, false) + assert.equal(r.subscription.cancelAt, '2027-10-05T10:00:00.000Z') +}) + +test('Widerrufsfristen aus den Ereignissen', async () => { + aw.seed('subscriptions', { ...hostingDoc, storageUnits: 2 }) + const consentAt = daysAgo(2) + await recordContractEvent({ + customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting', + details: { fromUnits: 0, toUnits: 2 }, consent: { ...consent, consentAt }, status: 'offen', dueAt: consentAt, + }) + try { + const r = await get('/api/abo', { cookie: customerCookie() }) + assert.deepEqual(r.subscription.withdrawals.map((w) => w.type), ['speicher']) + assert.equal(r.subscription.withdrawalOpen, true) + assert.equal(r.subscription.withdrawalUntil, withdrawalDeadline(consentAt).endsAt.toISOString()) + assert.equal(r.subscription.withdrawals[0].until, r.subscription.withdrawalUntil) + assert.ok(r.subscription.withdrawals[0].eventId) + // Offenes Plus: konsoleH noch nicht umgestellt + assert.deepEqual(r.subscription.storage, { units: 2, pendingSetup: true, pendingReduction: null }) + } finally { + await clearEvents('c1') + } +}) + +test('Geplante Verringerung: ab Periodenende, ohne Widerrufsfrist', async () => { + aw.seed('subscriptions', { ...hostingDoc, storageUnits: 2 }) + await recordContractEvent({ + customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting', + details: { fromUnits: 2, toUnits: 1 }, consent: null, status: 'offen', dueAt: hostingDoc.currentPeriodEnd, + }) + try { + const r = await get('/api/abo', { cookie: customerCookie() }) + assert.deepEqual(r.subscription.storage, { units: 2, pendingSetup: false, pendingReduction: { units: 1, effectiveAt: hostingDoc.currentPeriodEnd } }) + assert.deepEqual(r.subscription.withdrawals, []) + assert.equal(r.subscription.withdrawalOpen, false) + assert.equal(r.subscription.canChangeStorage, true) + } finally { + await clearEvents('c1') + } +}) + +// F12: solange ein Wechsel auf die Zahlung wartet, gibt es weder Wechsel noch Speicheraenderung, +// und das Portal kann den Kunden zur Zahlungsseite zurueckfuehren +test('Wechsel wartet auf die Zahlung: canSwitch und canChangeStorage false, switchPending mit Zahlungslink', async () => { + aw.seed('subscriptions', hostingDoc) + const ev = await recordContractEvent({ + customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', + details: { from: 'hosting', paymentUrl: 'https://pay.example/inv' }, consent, status: 'offen', + }) + try { + const r = await get('/api/abo', { cookie: customerCookie() }) + assert.equal(r.subscription.canSwitch, false) + assert.equal(r.subscription.canChangeStorage, false) + assert.deepEqual(r.subscription.switchPending, { since: consent.consentAt, paymentUrl: 'https://pay.example/inv' }) + assert.equal(ev.status, 'offen') + } finally { + await clearEvents('c1') + } +}) + +// F14: Abos von vor Plan 2 haben kein Ereignis abschluss -> Frist ab startedAt wie bisher +test('Altbestand ohne Ereignis abschluss: Widerrufsfrist ab startedAt', async () => { + const startedAt = daysAgo(2) + aw.seed('subscriptions', { ...hostingDoc, startedAt, termStartAt: startedAt }) + const r = await get('/api/abo', { cookie: customerCookie() }) + const until = withdrawalDeadline(startedAt).endsAt.toISOString() + assert.deepEqual(r.subscription.withdrawals, [{ type: 'abschluss', eventId: null, until }]) + assert.equal(r.subscription.withdrawalOpen, true) + assert.equal(r.subscription.withdrawalUntil, until) + + // Mit Ereignis zaehlt allein das Ereignis (hier: Vertragsschluss vor 40 Tagen, Frist vorbei) + await recordContractEvent({ + id: 'abschluss-sub_h', customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'hosting', + details: { contractAt: daysAgo(40) }, consent: { ...consent, consentAt: daysAgo(40) }, + }) + try { + const again = await get('/api/abo', { cookie: customerCookie() }) + assert.deepEqual(again.subscription.withdrawals, []) + assert.equal(again.subscription.withdrawalOpen, false) + } finally { + await clearEvents('c1') + } +}) + +test('minTermEnd rechnet ab termStartAt, sonst ab trialEnd bzw. startedAt', async () => { + aw.seed('subscriptions', { ...hostingDoc, termStartAt: '2026-10-05T10:00:00.000Z' }) + assert.equal((await get('/api/abo', { cookie: customerCookie() })).subscription.minTermEnd, '2027-10-05T10:00:00.000Z') + aw.seed('subscriptions', { ...hostingDoc, termStartAt: null, trialEnd: '2026-11-01T05:00:00.000Z', status: 'trialing' }) + assert.equal((await get('/api/abo', { cookie: customerCookie() })).subscription.minTermEnd, '2027-11-01T05:00:00.000Z') + aw.seed('subscriptions', hostingDoc) +}) + +// F04/F05/F25: der Abschluss wird als Ereignis nachgewiesen, die Frist zaehlt ab dem Vertragsschluss bei +// Stripe (startedAt), eine Webhook-Wiederholung legt kein zweites Ereignis an. Zuletzt, weil danach c3 ein Abo hat. +test('Webhook checkout.session.completed: Ereignis abschluss ab Vertragsschluss, kein Duplikat', async () => { + const stripeSub = stripeSubscription({ id: 'sub_new', customerId: 'c3', productKey: 'hosting', metadata: { termsAcceptedAt: '2026-09-20T10:00:00.000Z' } }) + st.subscriptions.set('sub_new', stripeSub) + const event = { + id: 'evt_1', + object: 'event', + type: 'checkout.session.completed', + created: Math.floor(Date.now() / 1000), + data: { + object: { + id: 'cs_done', object: 'checkout.session', mode: 'subscription', subscription: 'sub_new', customer: 'cus_c3', + customer_details: { email: 'k3@example.invalid', name: 'Kunde Drei' }, + metadata: { webklarCustomerId: 'c3', productKey: 'hosting' }, + }, + }, + } + try { + const first = await postWebhook(event) + assert.equal(first.status, 200, JSON.stringify(first.body)) + const events = await listContractEvents('c3') + assert.equal(events.length, 1) + const [e] = events + assert.equal(e.$id, 'abschluss-sub_new') + assert.equal(e.type, 'abschluss') + assert.equal(e.productKey, 'hosting') + assert.equal(e.status, 'erledigt') + assert.equal(e.details.contractAt, new Date(stripeSub.start_date * 1000).toISOString()) + assert.equal(e.termsVersion, config.abo.termsVersion) + assert.equal(e.consentAt, '2026-09-20T10:00:00.000Z') + assert.equal(e.consentIp, '127.0.0.1') + assert.equal(e.consentUserAgent, 'test') + assert.equal(aw.docs('subscriptions').get('sub_new').productKey, 'hosting') + + const again = await postWebhook(event) + assert.equal(again.status, 200) + assert.equal((await listContractEvents('c3')).length, 1) + + // Falsche Signatur: 400, nichts verarbeitet + assert.equal((await postWebhook(event, { secret: 'whsec_falsch' })).status, 400) + } finally { + await clearEvents('c3') + await deleteDocument(config.collections.subscriptions, 'sub_new').catch(() => {}) + } +}) diff --git a/test/support/fakeStripe.js b/test/support/fakeStripe.js index 638491a..e916908 100644 --- a/test/support/fakeStripe.js +++ b/test/support/fakeStripe.js @@ -12,6 +12,8 @@ export async function startFakeStripe() { const products = new Map() const coupons = new Map() const billingPortalConfigurations = new Map() + const customers = new Map() + const checkoutSessions = new Map() const calls = [] let seq = 0 const nextId = (prefix) => `${prefix}_test_${(seq += 1)}` @@ -115,6 +117,44 @@ export async function startFakeStripe() { coupons.set(id, obj) return [200, obj] } + // Checkout (Neuabschluss): Kunde suchen/anlegen, Checkout-Sitzung anlegen (F10) + if (method === 'GET' && path === '/v1/customers/search') { + // Suchsyntax wie in getOrCreateStripeCustomer: metadata['webklarCustomerId']:'c1' + const m2 = (query.get('query') || '').match(/metadata\['(\w+)'\]:'([^']*)'/) + const found = [...customers.values()].filter((c) => !m2 || c.metadata?.[m2[1]] === m2[2]) + return [200, { object: 'search_result', data: found, has_more: false, url: '/v1/customers/search' }] + } + if (method === 'POST' && path === '/v1/customers') { + const data = unflatten(body) + const obj = { + id: nextId('cus'), + object: 'customer', + email: data.email ?? null, + name: data.name ?? null, + address: data.address ?? null, + invoice_settings: data.invoice_settings || {}, + metadata: data.metadata || {}, + } + customers.set(obj.id, obj) + return [200, obj] + } + if (method === 'POST' && path === '/v1/checkout/sessions') { + const data = unflatten(body) + const id = nextId('cs') + const obj = { + id, + object: 'checkout.session', + url: `https://checkout.stripe.test/c/pay/${id}`, + mode: data.mode || 'payment', + customer: data.customer ?? null, + client_reference_id: data.client_reference_id ?? null, + subscription: null, + metadata: data.metadata || {}, + subscription_data: data.subscription_data || {}, + } + checkoutSessions.set(id, obj) + return [200, obj] + } if (method === 'GET' && path === '/v1/billing_portal/configurations') { const active = query.get('active') const found = [...billingPortalConfigurations.values()].filter( @@ -172,6 +212,8 @@ export async function startFakeStripe() { products, coupons, billingPortalConfigurations, + customers, + checkoutSessions, close: () => new Promise((resolve) => server.close(resolve)), } } diff --git a/test/support/fixtures.js b/test/support/fixtures.js new file mode 100644 index 0000000..0d4ef08 --- /dev/null +++ b/test/support/fixtures.js @@ -0,0 +1,153 @@ +// testApp.js zuerst: es prueft beim Laden, dass useTestEnv() gelaufen ist, bevor config.js die Umgebung liest +import { sessionCookie } from './testApp.js' +import { config } from '../../server/config.js' + +/** + * Feste Testdaten für die Routen-Tests (Aufgaben 7–13): Kunden c1–c3, Abo-Dokumente, Zustimmung, + * Session-Cookies. seedFixtures(aw) legt Kunden und Abos in den Appwrite-Nachbau; c3 hat kein Abo + * (Neuabschluss-Tests). Erst nach useTestEnv() dynamisch importieren (siehe testApp.js). + */ + +const DAY_MS = 86400000 +const daysFromNow = (days) => new Date(Date.now() + days * DAY_MS).toISOString() + +export const customers = { + c1: { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true }, + c2: { $id: 'c2', appwriteUserId: 'u2', email: 'k2@example.invalid', name: 'Kunde Zwei', status: 'active', portalAccessEnabled: true }, + c3: { $id: 'c3', appwriteUserId: 'u3', email: 'k3@example.invalid', name: 'Kunde Drei', status: 'active', portalAccessEnabled: true }, +} + +// Abo seit 40 Tagen (Widerrufsfrist des Abschlusses vorbei), naechste Abbuchung in 20 Tagen +const startedAt = daysFromNow(-40) + +/** Hosting-Abo sub_h von c1: aktiv, Fassung 4, ohne Zusatzspeicher. */ +export const hostingDoc = { + $id: 'sub_h', + customerId: 'c1', + email: 'k@example.invalid', + stripeCustomerId: 'cus_c1', + stripeSubscriptionId: 'sub_h', + status: 'active', + productKey: 'hosting', + planKey: config.products.hosting.lookupKey, + priceAmount: config.products.hosting.amountCents, + startedAt, + termStartAt: startedAt, + currentPeriodEnd: daysFromNow(20), + trialEnd: null, + cancelAtPeriodEnd: false, + cancelAt: null, + canceledAt: null, + endedAt: null, + termsVersion: config.abo.termsVersion, + termsAcceptedAt: startedAt, + earlyStartConsentAt: startedAt, + startChoice: 'sofort', + consentIp: '127.0.0.1', + consentUserAgent: 'test', + consentTextVersion: config.abo.termsVersion, + storageUnits: 0, + storagePaidUnits: 0, + storagePaidPeriodEnd: null, +} + +/** Wartungs-Abo sub_w von c1 (im Test meist als sub_h eingespielt, damit c1 nur ein Abo hat). */ +export const wartungDoc = { + ...hostingDoc, + $id: 'sub_w', + stripeSubscriptionId: 'sub_w', + productKey: 'wartung', + planKey: config.products.wartung.lookupKey, + priceAmount: config.products.wartung.amountCents, +} + +/** Zweites Hosting-Abo sub_h2 von c2. */ +export const hosting2Doc = { + ...hostingDoc, + $id: 'sub_h2', + customerId: 'c2', + email: 'k2@example.invalid', + stripeCustomerId: 'cus_c2', + stripeSubscriptionId: 'sub_h2', +} + +const consentAt = new Date().toISOString() + +/** Zustimmung im Portal (Bestellung, Wechsel, Speicher-Plus); earlyStartConsentAt fuer den Wechsel (F16). */ +export const consent = { + termsVersion: config.abo.termsVersion, + consentAt, + consentIp: '127.0.0.1', + consentUserAgent: 'test', + earlyStartConsentAt: consentAt, +} + +/** Kunden-Session; Standard c1. */ +export const customerCookie = (customerId = 'c1') => + sessionCookie({ customerId, appwriteUserId: customers[customerId]?.appwriteUserId || `u_${customerId}` }) + +/** Admin-Session (Ticketsystem-Mitarbeiter im Portal), F11. */ +export const adminCookie = () => + sessionCookie({ role: 'admin', appwriteUserId: 'u_admin', name: 'Kenso', email: 'kenso@webklar.com' }) + +/** Kunden c1–c3 und die Abos sub_h (c1) und sub_h2 (c2) in den Appwrite-Nachbau legen. */ +export function seedFixtures(aw) { + for (const customer of Object.values(customers)) aw.seed('customers', customer) + aw.seed('subscriptions', hostingDoc) + aw.seed('subscriptions', hosting2Doc) +} + +const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000) + +/** + * Stripe-Abo in der Form der SDK-API-Version für den Stripe-Nachbau: Abo-Posten je Produkt, + * dazu ein Zusatzspeicher-Posten, wenn storageUnits > 0. metadata ergaenzt/ueberschreibt die + * Zustimmungsdaten, rest alles Weitere (status, cancel_at, ...). + */ +export function stripeSubscription({ id = 'sub_h', customerId = 'c1', productKey = 'hosting', storageUnits = 0, metadata = {}, ...rest } = {}) { + const product = config.products[productKey] + const items = [ + { + id: `si_${id}_abo`, + object: 'subscription_item', + quantity: 1, + price: { id: `price_${productKey}`, object: 'price', lookup_key: product.lookupKey, unit_amount: product.amountCents }, + }, + ] + if (storageUnits > 0) { + items.push({ + id: `si_${id}_speicher`, + object: 'subscription_item', + quantity: storageUnits, + price: { id: 'price_mailStorage', object: 'price', lookup_key: config.mailStorage.lookupKey, unit_amount: config.mailStorage.amountCents }, + }) + } + return { + id, + object: 'subscription', + status: 'active', + customer: `cus_${customerId}`, + start_date: unix(hostingDoc.startedAt), + current_period_end: unix(hostingDoc.currentPeriodEnd), + trial_end: null, + cancel_at_period_end: false, + cancel_at: null, + canceled_at: null, + ended_at: null, + metadata: { + webklarCustomerId: customerId, + email: customers[customerId]?.email || '', + productKey, + termsVersion: config.abo.termsVersion, + consentTextVersion: config.abo.termsVersion, + termsAcceptedAt: consentAt, + startChoice: 'sofort', + earlyStartConsentAt: consentAt, + consentIp: '127.0.0.1', + consentUserAgent: 'test', + ...metadata, + }, + items: { object: 'list', data: items }, + ...rest, + } +} diff --git a/test/support/testApp.js b/test/support/testApp.js new file mode 100644 index 0000000..8c35e71 --- /dev/null +++ b/test/support/testApp.js @@ -0,0 +1,117 @@ +import http from 'node:http' +import express from 'express' +import cookieParser from 'cookie-parser' +import signature from 'cookie-signature' +import Stripe from 'stripe' + +/** + * Express-App für Routen-Tests: wie server/index.js mit cookieParser(SESSION_SECRET) und + * express.json samt rawBody (Stripe-Webhook), aber nur mit den Routern, die der Test übergibt: + * const app = await startTestApp({ '/api/abo': aboRoutes, '/api/portal-admin': portalAdminRoutes }) + * + * Reihenfolge im Test: Nachbauten starten, useTestEnv(), dann dieses Modul und die Server-Module + * dynamisch importieren (assertTestEnv() weigert sich sonst, weil config.js die Umgebung beim + * ersten Import liest). Die Anfrage-Helfer sprechen die zuletzt gestartete App an. + */ + +function assertTestEnv() { + for (const name of ['APPWRITE_ENDPOINT', 'STRIPE_API_BASE']) { + let host = null + try { + host = new URL(process.env[name] || '').hostname + } catch { + host = null + } + if (host !== '127.0.0.1') { + throw new Error(`testApp: ${name} zeigt nicht auf 127.0.0.1 – useTestEnv() vor dem Import aufrufen`) + } + } +} +assertTestEnv() + +const cookieName = () => process.env.SESSION_COOKIE_NAME || 'webklar_portal_session' +const sessionSecret = () => process.env.SESSION_SECRET || '' + +/** + * Signierter Session-Cookie wie setPortalSession (cookie-parser: Praefix "s:" + cookie-signature). + * Kunde: { customerId, appwriteUserId }. Admin: { role: 'admin', appwriteUserId, name, email }. + */ +export function sessionCookie(session) { + const value = `s:${signature.sign(JSON.stringify(session), sessionSecret())}` + return `${cookieName()}=${encodeURIComponent(value)}` +} + +let baseUrl = null + +/** App mit den Routern (Pfad -> Router) auf 127.0.0.1 starten; liefert { url, close }. */ +export async function startTestApp(routers = {}) { + const app = express() + app.set('trust proxy', 1) + app.use(cookieParser(sessionSecret())) + app.use( + express.json({ + limit: '2mb', + verify: (req, _res, buf) => { + req.rawBody = buf + }, + }) + ) + for (const [mountPath, router] of Object.entries(routers)) app.use(mountPath, router) + app.use((err, _req, res, _next) => { + if (!res.headersSent) res.status(err.status || 500).json({ error: err.message || 'Interner Serverfehler' }) + }) + const server = http.createServer(app) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + baseUrl = `http://127.0.0.1:${server.address().port}` + return { url: baseUrl, close: () => new Promise((resolve) => server.close(resolve)) } +} + +async function request(method, path, { body, cookie, headers = {} } = {}) { + if (!baseUrl) throw new Error('testApp: startTestApp() zuerst aufrufen') + const res = await fetch(baseUrl + path, { + method, + headers: { + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + ...(cookie ? { cookie } : {}), + ...headers, + }, + body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body), + redirect: 'manual', + }) + const text = await res.text() + let parsed = null + try { + parsed = text ? JSON.parse(text) : null + } catch { + parsed = text + } + return { status: res.status, body: parsed, headers: res.headers } +} + +/** GET -> { status, body } */ +export const rawGet = (path, opts = {}) => request('GET', path, opts) + +/** GET -> JSON-Body; ab Status 400 fliegt ein Fehler mit err.status und err.body. */ +export async function get(path, opts = {}) { + const { status, body } = await rawGet(path, opts) + if (status >= 400) { + const err = new Error(`GET ${path} → ${status}: ${body?.error || JSON.stringify(body)}`) + err.status = status + err.body = body + throw err + } + return body +} + +/** POST mit JSON-Body -> { status, body } */ +export const post = (path, body, opts = {}) => request('POST', path, { ...opts, body }) + +// Signiert wie Stripe (whsec_test aus useTestEnv); der Schluessel wird nie fuer Netzaufrufe benutzt +const webhookSigner = new Stripe('sk_test_fake') + +/** Stripe-Ereignis signiert an den Webhook schicken (F25) -> { status, body }. */ +export function postWebhook(event, { path = '/api/abo/webhook', secret = process.env.STRIPE_WEBHOOK_SECRET || 'whsec_test' } = {}) { + const payload = JSON.stringify(event) + const stripeSignature = webhookSigner.webhooks.generateTestHeaderString({ payload, secret }) + return request('POST', path, { body: payload, headers: { 'stripe-signature': stripeSignature } }) +}