From 770cbe32b72080dab54f0d7057a4b25aa370784e Mon Sep 17 00:00:00 2001 From: knso Date: Wed, 30 Sep 2026 16:29:12 +0000 Subject: [PATCH] Funktionen: Bestellschutz, serialisiertes markPaid, idx_session Co-Authored-By: Claude Opus 5.5 --- scripts/setup-abo-collections.mjs | 1 + server/services/siteFeatures.js | 50 +++++++++++++++++++------------ test/siteFeatures.test.js | 21 +++++++++++++ 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/scripts/setup-abo-collections.mjs b/scripts/setup-abo-collections.mjs index f5d3b24..3908ade 100644 --- a/scripts/setup-abo-collections.mjs +++ b/scripts/setup-abo-collections.mjs @@ -276,6 +276,7 @@ await ensureCollection( { key: 'idx_customer', type: 'key', attributes: ['customerId'] }, { key: 'idx_state', type: 'key', attributes: ['state'] }, { key: 'idx_sitekey', type: 'unique', attributes: ['siteKey'] }, + { key: 'idx_session', type: 'key', attributes: ['stripeSessionId'] }, ] ) diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 3f3c4df..3b55a1c 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -1,6 +1,7 @@ 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 { createFeatureTicket } from './ticketAdmin.js' export const FEATURE_KEYS = ['news'] @@ -117,6 +118,7 @@ export async function listCustomerFeatures(customerId) { const projects = await listDocuments(config.collections.websiteProjects, [ Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), + Query.limit(1000), ]) const docs = await listDocuments(config.collections.portalFeatures, [ Query.equal('customerId', customerId), @@ -142,6 +144,9 @@ export async function listCustomerFeatures(customerId) { export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) { assertKey(key) const doc = await ensureFeatureDoc(customerId, projectId, key) + if (doc.state === 'setup' || doc.state === 'active') { + throw httpError(409, 'Diese Funktion ist bereits bestellt oder freigeschaltet') + } return updateDocument(config.collections.portalFeatures, doc.$id, { featureKey: key, ...stateFields('ordered'), @@ -164,28 +169,35 @@ async function findBySession(sessionId) { return doc || null } -/** Idempotent: Ticket und Wechsel nach 'setup' nur beim Uebergang aus 'ordered'. */ +/** + * Idempotent und je Session serialisiert: nur der Uebergang aus 'ordered' legt das Ticket an. Erst + * wird auf 'setup' gestellt, dann das Ticket erstellt; scheitert das, bleibt 'setup' ohne ticketId + * (das Portal zeigt "Ticket fehlt") und der Fehler wird weitergereicht. + */ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) { - const doc = await findBySession(sessionId) - if (!doc) return { doc: null, created: false } - if (doc.state !== 'ordered') return { doc, created: false } + if (!sessionId) return { doc: null, created: false } + return withLocks([`feature-paid:${sessionId}`], async () => { + const doc = await findBySession(sessionId) + if (!doc) return { doc: null, created: false } + if (doc.state !== 'ordered') return { doc, created: false } - const project = await getDocument(config.collections.websiteProjects, doc.projectId) - const { ticketId } = await createFeatureTicket({ - customerId: doc.customerId, - project, - featureKey: doc.featureKey, - siteKey: doc.siteKey, + const setup = await updateDocument(config.collections.portalFeatures, doc.$id, { + ...stateFields('setup'), + purchaseStatus: 'paid', + unlockedByPurchase: true, + stripeInvoiceId: invoiceId || '', + invoiceUrl: invoiceUrl || '', + }) + const project = await getDocument(config.collections.websiteProjects, doc.projectId) + const { ticketId } = await createFeatureTicket({ + customerId: doc.customerId, + project, + featureKey: doc.featureKey, + siteKey: doc.siteKey, + }) + const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId }) + return { doc: updated, created: true } }) - const updated = await updateDocument(config.collections.portalFeatures, doc.$id, { - ...stateFields('setup'), - purchaseStatus: 'paid', - unlockedByPurchase: true, - stripeInvoiceId: invoiceId || '', - invoiceUrl: invoiceUrl || '', - ticketId, - }) - return { doc: updated, created: true } } /** Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck. */ diff --git a/test/siteFeatures.test.js b/test/siteFeatures.test.js index beeb5dd..c58b261 100644 --- a/test/siteFeatures.test.js +++ b/test/siteFeatures.test.js @@ -110,3 +110,24 @@ test('lange_projekt_id_bekommt_gueltige_dokument_id', async () => { const doc = await sf.adminActivate('c1', 'p_' + 'x'.repeat(40), 'news', {}) assert.ok(doc.$id.length <= 36) }) + +test('bestellen_bei_aktiver_funktion_409', async () => { + const doc = await sf.adminActivate('c1', 'p1', 'news', {}).catch(() => null) + const active = doc || (await sf.getFeatureDoc('c1', 'p1', 'news')) + assert.equal(active.state, 'active') + await assert.rejects( + () => sf.markOrdered({ customerId: 'c1', projectId: 'p1', key: 'news', sessionId: 'cs_x', consent }), + { status: 409 } + ) + assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).state, 'active') +}) + +test('parallele_webhooks_ein_ticket', async () => { + aw.seed('websiteProjects', { $id: 'p4', customerId: 'c1', projectName: 'Parallel' }) + await sf.markOrdered({ customerId: 'c1', projectId: 'p4', key: 'news', sessionId: 'cs_par', consent }) + const before = tickets().length + const res = await Promise.all([1, 2, 3].map(() => sf.markPaid({ sessionId: 'cs_par', invoiceId: 'in_p', invoiceUrl: 'u' }))) + assert.equal(res.filter((r) => r.created).length, 1) + assert.equal(tickets().length, before + 1) + assert.ok((await sf.getFeatureDoc('c1', 'p4', 'news')).ticketId) +})