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 }) // Testkonto (Testrabatt im Checkout): Kunde c4 der Fixtures process.env.ABO_TEST_EMAILS = 'test@example.invalid' 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, viewAsCookie, seedFixtures, subscriptionDoc, stripeSubscription } = await import('./support/fixtures.js') const { default: aboRoutes } = await import('../server/routes/abo.js') const { recordContractEvent, listContractEvents, eventIdFor } = await import('../server/services/contractEvents.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { withdrawalDeadline } = await import('../server/services/deadlines.js') const { offerText } = await import('../server/services/offers.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { mailSink } = await import('./support/mailSink.js') const app = await startTestApp({ '/api/abo': aboRoutes }) // F25: Mails landen in einer Senke statt bei SMTP, damit K01 (Anhang je Fassung) und T02 pruefbar sind (Task 12) const mails = mailSink() setMailTransportForTests(mails) after(() => { setMailTransportForTests(null) return 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 unix = (iso) => Math.floor(new Date(iso).getTime() / 1000) const orderBody = (productKey, extra = {}) => ({ productKey, acceptTerms: true, startChoice: 'sofort', termsVersion: config.abo.termsVersion, offerId: '', ...extra, }) /** Bestellung abschicken; dazu der Checkout-Aufruf beim Stripe-Nachbau (nur Aufrufe ab jetzt, sonst null). */ async function checkout(body, { cookie = customerCookie('c3') } = {}) { const before = st.calls.length const res = await post('/api/abo/checkout', body, { cookie }) const call = st.calls.slice(before).find((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions') || null return { res, call } } const message = (call) => call.body['custom_text[submit][message]'] const priceIdFor = (lookupKey) => [...st.prices.values()].find((p) => p.lookup_key === lookupKey)?.id // Hinweistext ueber dem Bezahlknopf, wortgleich (Wartung wie vor Plan 2, plus § 19 UStG) const INTRO_SOFORT = 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande.' const INTRO_FRIST = '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.' const TAIL = 'Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.' const OFFER_O1 = { $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: '', } 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.testAccount, false) 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', subscriptionDoc('sub_h', { ...wartungDoc, 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', subscriptionDoc('sub_h', { ...wartungDoc, 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, call } = await checkout(orderBody('hosting')) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.match(res.body.url, /^https:\/\//) assert.equal(call.body['subscription_data[description]'], 'WEBklar Hosting-Abo') assert.match(message(call), /15,00 € monatlich/) assert.equal(call.body['subscription_data[metadata][productKey]'], 'hosting') assert.equal(message(call), `${INTRO_SOFORT} 15,00 € monatlich, ${TAIL}`) assert.equal(call.body['line_items[0][price]'], priceIdFor('webklar_hosting_monatlich_1500')) assert.equal(call.body['discounts[0][coupon]'], undefined) assert.equal(call.body['subscription_data[trial_end]'], 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 Hosting nach der Widerrufsfrist: Probezeit bis zum Leistungsbeginn, Hinweistext', async () => { const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') }) const { res, call } = await checkout(orderBody('hosting', { startChoice: 'nach_frist', lateStartAt })) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal(call.body['subscription_data[trial_end]'], String(unix(lateStartAt))) assert.equal(message(call), `${INTRO_FRIST} Danach 15,00 € monatlich, ${TAIL}`) // Veralteter Leistungsbeginn (Datumswechsel seit dem Laden der Seite) -> 409, kein Checkout const stale = await checkout(orderBody('hosting', { startChoice: 'nach_frist', lateStartAt: '2026-01-02T05:00:00.000Z' })) assert.equal(stale.res.status, 409) assert.equal(stale.call, null) }) test('Checkout Wartung: 50,00 €, Beschreibung, Hinweistexte wortgleich', async () => { const sofort = await checkout(orderBody('wartung')) assert.equal(sofort.res.status, 200, JSON.stringify(sofort.res.body)) assert.equal(sofort.call.body['subscription_data[description]'], 'WEBklar Wartungs-Abo') assert.equal(sofort.call.body['subscription_data[metadata][productKey]'], 'wartung') assert.equal(sofort.call.body['line_items[0][price]'], priceIdFor('webklar_wartung_monatlich_5000')) assert.equal(message(sofort.call), `${INTRO_SOFORT} 50,00 € monatlich, ${TAIL}`) const { lateStartAt } = await get('/api/abo', { cookie: customerCookie('c3') }) const frist = await checkout(orderBody('wartung', { startChoice: 'nach_frist', lateStartAt })) assert.equal(frist.res.status, 200, JSON.stringify(frist.res.body)) assert.equal(message(frist.call), `${INTRO_FRIST} Danach 50,00 € monatlich, ${TAIL}`) assert.equal(frist.call.body['subscription_data[trial_end]'], String(unix(lateStartAt))) }) test('Checkout Testkonto: Testrabatt des bestellten Produkts', async () => { const r = await get('/api/abo', { cookie: customerCookie('c4') }) assert.equal(r.testAccount, true) const hosting = await checkout(orderBody('hosting'), { cookie: customerCookie('c4') }) assert.equal(hosting.res.status, 200, JSON.stringify(hosting.res.body)) assert.equal(hosting.call.body['discounts[0][coupon]'], 'WEBKLAR-TEST-050-HOSTING') const wartung = await checkout(orderBody('wartung'), { cookie: customerCookie('c4') }) assert.equal(wartung.res.status, 200, JSON.stringify(wartung.res.body)) assert.equal(wartung.call.body['discounts[0][coupon]'], 'WEBKLAR-TEST-050') }) test('Checkout: unbekanntes Produkt → 400', async () => { const gold = await checkout(orderBody('gold')) assert.equal(gold.res.status, 400) assert.equal(gold.call, null) assert.equal((await checkout({ ...orderBody('hosting'), productKey: undefined })).res.status, 400) }) test('Checkout Hosting mit Angebot → 409; das Angebot gilt fuer die Wartung', async () => { aw.seed('aboOffers', OFFER_O1) 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 checkout(orderBody('hosting', { offerId: 'o1' })) assert.equal(hosting.res.status, 409) assert.match(hosting.res.body.error, /anderes Abo/) assert.equal(hosting.call, null) const wartung = await checkout(orderBody('wartung', { offerId: 'o1' })) assert.equal(wartung.res.status, 200, JSON.stringify(wartung.res.body)) assert.equal(wartung.call.body['discounts[0][coupon]'], 'WEBKLAR-ANGEBOT-O1') assert.equal(wartung.call.body['subscription_data[metadata][offerId]'], 'o1') assert.equal(message(wartung.call), `${INTRO_SOFORT} Preis wie vereinbart: ${offerText(OFFER_O1)}. ${TAIL}`) // Der Webhook liest das Angebot von der Sitzung (activateOfferFromCheckout) assert.equal(wartung.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, call } = await checkout(orderBody('wartung'), { cookie: customerCookie('c1') }) assert.equal(res.status, 409) assert.match(res.body.error, /bereits/) assert.equal(call, null) }) test('Checkout im Modus „Als Kunde ansehen“ → 403', async () => { const { res, call } = await checkout(orderBody('hosting'), { cookie: viewAsCookie('c3') }) assert.equal(res.status, 403) assert.equal(call, null) }) test('Gekündigtes Abo: canSwitch und canChangeStorage false', async () => { aw.seed('subscriptions', { ...hostingDoc, 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('Kein Wechsel und kein Speicher-Plus bei trialing, past_due oder Kündigung nur per cancelAt', async () => { for (const patch of [ { status: 'trialing', trialEnd: '2026-11-01T05:00:00.000Z', startChoice: 'nach_frist' }, { status: 'past_due' }, { cancelAtPeriodEnd: false, cancelAt: '2027-10-05T10:00:00.000Z' }, ]) { aw.seed('subscriptions', { ...hostingDoc, ...patch }) const r = await get('/api/abo', { cookie: customerCookie() }) assert.equal(r.subscription.canSwitch, false, JSON.stringify(patch)) assert.equal(r.subscription.canChangeStorage, false, JSON.stringify(patch)) assert.equal(r.subscription.active, true, JSON.stringify(patch)) } aw.seed('subscriptions', hostingDoc) }) 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: eventIdFor('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) }) // --------------------------------------------------------------------------- // Webhook checkout.session.completed (zuletzt: gibt c3 voruebergehend ein Abo) // --------------------------------------------------------------------------- // Abo-IDs 28 Zeichen wie bei Stripe (sub_ + 24): 'abschluss-' + ID waeren 38 Zeichen, Appwrite erlaubt 36 const SUB_NEW = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp' const checkoutCompleted = (subscriptionId, metadata = {}) => ({ id: `evt_${subscriptionId}`, object: 'event', type: 'checkout.session.completed', created: Math.floor(Date.now() / 1000), data: { object: { id: `cs_${subscriptionId}`, object: 'checkout.session', mode: 'subscription', subscription: subscriptionId, customer: 'cus_c3', customer_details: { email: 'k3@example.invalid', name: 'Kunde Drei' }, metadata: { webklarCustomerId: 'c3', productKey: 'hosting', ...metadata }, }, }, }) async function cleanupWebhook(subscriptionId, offerId = null) { await clearEvents('c3') await deleteDocument(config.collections.subscriptions, subscriptionId).catch(() => {}) if (offerId) await deleteDocument(config.collections.aboOffers, offerId).catch(() => {}) } // 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 test('Webhook checkout.session.completed: Ereignis abschluss ab Vertragsschluss, kein Duplikat', async () => { assert.equal(SUB_NEW.length, 28) const stripeSub = stripeSubscription({ id: SUB_NEW, customerId: 'c3', productKey: 'hosting' }) // Zustimmung im Portal eine Minute vor dem Abschluss bei Stripe const termsAcceptedAt = new Date((stripeSub.start_date - 60) * 1000).toISOString() stripeSub.metadata.termsAcceptedAt = termsAcceptedAt st.subscriptions.set(SUB_NEW, stripeSub) const event = checkoutCompleted(SUB_NEW) 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, eventIdFor('abschluss', SUB_NEW)) assert.equal(e.$id.length, 36) assert.equal(e.type, 'abschluss') assert.equal(e.subscriptionId, SUB_NEW) 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, termsAcceptedAt) 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 cleanupWebhook(SUB_NEW) } }) // Task 12: K01 je Produkt (Katalogname, Preis, Liste „Enthalten“) mit dem PDF der Fassung, der der Kunde zugestimmt // hat (§ 312f BGB, contractAttachmentFor); T02 nennt das Produkt und die Aufgabe fuer UptimeRobot test('Webhook checkout.session.completed: K01 je Produkt mit PDF der zugestimmten Fassung, T02 mit UptimeRobot', async () => { const subH = 'sub_1SD6y2SxAbCdEfGhIjKlMnOs' st.subscriptions.set(subH, stripeSubscription({ id: subH, customerId: 'c3', productKey: 'hosting' })) try { mails.sent.length = 0 const res = await postWebhook(checkoutCompleted(subH)) assert.equal(res.status, 200, JSON.stringify(res.body)) const k01 = mails.sent.find((m) => m.subject.includes('Vertragsbestätigung')) assert.ok(k01, 'K01 verschickt') assert.equal(k01.subject, 'Ihr WEBklar Hosting-Abo – Vertragsbestätigung') assert.deepEqual(k01.attachments.map((a) => [a.filename, a.contentType]), [['WEBklar-Abo-Vertragsunterlagen.pdf', 'application/pdf']]) assert.ok(k01.attachments[0].content.length > 1000, 'PDF-Inhalt angehaengt') for (const text of ['15,00 € / Monat', 'gemäß § 19 UStG ohne Umsatzsteuer', '1 E-Mail-Postfach', `(Version ${config.abo.termsVersion})`]) { assert.ok(k01.text.includes(text), `${text}\n---\n${k01.text}`) } assert.ok(!k01.text.includes('Stunde Arbeitszeit') && !k01.text.includes('Stundenkontingent'), k01.text) assert.ok(aw.docs('subscriptions').get(subH).confirmationSentAt) const t02 = mails.sent.find((m) => m.subject.startsWith('[Abo] Neues')) assert.ok(t02.subject.startsWith('[Abo] Neues Hosting-Abo: '), t02.subject) assert.ok(t02.text.includes('Website als Monitor in UptimeRobot anlegen'), t02.text) } finally { await cleanupWebhook(subH) } // Wartungs-Abo nach Fassung 3 (Altbestand): das Archiv-PDF der Fassung 3, Stunde und beliebig viele Postfaecher const subW = 'sub_1SD6z3TxAbCdEfGhIjKlMnOt' st.subscriptions.set(subW, stripeSubscription({ id: subW, customerId: 'c3', productKey: 'wartung', metadata: { termsVersion: '2026-09-25-v3' } })) try { mails.sent.length = 0 assert.equal((await postWebhook(checkoutCompleted(subW, { productKey: 'wartung' }))).status, 200) const k01 = mails.sent.find((m) => m.subject.includes('Vertragsbestätigung')) assert.equal(k01.subject, 'Ihr WEBklar Wartungs-Abo – Vertragsbestätigung') assert.deepEqual(k01.attachments.map((a) => a.filename), ['WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf']) for (const text of ['50,00 € / Monat', 'beliebig viele Postfächer', '1 Stunde Arbeitszeit je bezahltem Monat', '(Version 2026-09-25-v3)']) { assert.ok(k01.text.includes(text), `${text}\n---\n${k01.text}`) } } finally { await cleanupWebhook(subW) } }) // Fix 1, Important: ein dauerhafter Fehler beim Nachweis (4xx, z. B. Collection oder Attribut fehlt) darf // Angebot, T02 und K01 nicht blockieren; ein voruebergehender (5xx, Netz) gibt 500, Stripe wiederholt test('Webhook: Nachweis dauerhaft nicht speicherbar (400) → 200, Angebot trotzdem aktiviert', async () => { const subId = 'sub_1SD6w0QxAbCdEfGhIjKlMnOq' st.subscriptions.set(subId, stripeSubscription({ id: subId, customerId: 'c3', productKey: 'wartung', metadata: { offerId: 'o2' } })) aw.seed('aboOffers', { ...OFFER_O1, $id: 'o2', stripeCouponId: 'WEBKLAR-ANGEBOT-O2' }) aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 400 }) try { const res = await postWebhook(checkoutCompleted(subId, { productKey: 'wartung', offerId: 'o2' })) assert.equal(res.status, 200, JSON.stringify(res.body)) assert.equal((await listContractEvents('c3')).length, 0) assert.equal(aw.docs('aboOffers').get('o2').status, 'aktiv') assert.equal(aw.docs('aboOffers').get('o2').stripeSubscriptionId, subId) assert.equal(aw.docs('subscriptions').get(subId).productKey, 'wartung') } finally { await cleanupWebhook(subId, 'o2') } }) test('Webhook: Nachweis voruebergehend nicht speicherbar (500) → 500, Wiederholung holt alles nach', async () => { const subId = 'sub_1SD6x1RxAbCdEfGhIjKlMnOr' st.subscriptions.set(subId, stripeSubscription({ id: subId, customerId: 'c3', productKey: 'wartung', metadata: { offerId: 'o3' } })) aw.seed('aboOffers', { ...OFFER_O1, $id: 'o3', stripeCouponId: 'WEBKLAR-ANGEBOT-O3' }) aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 }) const event = checkoutCompleted(subId, { productKey: 'wartung', offerId: 'o3' }) try { const failed = await postWebhook(event) assert.equal(failed.status, 500) assert.equal((await listContractEvents('c3')).length, 0) assert.equal(aw.docs('aboOffers').get('o3').status, 'offen') // Stripe wiederholt: Nachweis, Angebot und Hinweise kommen nach const retry = await postWebhook(event) assert.equal(retry.status, 200, JSON.stringify(retry.body)) const events = await listContractEvents('c3') assert.equal(events.length, 1) assert.equal(events[0].$id, eventIdFor('abschluss', subId)) assert.equal(aw.docs('aboOffers').get('o3').status, 'aktiv') } finally { await cleanupWebhook(subId, 'o3') } })