Funktionen: Buchung per Stripe, Webhook, Mails und Freischalt-Job

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-09-30 16:33:48 +00:00
1 parent 770cbe32b7
commit 0cc35bf1a5
12 files changed
+642 -8

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
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'
// websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau
const aw = await startAppwriteMock({
schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post, postWebhook } = await import('./support/testApp.js')
const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const sf = await import('../server/services/siteFeatures.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { mailSink } = await import('./support/mailSink.js')
const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/abo': aboRoutes })
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' })
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Café' })
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Kiosk' })
aw.seed('websiteProjects', { $id: 'p4', customerId: 'c1', projectName: 'Bistro' })
aw.seed('websiteProjects', { $id: 'p5', customerId: 'c2', projectName: 'Fremd' })
const cookie = customerCookie('c1')
const checkout = (projectId, consent = true) => post('/api/funktionen/news/checkout', { projectId, consent }, { cookie })
const sessions = () => [...st.checkoutSessions.values()]
const workorders = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).startsWith('News-Pinnwand einbauen'))
const subjects = () => mails.sent.map((m) => m.subject)
const event = (type, session) => ({ id: `evt_${Math.random()}`, object: 'event', type, data: { object: session } })
const paidSession = (id, projectId, extra = {}) => ({
id,
object: 'checkout.session',
mode: 'payment',
payment_status: 'paid',
metadata: { customerId: 'c1', projectId, featureKey: 'news' },
...extra,
})
test('liste_zustaende', async () => {
const body = await get('/api/funktionen', { cookie })
assert.equal(body.setupEstimate, 'meist innerhalb von 1–2 Wochen')
assert.equal(body.projects.length, 4)
for (const p of body.projects) assert.equal(p.features.news.state, 'off')
})
test('checkout_ohne_zustimmung_400', async () => {
assert.equal((await checkout('p1', false)).status, 400)
assert.equal((await post('/api/funktionen/news/checkout', { projectId: 'p1' }, { cookie })).status, 400)
assert.equal(sessions().length, 0)
})
test('checkout_fremdes_projekt_403', async () => {
assert.equal((await checkout('p5')).status, 403)
assert.equal(sessions().length, 0)
})
test('checkout_legt_session_an', async () => {
const res = await checkout('p1')
assert.equal(res.status, 200)
assert.match(res.body.url, /^https:\/\/checkout\.stripe\.test\//)
const [session] = sessions()
assert.equal(session.mode, 'payment')
assert.equal(session.metadata.featureKey, 'news')
assert.equal(session.metadata.projectId, 'p1')
assert.equal(session.metadata.customerId, 'c1')
const call = st.calls.find((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions')
assert.equal(call.body['invoice_creation[enabled]'], 'true')
assert.equal(call.body.success_url, `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`)
assert.equal(call.body.cancel_url, `${config.publicBaseUrl}/dashboard.html#news`)
assert.ok(Number(call.body.expires_at) > Date.now() / 1000)
const price = [...st.prices.values()].find((p) => p.id === call.body['line_items[0][price]'])
assert.equal(price.unit_amount, 3000)
const doc = await sf.getFeatureDoc('c1', 'p1', 'news')
assert.equal(doc.state, 'ordered')
assert.equal(doc.stripeSessionId, session.id)
assert.equal(doc.consentText, config.funktionen.consentText)
assert.ok(doc.consentAt)
assert.ok(doc.consentUserAgent)
// zweite Bestellung waehrend die erste offen ist: 409, keine neue Sitzung
assert.equal((await checkout('p1')).status, 409)
assert.equal(sessions().length, 1)
})
test('abgelaufene_bestellung_darf_neu_gestartet_werden', async () => {
const doc = await sf.getFeatureDoc('c1', 'p1', 'news')
aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), orderedAt: new Date(Date.now() - 25 * 3600 * 1000).toISOString() })
assert.equal((await checkout('p1')).status, 200)
assert.equal(sessions().length, 2)
})
test('checkout_bei_setup_409', async () => {
const sid = 'cs_setup'
await sf.markOrdered({ customerId: 'c1', projectId: 'p2', key: 'news', sessionId: sid, consent: {} })
await sf.markPaid({ sessionId: sid })
const before = sessions().length
assert.equal((await checkout('p2')).status, 409)
assert.equal(sessions().length, before)
})
test('sepa_wartet_auf_zahlung', async () => {
const res = await checkout('p3')
assert.equal(res.status, 200)
const id = sessions().at(-1).id
const mailsBefore = mails.sent.length
const wosBefore = workorders().length
const unpaid = paidSession(id, 'p3', { payment_status: 'unpaid' })
assert.equal((await postWebhook(event('checkout.session.completed', unpaid))).status, 200)
assert.equal((await sf.getFeatureDoc('c1', 'p3', 'news')).state, 'ordered')
assert.equal(workorders().length, wosBefore)
assert.equal(mails.sent.length, mailsBefore)
assert.equal((await postWebhook(event('checkout.session.async_payment_succeeded', paidSession(id, 'p3')))).status, 200)
assert.equal((await sf.getFeatureDoc('c1', 'p3', 'news')).state, 'setup')
assert.equal(workorders().length, wosBefore + 1)
assert.deepEqual(subjects().slice(mailsBefore).sort(), ['Deine Bestellung: News-Pinnwand', 'Neue Funktion bestellt: News-Pinnwand'])
const k15 = mails.sent.at(-2).subject.startsWith('Deine') ? mails.sent.at(-2) : mails.sent.at(-1)
assert.equal(k15.to[0].address, 'k@example.invalid')
assert.match(k15.text, /30,00 €/)
})
test('webhook_idempotent', async () => {
await checkout('p4')
const id = sessions().at(-1).id
const mailsBefore = mails.sent.length
const wosBefore = workorders().length
for (let i = 0; i < 2; i += 1) {
assert.equal((await postWebhook(event('checkout.session.completed', paidSession(id, 'p4')))).status, 200)
}
assert.equal(workorders().length, wosBefore + 1)
assert.equal(mails.sent.length - mailsBefore, 2)
assert.equal(subjects().slice(mailsBefore).filter((s) => s.startsWith('Deine Bestellung')).length, 1)
})
test('abgelaufen_zurueck_auf_off', async () => {
aw.seed('websiteProjects', { $id: 'p6', customerId: 'c1', projectName: 'Werkstatt' })
await checkout('p6')
const id = sessions().at(-1).id
const expired = { id, object: 'checkout.session', mode: 'payment', metadata: { customerId: 'c1', projectId: 'p6', featureKey: 'news' } }
assert.equal((await postWebhook(event('checkout.session.expired', expired))).status, 200)
assert.equal((await sf.getFeatureDoc('c1', 'p6', 'news')).state, 'off')
assert.equal((await checkout('p6')).status, 200)
})
test('zahlung_fehlgeschlagen_mailt_team', async () => {
aw.seed('websiteProjects', { $id: 'p7', customerId: 'c1', projectName: 'Garage' })
await checkout('p7')
const id = sessions().at(-1).id
const mailsBefore = mails.sent.length
await postWebhook(event('checkout.session.async_payment_failed', paidSession(id, 'p7', { payment_status: 'unpaid' })))
assert.equal((await sf.getFeatureDoc('c1', 'p7', 'news')).state, 'off')
assert.equal(mails.sent.length - mailsBefore, 1)
assert.match(mails.sent.at(-1).text, /Zahlung fehlgeschlagen/)
})
test('aktivierung_mailt', async () => {
const doc = await sf.getFeatureDoc('c1', 'p3', 'news')
assert.equal(doc.state, 'setup')
aw.seed('workorders', { ...aw.docs('workorders').get(doc.ticketId), status: 'Closed' })
const mailsBefore = mails.sent.length
const done = await sf.runActivation()
assert.equal(done.length, 1)
assert.equal((await sf.getFeatureDoc('c1', 'p3', 'news')).state, 'active')
assert.equal(mails.sent.length - mailsBefore, 1)
assert.equal(mails.sent.at(-1).subject, 'Deine News-Pinnwand ist bereit')
assert.equal(mails.sent.at(-1).to[0].address, 'k@example.invalid')
assert.deepEqual(await sf.runActivation(), [])
assert.equal(mails.sent.length - mailsBefore, 1)
})
+5 -2
View File
@@ -6,17 +6,20 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
const DIR = new URL('../server/mail-templates/', import.meta.url)
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1);
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand)
const EXPECTED = [
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
'K12',
'K13',
'K14',
'K15',
'K16',
...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`),
'T12',
'T13',
'T14',
'T15',
'T16',
]
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
@@ -33,7 +36,7 @@ for (const id of EXPECTED) {
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen) oder [Zeit] (Push-Zeitmail)
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz] oder [Zeit]')
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit)\]/.test(tpl.subject) || id === 'T01' || id === 'T16', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz] oder [Zeit] (T16 Funktion bestellt ohne Kennung)')
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
+31
View File
@@ -131,3 +131,34 @@ test('parallele_webhooks_ein_ticket', async () => {
assert.equal(tickets().length, before + 1)
assert.ok((await sf.getFeatureDoc('c1', 'p4', 'news')).ticketId)
})
test('abgelaufen_fasst_setup_und_active_nicht_an', async () => {
// cs_1 ist bezahlt ('setup' bzw. nach dem Schliessen des Tickets 'active'): ein spaetes expired aendert nichts
const before = await sf.getFeatureDoc('c1', 'p1', 'news')
assert.notEqual(before.state, 'ordered')
const after = await sf.markCheckoutFailed('cs_1')
assert.equal(after.state, before.state)
assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).state, before.state)
assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).purchaseStatus, 'paid')
})
test('bezahlt_und_abgelaufen_gleichzeitig_bleiben_stimmig', async () => {
const pid = 'p_race'
aw.seed('websiteProjects', { $id: pid, customerId: 'c1', projectName: 'Rennen' })
await sf.markOrdered({ customerId: 'c1', projectId: pid, key: 'news', sessionId: 'cs_race', consent })
const ticketsBefore = tickets().length
await Promise.all([
sf.markPaid({ sessionId: 'cs_race', invoiceId: 'in_r', invoiceUrl: '' }),
sf.markCheckoutFailed('cs_race'),
])
const doc = await sf.getFeatureDoc('c1', pid, 'news')
// entweder bezahlt (mit Ticket) oder zurueckgesetzt (ohne): nie 'setup' ohne Ticket oder 'off' mit Ticket
if (doc.state === 'setup') {
assert.ok(doc.ticketId)
assert.equal(tickets().length, ticketsBefore + 1)
} else {
assert.equal(doc.state, 'off')
assert.equal(doc.ticketId || '', '')
assert.equal(tickets().length, ticketsBefore)
}
})