From 0cc35bf1a5c06eab65001f578f6aef3d7906e319 Mon Sep 17 00:00:00 2001 From: knso Date: Wed, 30 Sep 2026 16:33:48 +0000 Subject: [PATCH] Funktionen: Buchung per Stripe, Webhook, Mails und Freischalt-Job Co-Authored-By: Claude Opus 5.5 --- server/config.js | 7 + server/index.js | 10 + .../K15 Bestellung Funktion.html | 54 ++++++ .../mail-templates/K16 Funktion bereit.html | 48 +++++ .../mail-templates/T16 Funktion bestellt.html | 50 +++++ server/routes/abo.js | 6 + server/routes/funktionen.js | 63 ++++++ server/services/siteFeatures.js | 152 ++++++++++++++- server/services/stripeBilling.js | 42 ++++ test/funktionenRoutes.test.js | 180 ++++++++++++++++++ test/mailTemplates.test.js | 7 +- test/siteFeatures.test.js | 31 +++ 12 files changed, 642 insertions(+), 8 deletions(-) create mode 100644 server/mail-templates/K15 Bestellung Funktion.html create mode 100644 server/mail-templates/K16 Funktion bereit.html create mode 100644 server/mail-templates/T16 Funktion bestellt.html create mode 100644 server/routes/funktionen.js create mode 100644 test/funktionenRoutes.test.js diff --git a/server/config.js b/server/config.js index 1601fdb..4ef99a6 100644 --- a/server/config.js +++ b/server/config.js @@ -144,6 +144,13 @@ export const config = { products, mailStorage, newsSetup, + // Funktionen-Tab (News-Pinnwand): Zeiteinschaetzung auf der Buchungsseite und Wortlaut der Zustimmung, + // der mit Zeitstempel am Feature-Dokument gespeichert wird (Widerruf, § 356 Abs. 4 BGB) + funktionen: { + setupEstimate: 'meist innerhalb von 1–2 Wochen', + consentText: + 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).', + }, // Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an. // Die Werte kommen aus dem Produktkatalog (products.wartung). diff --git a/server/index.js b/server/index.js index 107c3b5..8c75f8f 100644 --- a/server/index.js +++ b/server/index.js @@ -12,9 +12,11 @@ import customerDataRoutes from './routes/customerData.js' import portalAdminRoutes from './routes/portalAdmin.js' import chatRoutes from './routes/chat.js' import aboRoutes from './routes/abo.js' +import funktionenRoutes from './routes/funktionen.js' import takeoverCreditRoutes from './routes/takeoverCredits.js' import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgePendingChatUploads } from './services/chatAttachments.js' +import { runActivation } from './services/siteFeatures.js' import giteaWebhookRoutes from './routes/webhook/gitea.js' import pushTimeRoutes from './routes/pushTime.js' import adminGiteaRoutes from './routes/admin/gitea.js' @@ -60,6 +62,7 @@ app.use('/api', customerDataRoutes) app.use('/api/chat', chatRoutes) app.use('/api/abo/takeover-credits', takeoverCreditRoutes) app.use('/api/abo', aboRoutes) +app.use('/api/funktionen', funktionenRoutes) app.use('/api/portal-admin', portalAdminRoutes) app.use('/api/admin/gitea', adminGiteaRoutes) app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes) @@ -130,6 +133,13 @@ const server = app.listen(config.port, '0.0.0.0', () => { .catch((err) => console.warn('[chat] Aufräumen fehlgeschlagen:', err.message)) setTimeout(purgeChat, 90 * 1000).unref() setInterval(purgeChat, 60 * 60 * 1000).unref() + + // Funktionen (News-Pinnwand): geschlossene Einrichtungs-Tickets freischalten und den Kunden benachrichtigen (alle 5 Minuten) + const activateFeatures = () => + runActivation() + .then((done) => done.length && console.log(`[funktionen] ${done.length} Funktion(en) freigeschaltet`)) + .catch((err) => console.warn('[funktionen] Freischalten fehlgeschlagen:', err.message)) + setInterval(activateFeatures, 5 * 60 * 1000).unref() }) server.on('error', (err) => { diff --git a/server/mail-templates/K15 Bestellung Funktion.html b/server/mail-templates/K15 Bestellung Funktion.html new file mode 100644 index 0000000..53d7b01 --- /dev/null +++ b/server/mail-templates/K15 Bestellung Funktion.html @@ -0,0 +1,54 @@ + + + + + + + + +Deine Bestellung: News-Pinnwand + + + + +Deine Bestellung der News-Pinnwand ist bei uns eingegangen. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/K16 Funktion bereit.html b/server/mail-templates/K16 Funktion bereit.html new file mode 100644 index 0000000..f9c2fde --- /dev/null +++ b/server/mail-templates/K16 Funktion bereit.html @@ -0,0 +1,48 @@ + + + + + + + + +Deine News-Pinnwand ist bereit + + + + +Deine News-Pinnwand ist freigeschaltet. ͏ ͏ ͏ + + + + + diff --git a/server/mail-templates/T16 Funktion bestellt.html b/server/mail-templates/T16 Funktion bestellt.html new file mode 100644 index 0000000..4a52403 --- /dev/null +++ b/server/mail-templates/T16 Funktion bestellt.html @@ -0,0 +1,50 @@ + + + + + + + + +Neue Funktion bestellt: News-Pinnwand + + + + +{{kunde_name}}: News-Pinnwand, Projekt {{projekt}}. ͏ ͏ ͏ + + + + + diff --git a/server/routes/abo.js b/server/routes/abo.js index edfb105..eed9c3c 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -18,6 +18,7 @@ import { reconcilePendingSwitch, switchToWartung, } from '../services/planSwitch.js' +import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent } from '../services/siteFeatures.js' import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js' import { ACTIVE_STATUSES, @@ -518,6 +519,11 @@ async function recordAbschluss(sub, session) { async function handleStripeEvent(event) { const obj = event.data.object + // Einmalkauf einer Funktion (News-Pinnwand): eigener Weg, der Abo-Zweig unten bleibt unberuehrt + if (isFeatureCheckoutEvent(event)) { + await handleFeatureCheckoutEvent(event) + return + } switch (event.type) { case 'checkout.session.completed': { if (obj.mode !== 'subscription' || !obj.subscription) return diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js new file mode 100644 index 0000000..aedc630 --- /dev/null +++ b/server/routes/funktionen.js @@ -0,0 +1,63 @@ +import { Router } from 'express' +import { config } from '../config.js' +import { getDocument } from '../services/appwriteAdmin.js' +import { getSessionCustomerId, requireSession } from '../middleware/session.js' +import { createFeatureCheckout } from '../services/stripeBilling.js' +import { assertOwnProject, getFeatureDoc, listCustomerFeatures, markOrdered, runActivation } from '../services/siteFeatures.js' + +const router = Router() +const DAY_MS = 24 * 60 * 60 * 1000 + +function fail(res, err) { + const status = err.status >= 400 && err.status < 600 ? err.status : 500 + return res.status(status).json({ error: err.message || 'Interner Serverfehler' }) +} + +// Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet +router.get('/', requireSession, async (req, res) => { + try { + const customerId = getSessionCustomerId(req) + await runActivation({ customerId }) + res.json({ projects: await listCustomerFeatures(customerId), setupEstimate: config.funktionen.setupEstimate }) + } catch (err) { + fail(res, err) + } +}) + +router.post('/news/checkout', requireSession, async (req, res) => { + try { + if (req.session.viewAs) { + return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' }) + } + const { projectId, consent } = req.body || {} + if (consent !== true) { + return res.status(400).json({ error: 'Bitte stimme der Erklärung zu, um zu bestellen.' }) + } + if (typeof projectId !== 'string' || !projectId) { + return res.status(400).json({ error: 'Bitte wähle ein Projekt.' }) + } + const customerId = getSessionCustomerId(req) + await assertOwnProject(customerId, projectId) + // Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv oder eine noch gueltige offene Bestellung -> 409 + const existing = await getFeatureDoc(customerId, projectId, 'news') + const freshOrder = existing?.state === 'ordered' && Date.now() - new Date(existing.orderedAt || 0).getTime() < DAY_MS + if (existing?.state === 'setup' || existing?.state === 'active' || freshOrder) { + return res.status(409).json({ error: 'Die News-Pinnwand ist für dieses Projekt schon bestellt.' }) + } + const customer = await getDocument(config.collections.customers, customerId) + const now = new Date().toISOString() + const session = await createFeatureCheckout(customer, { projectId, key: 'news', consentAt: now }) + await markOrdered({ + customerId, + projectId, + key: 'news', + sessionId: session.id, + consent: { text: config.funktionen.consentText, at: now, ip: req.ip, userAgent: req.get('user-agent') }, + }) + res.json({ url: session.url }) + } catch (err) { + fail(res, err) + } +}) + +export default router diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 3b55a1c..430043b 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -2,6 +2,8 @@ import { createHash, randomBytes } from 'node:crypto' import { config } from '../config.js' import { createDocument, getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js' import { withLocks } from './keyedLock.js' +import { sendAdminNotice, sendCustomerMail } from './mailer.js' +import { getStripe } from './stripeBilling.js' import { createFeatureTicket } from './ticketAdmin.js' export const FEATURE_KEYS = ['news'] @@ -200,13 +202,20 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) { }) } -/** Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck. */ +/** + * Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck. + * Unter demselben Schluessel wie markPaid, damit ein spaetes 'expired' nie eine gerade bezahlte + * Bestellung ('setup') oder eine aktive Funktion anfasst. + */ export async function markCheckoutFailed(sessionId) { - const doc = await findBySession(sessionId) - if (!doc || doc.state !== 'ordered') return doc - return updateDocument(config.collections.portalFeatures, doc.$id, { - ...stateFields('off'), - purchaseStatus: 'failed', + if (!sessionId) return null + return withLocks([`feature-paid:${sessionId}`], async () => { + const doc = await findBySession(sessionId) + if (!doc || doc.state !== 'ordered') return doc + return updateDocument(config.collections.portalFeatures, doc.$id, { + ...stateFields('off'), + purchaseStatus: 'failed', + }) }) } @@ -274,3 +283,134 @@ export async function findActiveBySiteKey(siteKey) { ]) return doc || null } + +// --------------------------------------------------------------------------- +// Stripe-Webhook, Mails und Freischalt-Job +// --------------------------------------------------------------------------- + +const eur = (cents) => `${(cents / 100).toFixed(2).replace('.', ',')} €` +const portalUrl = () => `${config.publicBaseUrl}/dashboard.html#news` + +/** Ereignisse, die handleFeatureCheckoutEvent versteht (Funktions-Checkout: mode 'payment' mit featureKey). */ +export const FEATURE_EVENT_TYPES = [ + 'checkout.session.completed', + 'checkout.session.async_payment_succeeded', + 'checkout.session.async_payment_failed', + 'checkout.session.expired', +] + +export function isFeatureCheckoutEvent(event) { + const obj = event?.data?.object + return obj?.mode === 'payment' && Boolean(obj.metadata?.featureKey) && FEATURE_EVENT_TYPES.includes(event.type) +} + +async function projectName(projectId) { + try { + return (await getDocument(config.collections.websiteProjects, projectId)).projectName || '' + } catch (err) { + if (err.status === 404) return '' + throw err + } +} + +async function customerOf(customerId) { + try { + return await getDocument(config.collections.customers, customerId) + } catch (err) { + if (err.status === 404) return null + throw err + } +} + +/** Link zur Stripe-Rechnung; fehlt sie noch oder ist sie nicht lesbar, geht die Mail ohne Link raus. */ +async function invoiceOf(invoiceRef) { + const id = typeof invoiceRef === 'string' ? invoiceRef : invoiceRef?.id + if (!id) return { invoiceId: '', invoiceUrl: '' } + try { + const invoice = await getStripe().invoices.retrieve(id) + return { invoiceId: id, invoiceUrl: invoice.hosted_invoice_url || '' } + } catch (err) { + console.warn(`[funktionen] Rechnung ${id} nicht lesbar:`, err.message) + return { invoiceId: id, invoiceUrl: '' } + } +} + +async function ticketWoidOf(doc) { + return doc.ticketId ? (await ticketWoid(doc.ticketId)) || '' : '' +} + +async function teamNotice(doc, customer, { failed = false } = {}) { + await sendAdminNotice({ + template: 'T16', + values: { + kunde_name: customer?.name || customer?.email || doc.customerId, + email: customer?.email || '', + projekt: await projectName(doc.projectId), + woid: failed ? '' : await ticketWoidOf(doc), + sitekey: failed ? '' : doc.siteKey || '', + }, + blocks: { zahlung_fehlgeschlagen: failed }, + }) +} + +async function onFeaturePaid(session) { + const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice) + const { doc, created } = await markPaid({ sessionId: session.id, invoiceId, invoiceUrl }) + if (!created) return + const customer = await customerOf(doc.customerId) + if (customer?.email) { + await sendCustomerMail(customer.email, { + template: 'K15', + values: { + kunde_name: customer.name || '', + projekt: await projectName(doc.projectId), + preis: eur(config.newsSetup.amountCents), + zeit: config.funktionen.setupEstimate, + rechnung_url: invoiceUrl, + }, + blocks: { rechnung: Boolean(invoiceUrl) }, + }).catch((err) => console.error('[funktionen] K15 fehlgeschlagen:', err.message)) + } + await teamNotice(doc, customer) +} + +/** Stripe-Ereignis eines Funktions-Checkouts (isFeatureCheckoutEvent); idempotent, Fehler -> Stripe wiederholt. */ +export async function handleFeatureCheckoutEvent(event) { + const session = event.data.object + switch (event.type) { + case 'checkout.session.completed': + // SEPA: die Sitzung ist abgeschlossen, das Geld noch nicht da -> erst async_payment_succeeded + if (session.payment_status !== 'paid') return + await onFeaturePaid(session) + return + case 'checkout.session.async_payment_succeeded': + await onFeaturePaid(session) + return + case 'checkout.session.async_payment_failed': { + const doc = await markCheckoutFailed(session.id) + if (doc && doc.state === 'off') await teamNotice(doc, await customerOf(doc.customerId), { failed: true }) + return + } + case 'checkout.session.expired': + await markCheckoutFailed(session.id) + return + } +} + +/** Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active', K16 an jeden betroffenen Kunden. */ +export async function runActivation({ now = new Date(), customerId = null } = {}) { + const activated = await activateClosedTickets({ now, customerId }) + for (const doc of activated) { + try { + const customer = await customerOf(doc.customerId) + if (!customer?.email) continue + await sendCustomerMail(customer.email, { + template: 'K16', + values: { kunde_name: customer.name || '', projekt: await projectName(doc.projectId), portal_url: portalUrl() }, + }) + } catch (err) { + console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message) + } + } + return activated +} diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js index cf7c071..4da6906 100644 --- a/server/services/stripeBilling.js +++ b/server/services/stripeBilling.js @@ -591,6 +591,48 @@ export async function createAboCheckout(customer, consent, productKey, offer = n return session.url } +/** + * Checkout fuer die einmalige Einrichtung einer Funktion (News-Pinnwand, 30 € brutto, kein Abo). Die Sitzung + * verfaellt nach 24 Stunden (Stripe-Hoechstwert; fuenf Minuten Luft fuer die Laufzeit des Aufrufs), die + * Rechnung erzeugt Stripe selbst. Nur die Zuordnung steht in den Metadaten, der Zustimmungswortlaut nicht. + * @returns {Promise<{ id: string, url: string }>} + */ +export async function createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) { + const stripe = getStripe() + const [priceId, stripeCustomerId] = await Promise.all([ensurePrice('newsSetup'), getOrCreateStripeCustomer(customer)]) + const params = { + mode: 'payment', + customer: stripeCustomerId, + client_reference_id: customer.$id, + line_items: [{ price: priceId, quantity: 1 }], + payment_method_types: ['card', 'sepa_debit'], + locale: 'de', + billing_address_collection: 'required', + customer_update: { address: 'auto', name: 'auto' }, + invoice_creation: { + enabled: true, + invoice_data: { description: config.newsSetup.name, footer: config.stripe.invoiceFooter }, + }, + metadata: { customerId: customer.$id, projectId, featureKey: key, ...(consentAt ? { consentAt } : {}) }, + expires_at: Math.floor(Date.now() / 1000) + 24 * 60 * 60 - 5 * 60, + success_url: `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`, + cancel_url: `${config.publicBaseUrl}/dashboard.html#news`, + } + let session + try { + session = await stripe.checkout.sessions.create(params) + } catch (err) { + // SEPA-Lastschrift noch nicht im Stripe-Konto aktiviert -> nur Karte anbieten (wie beim Abo-Checkout) + if (/sepa_debit|payment_method_types/i.test(err.message || '')) { + console.warn('[stripe] SEPA-Lastschrift nicht verfuegbar, Checkout nur mit Karte:', err.message) + session = await stripe.checkout.sessions.create({ ...params, payment_method_types: ['card'] }) + } else { + throw err + } + } + return { id: session.id, url: session.url } +} + export async function createBillingPortalUrl(customerId) { const sub = await getCurrentSubscription(customerId) if (!sub?.stripeCustomerId) { diff --git a/test/funktionenRoutes.test.js b/test/funktionenRoutes.test.js new file mode 100644 index 0000000..205842c --- /dev/null +++ b/test/funktionenRoutes.test.js @@ -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) +}) diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js index 7a6cb33..7e5f589 100644 --- a/test/mailTemplates.test.js +++ b/test/mailTemplates.test.js @@ -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(/([\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') diff --git a/test/siteFeatures.test.js b/test/siteFeatures.test.js index c58b261..b4f43e7 100644 --- a/test/siteFeatures.test.js +++ b/test/siteFeatures.test.js @@ -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) + } +})