Abo-API: Nachweis-ID nach Appwrite-Regeln, Webhook bei dauerhaften Fehlern, Tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
7df0b72019
commit
887e1e54a0
10 files changed
+409
-107
No files matched your search
+194
-61
@@ -16,13 +16,17 @@ const aw = await startAppwriteMock({
|
||||
})
|
||||
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, seedFixtures, stripeSubscription } = await import('./support/fixtures.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 } = await import('../server/services/contractEvents.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 app = await startTestApp({ '/api/abo': aboRoutes })
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||
@@ -30,6 +34,7 @@ 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,
|
||||
@@ -38,6 +43,29 @@ const orderBody = (productKey, extra = {}) => ({
|
||||
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)
|
||||
}
|
||||
@@ -63,12 +91,13 @@ test('GET /api/abo: Katalog, Hosting-Abo mit Speicher, kein Stundenkonto', async
|
||||
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', { ...wartungDoc, $id: 'sub_h', customerId: 'c1', termsVersion: '2026-09-25-v3' })
|
||||
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')
|
||||
@@ -79,7 +108,7 @@ test('GET /api/abo: Wartung v3 → Link auf Fassung 3, Stundenkonto da', async (
|
||||
})
|
||||
|
||||
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' })
|
||||
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)
|
||||
@@ -93,18 +122,16 @@ test('Ohne Session 401, Admin-Session ist keine Kunden-Session', async () => {
|
||||
})
|
||||
|
||||
test('Checkout Hosting: Preis, Beschreibung, Hinweistext', async () => {
|
||||
const res = await post('/api/abo/checkout', orderBody('hosting'), { cookie: customerCookie('c3') })
|
||||
const { res, call } = await checkout(orderBody('hosting'))
|
||||
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.match(message(call), /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(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]'])
|
||||
@@ -116,43 +143,67 @@ test('Checkout Hosting: Preis, Beschreibung, Hinweistext', async () => {
|
||||
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') })
|
||||
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))
|
||||
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)
|
||||
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 () => {
|
||||
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)
|
||||
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', {
|
||||
$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: '',
|
||||
})
|
||||
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 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')
|
||||
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(call.body['metadata[offerId]'], 'o1')
|
||||
assert.equal(wartung.call.body['metadata[offerId]'], 'o1')
|
||||
} finally {
|
||||
await deleteDocument(config.collections.aboOffers, 'o1')
|
||||
}
|
||||
@@ -160,18 +211,40 @@ test('Checkout Hosting mit Angebot → 409; das Angebot gilt fuer die Wartung',
|
||||
|
||||
test('Checkout mit laufendem Abo → 409', async () => {
|
||||
aw.seed('subscriptions', hostingDoc)
|
||||
const res = await post('/api/abo/checkout', orderBody('wartung'), { cookie: customerCookie('c1') })
|
||||
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, $id: 'sub_h', cancelAtPeriodEnd: true, cancelAt: '2027-10-05T10:00:00.000Z' })
|
||||
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)
|
||||
@@ -241,7 +314,7 @@ test('Altbestand ohne Ereignis abschluss: Widerrufsfrist ab startedAt', async ()
|
||||
|
||||
// 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',
|
||||
id: eventIdFor('abschluss', 'sub_h'), customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'hosting',
|
||||
details: { contractAt: daysAgo(40) }, consent: { ...consent, consentAt: daysAgo(40) },
|
||||
})
|
||||
try {
|
||||
@@ -261,40 +334,59 @@ test('minTermEnd rechnet ab termStartAt, sonst ab trialEnd bzw. startedAt', asyn
|
||||
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' },
|
||||
},
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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, 'abschluss-sub_new')
|
||||
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, '2026-09-20T10:00:00.000Z')
|
||||
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')
|
||||
assert.equal(aw.docs('subscriptions').get(SUB_NEW).productKey, 'hosting')
|
||||
|
||||
const again = await postWebhook(event)
|
||||
assert.equal(again.status, 200)
|
||||
@@ -303,7 +395,48 @@ test('Webhook checkout.session.completed: Ereignis abschluss ab Vertragsschluss,
|
||||
// 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(() => {})
|
||||
await cleanupWebhook(SUB_NEW)
|
||||
}
|
||||
})
|
||||
|
||||
// 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')
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user