Funktionen: Bestellschutz, serialisiertes markPaid, idx_session
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4b1df8fbfa
commit
770cbe32b7
3 files changed
+43
-9
No files matched your search
@@ -276,6 +276,7 @@ await ensureCollection(
|
|||||||
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
|
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
|
||||||
{ key: 'idx_state', type: 'key', attributes: ['state'] },
|
{ key: 'idx_state', type: 'key', attributes: ['state'] },
|
||||||
{ key: 'idx_sitekey', type: 'unique', attributes: ['siteKey'] },
|
{ key: 'idx_sitekey', type: 'unique', attributes: ['siteKey'] },
|
||||||
|
{ key: 'idx_session', type: 'key', attributes: ['stripeSessionId'] },
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createHash, randomBytes } from 'node:crypto'
|
import { createHash, randomBytes } from 'node:crypto'
|
||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
import { createDocument, getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js'
|
import { createDocument, getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js'
|
||||||
|
import { withLocks } from './keyedLock.js'
|
||||||
import { createFeatureTicket } from './ticketAdmin.js'
|
import { createFeatureTicket } from './ticketAdmin.js'
|
||||||
|
|
||||||
export const FEATURE_KEYS = ['news']
|
export const FEATURE_KEYS = ['news']
|
||||||
@@ -117,6 +118,7 @@ export async function listCustomerFeatures(customerId) {
|
|||||||
const projects = await listDocuments(config.collections.websiteProjects, [
|
const projects = await listDocuments(config.collections.websiteProjects, [
|
||||||
Query.equal('customerId', customerId),
|
Query.equal('customerId', customerId),
|
||||||
Query.orderDesc('$createdAt'),
|
Query.orderDesc('$createdAt'),
|
||||||
|
Query.limit(1000),
|
||||||
])
|
])
|
||||||
const docs = await listDocuments(config.collections.portalFeatures, [
|
const docs = await listDocuments(config.collections.portalFeatures, [
|
||||||
Query.equal('customerId', customerId),
|
Query.equal('customerId', customerId),
|
||||||
@@ -142,6 +144,9 @@ export async function listCustomerFeatures(customerId) {
|
|||||||
export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) {
|
export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) {
|
||||||
assertKey(key)
|
assertKey(key)
|
||||||
const doc = await ensureFeatureDoc(customerId, projectId, 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, {
|
return updateDocument(config.collections.portalFeatures, doc.$id, {
|
||||||
featureKey: key,
|
featureKey: key,
|
||||||
...stateFields('ordered'),
|
...stateFields('ordered'),
|
||||||
@@ -164,12 +169,25 @@ async function findBySession(sessionId) {
|
|||||||
return doc || null
|
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 }) {
|
export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
|
||||||
|
if (!sessionId) return { doc: null, created: false }
|
||||||
|
return withLocks([`feature-paid:${sessionId}`], async () => {
|
||||||
const doc = await findBySession(sessionId)
|
const doc = await findBySession(sessionId)
|
||||||
if (!doc) return { doc: null, created: false }
|
if (!doc) return { doc: null, created: false }
|
||||||
if (doc.state !== 'ordered') return { doc, created: false }
|
if (doc.state !== 'ordered') return { doc, created: false }
|
||||||
|
|
||||||
|
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 project = await getDocument(config.collections.websiteProjects, doc.projectId)
|
||||||
const { ticketId } = await createFeatureTicket({
|
const { ticketId } = await createFeatureTicket({
|
||||||
customerId: doc.customerId,
|
customerId: doc.customerId,
|
||||||
@@ -177,15 +195,9 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
|
|||||||
featureKey: doc.featureKey,
|
featureKey: doc.featureKey,
|
||||||
siteKey: doc.siteKey,
|
siteKey: doc.siteKey,
|
||||||
})
|
})
|
||||||
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
|
const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId })
|
||||||
...stateFields('setup'),
|
|
||||||
purchaseStatus: 'paid',
|
|
||||||
unlockedByPurchase: true,
|
|
||||||
stripeInvoiceId: invoiceId || '',
|
|
||||||
invoiceUrl: invoiceUrl || '',
|
|
||||||
ticketId,
|
|
||||||
})
|
|
||||||
return { doc: updated, created: true }
|
return { doc: updated, created: true }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
||||||
|
|||||||
@@ -110,3 +110,24 @@ test('lange_projekt_id_bekommt_gueltige_dokument_id', async () => {
|
|||||||
const doc = await sf.adminActivate('c1', 'p_' + 'x'.repeat(40), 'news', {})
|
const doc = await sf.adminActivate('c1', 'p_' + 'x'.repeat(40), 'news', {})
|
||||||
assert.ok(doc.$id.length <= 36)
|
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)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user