Files

550 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, kundenansichtCookie, 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', 2000], ['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, alte Mitarbeiter-Sitzung 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), /20,00 € monatlich/)
assert.equal(call.body['subscription_data[metadata][productKey]'], 'hosting')
assert.equal(message(call), `${INTRO_SOFORT} 20,00 € monatlich, ${TAIL}`)
assert.equal(call.body['line_items[0][price]'], priceIdFor('webklar_hosting_monatlich_2000'))
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 20,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 in der Kundenansicht → 403', async () => {
const { res, call } = await checkout(orderBody('hosting'), { cookie: kundenansichtCookie({ customerId: '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 ['20,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 und die Zeile „Enthalten“ der Fassung 3
// (1 E-Mail-Postfach, 1 Stunde Arbeitszeit pro Monat; Block wartung_v3, Fix 1)
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', '• 1 E-Mail-Postfach', '• 1 Stunde Arbeitszeit pro Monat', 'Ihr aktuelles Stundenkontingent', '(Version 2026-09-25-v3) einschließlich des Auftragsverarbeitungsvertrags']) {
assert.ok(k01.text.includes(text), `${text}\n---\n${k01.text}`)
}
assert.ok(!k01.text.includes('beliebig viele') && !k01.text.includes('je bezahltem Monat'), k01.text)
} finally {
await cleanupWebhook(subW)
}
// Gegenprobe: Wartungs-Abo nach Fassung 4 - aktuelles PDF, beliebig viele Postfaecher, Stunde je bezahltem Monat
const subW4 = 'sub_1SD6z4UxAbCdEfGhIjKlMnOu'
st.subscriptions.set(subW4, stripeSubscription({ id: subW4, customerId: 'c3', productKey: 'wartung' }))
try {
mails.sent.length = 0
assert.equal((await postWebhook(checkoutCompleted(subW4, { 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-Abo-Vertragsunterlagen.pdf'])
for (const text of ['50,00 € / Monat', '• beliebig viele Postfächer', '1 Stunde Arbeitszeit je bezahltem Monat', `(Version ${config.abo.termsVersion}) einschließlich des Auftragsverarbeitungsvertrags`]) {
assert.ok(k01.text.includes(text), `${text}\n---\n${k01.text}`)
}
assert.ok(!k01.text.includes('1 E-Mail-Postfach') && !k01.text.includes('pro Monat'), k01.text)
} finally {
await cleanupWebhook(subW4)
}
// Fix 1 (3): ohne gespeicherte Fassung kommen Versionsangabe und PDF aus derselben Quelle - beide Fassung 3
const subW0 = 'sub_1SD6z5VxAbCdEfGhIjKlMnOv'
st.subscriptions.set(subW0, stripeSubscription({ id: subW0, customerId: 'c3', productKey: 'wartung', metadata: { termsVersion: null } }))
try {
mails.sent.length = 0
assert.equal((await postWebhook(checkoutCompleted(subW0, { productKey: 'wartung' }))).status, 200)
const k01 = mails.sent.find((m) => m.subject.includes('Vertragsbestätigung'))
assert.ok(k01, 'K01 verschickt')
assert.deepEqual(k01.attachments.map((a) => a.filename), ['WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf'])
assert.ok(k01.text.includes('(Version 2026-09-25-v3)'), k01.text)
assert.ok(!k01.text.includes(config.abo.termsVersion), k01.text)
assert.ok(k01.text.includes('• 1 E-Mail-Postfach'), k01.text)
} finally {
await cleanupWebhook(subW0)
}
})
// Fix 1 (5): T07 nennt die nicht genutzte Arbeitszeit nur beim Wartungs-Abo
test('Webhook customer.subscription.deleted: T07 mit Arbeitszeit-Hinweis nur bei Wartung', async () => {
const deleted = (id) => ({ id: `evt_del_${id}`, object: 'event', type: 'customer.subscription.deleted', created: Math.floor(Date.now() / 1000), data: { object: { id, object: 'subscription', status: 'canceled' } } })
const subW = 'sub_1SD6z6WxAbCdEfGhIjKlMnOw'
const subH = 'sub_1SD6z7XxAbCdEfGhIjKlMnOx'
st.subscriptions.set(subW, stripeSubscription({ id: subW, customerId: 'c3', productKey: 'wartung', status: 'canceled' }))
st.subscriptions.set(subH, stripeSubscription({ id: subH, customerId: 'c3', productKey: 'hosting', status: 'canceled' }))
try {
mails.sent.length = 0
assert.equal((await postWebhook(deleted(subW))).status, 200)
const t07w = mails.sent.find((m) => m.subject.startsWith('[Abo] Abo beendet'))
assert.ok(t07w, 'T07 verschickt')
assert.ok(t07w.text.includes('Abo: Wartungs-Abo') && t07w.text.includes('Nicht genutzte Arbeitszeit aus dem Stundenkontingent verfällt mit dem Vertragsende.'), t07w.text)
mails.sent.length = 0
assert.equal((await postWebhook(deleted(subH))).status, 200)
const t07h = mails.sent.find((m) => m.subject.startsWith('[Abo] Abo beendet'))
assert.ok(t07h.text.includes('Abo: Hosting-Abo') && t07h.text.includes('Gebuchter Zusatzspeicher endet mit dem Abo.'), t07h.text)
assert.ok(!t07h.text.includes('Arbeitszeit') && !t07h.text.includes('Stundenkontingent'), t07h.text)
} finally {
await cleanupWebhook(subW)
await cleanupWebhook(subH)
}
})
// 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')
}
})