From 8374a570aa5060b6100bdf534235074de7641cfb Mon Sep 17 00:00:00 2001 From: knso Date: Wed, 30 Sep 2026 16:35:59 +0000 Subject: [PATCH] Funktionen: Bestellung serialisiert, Sitzung nachtragen, Freischaltung einmalig Co-Authored-By: Claude Opus 5.5 --- server/routes/funktionen.js | 39 +++++++++------- server/services/siteFeatures.js | 80 +++++++++++++++++++++++++-------- test/funktionenRoutes.test.js | 35 +++++++++++++++ 3 files changed, 120 insertions(+), 34 deletions(-) diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index aedc630..cdda912 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -2,6 +2,7 @@ import { Router } from 'express' import { config } from '../config.js' import { getDocument } from '../services/appwriteAdmin.js' import { getSessionCustomerId, requireSession } from '../middleware/session.js' +import { withLocks } from '../services/keyedLock.js' import { createFeatureCheckout } from '../services/stripeBilling.js' import { assertOwnProject, getFeatureDoc, listCustomerFeatures, markOrdered, runActivation } from '../services/siteFeatures.js' @@ -17,7 +18,8 @@ function fail(res, err) { router.get('/', requireSession, async (req, res) => { try { const customerId = getSessionCustomerId(req) - await runActivation({ customerId }) + // Ein Fehler beim Freischalten (z. B. Ticketsystem) darf die Liste nicht verhindern + await runActivation({ customerId }).catch((err) => console.error('[funktionen] Freischalten fehlgeschlagen:', err.message)) res.json({ projects: await listCustomerFeatures(customerId), setupEstimate: config.funktionen.setupEstimate }) } catch (err) { fail(res, err) @@ -38,21 +40,26 @@ router.post('/news/checkout', requireSession, async (req, res) => { } 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') }, + // Pruefen, Sitzung anlegen und eintragen als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine + // zwei Sitzungen erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument + const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => { + // 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) { + throw Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 }) + } + const customer = await getDocument(config.collections.customers, customerId) + const now = new Date().toISOString() + const created = await createFeatureCheckout(customer, { projectId, key: 'news', consentAt: now }) + await markOrdered({ + customerId, + projectId, + key: 'news', + sessionId: created.id, + consent: { text: config.funktionen.consentText, at: now, ip: req.ip, userAgent: req.get('user-agent') }, + }) + return created }) res.json({ url: session.url }) } catch (err) { diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 430043b..d12577b 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -265,11 +265,17 @@ export async function activateClosedTickets({ now = new Date(), customerId = nul throw err } if (ticket.status !== 'Closed') continue - const updated = await updateDocument(config.collections.portalFeatures, doc.$id, { - ...stateFields('active'), - activatedAt: now.toISOString(), + // Je Dokument gesperrt und neu gelesen: GET /api/funktionen, ein zweiter Tab und der 5-Minuten-Job + // duerfen dieselbe Funktion nicht beide freischalten (und beide benachrichtigen) + const updated = await withLocks([`feature-activate:${doc.$id}`], async () => { + const fresh = await getDocument(config.collections.portalFeatures, doc.$id) + if (fresh.state !== 'setup') return null + return updateDocument(config.collections.portalFeatures, doc.$id, { + ...stateFields('active'), + activatedAt: now.toISOString(), + }) }) - activated.push(updated) + if (updated) activated.push(updated) } return activated } @@ -353,25 +359,63 @@ async function teamNotice(doc, customer, { failed = false } = {}) { }) } +/** + * Bezahlte Sitzung, die kein Dokument ueber die Sitzungs-ID findet (zwei parallele Bestellungen haben die + * ID ueberschrieben): das Dokument aus den Metadaten nehmen, wenn es noch 'ordered' oder 'off' ist, und die + * bezahlte Sitzung eintragen. Sonst laut loggen - der Kunde hat gezahlt und nichts wird eingerichtet. + */ +async function adoptPaidSession(session) { + const { customerId, projectId, featureKey } = session.metadata || {} + const doc = customerId && projectId && featureKey ? await getFeatureDoc(customerId, projectId, featureKey) : null + if (!doc || (doc.state !== 'ordered' && doc.state !== 'off')) { + console.error( + `[funktionen] BEZAHLTE Sitzung ${session.id} ohne passende Bestellung (Kunde ${customerId}, Projekt ${projectId}, Zustand ${doc?.state || 'kein Dokument'}) - bitte von Hand pruefen` + ) + return false + } + await updateDocument(config.collections.portalFeatures, doc.$id, { + ...stateFields('ordered'), + stripeSessionId: session.id, + }) + return true +} + async function onFeaturePaid(session) { const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice) - const { doc, created } = await markPaid({ sessionId: session.id, invoiceId, invoiceUrl }) + const paid = { sessionId: session.id, invoiceId, invoiceUrl } + let { doc, created } = await markPaid(paid) + if (!doc && (await adoptPaidSession(session))) ({ doc, created } = await markPaid(paid)) if (!created) return - const customer = await customerOf(doc.customerId) + // Ab hier ist die Bestellung gebucht; ein Wiederholungsversuch von Stripe sieht created:false und + // wuerde keine Mail mehr schicken. Darum scheitert jede Mail einzeln und der Webhook antwortet trotzdem 200. + let customer = null + try { + customer = await customerOf(doc.customerId) + } catch (err) { + console.error('[funktionen] Kunde fuer K15/T16 nicht lesbar:', err.message) + } 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)) + try { + 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) + } + } + try { + await teamNotice(doc, customer) + } catch (err) { + console.error('[funktionen] T16 fehlgeschlagen:', err.message) } - await teamNotice(doc, customer) } /** Stripe-Ereignis eines Funktions-Checkouts (isFeatureCheckoutEvent); idempotent, Fehler -> Stripe wiederholt. */ diff --git a/test/funktionenRoutes.test.js b/test/funktionenRoutes.test.js index 205842c..903eb58 100644 --- a/test/funktionenRoutes.test.js +++ b/test/funktionenRoutes.test.js @@ -178,3 +178,38 @@ test('aktivierung_mailt', async () => { assert.deepEqual(await sf.runActivation(), []) assert.equal(mails.sent.length - mailsBefore, 1) }) + +test('parallele_checkouts_eine_sitzung', async () => { + aw.seed('websiteProjects', { $id: 'p8', customerId: 'c1', projectName: 'Gärtnerei' }) + const before = sessions().length + const results = await Promise.all([checkout('p8'), checkout('p8')]) + assert.deepEqual(results.map((r) => r.status).sort(), [200, 409]) + assert.equal(sessions().length, before + 1) +}) + +test('bezahlte_sitzung_mit_ueberschriebener_id_wird_zugeordnet', async () => { + aw.seed('websiteProjects', { $id: 'p9', customerId: 'c1', projectName: 'Tischlerei' }) + await checkout('p9') + const paidId = sessions().at(-1).id + const doc = await sf.getFeatureDoc('c1', 'p9', 'news') + aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), stripeSessionId: 'cs_ueberschrieben' }) + const wosBefore = workorders().length + assert.equal((await postWebhook(event('checkout.session.completed', paidSession(paidId, 'p9')))).status, 200) + const after = await sf.getFeatureDoc('c1', 'p9', 'news') + assert.equal(after.state, 'setup') + assert.equal(after.stripeSessionId, paidId) + assert.equal(workorders().length, wosBefore + 1) +}) + +test('parallele_aktivierung_mailt_einmal', async () => { + aw.seed('websiteProjects', { $id: 'p10', customerId: 'c1', projectName: 'Schreinerei' }) + await checkout('p10') + const id = sessions().at(-1).id + await postWebhook(event('checkout.session.completed', paidSession(id, 'p10'))) + const doc = await sf.getFeatureDoc('c1', 'p10', 'news') + aw.seed('workorders', { ...aw.docs('workorders').get(doc.ticketId), status: 'Closed' }) + const mailsBefore = mails.sent.length + const [a, b] = await Promise.all([sf.runActivation(), sf.runActivation({ customerId: 'c1' })]) + assert.equal(a.length + b.length, 1) + assert.equal(mails.sent.slice(mailsBefore).filter((m) => m.subject === 'Deine News-Pinnwand ist bereit').length, 1) +})