Funktionen: Bestellung serialisiert, Sitzung nachtragen, Freischaltung einmalig
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0cc35bf1a5
commit
8374a570aa
3 files changed
+120
-34
No files matched your search
+23
-16
@@ -2,6 +2,7 @@ import { Router } from 'express'
|
|||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
import { getDocument } from '../services/appwriteAdmin.js'
|
import { getDocument } from '../services/appwriteAdmin.js'
|
||||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||||
|
import { withLocks } from '../services/keyedLock.js'
|
||||||
import { createFeatureCheckout } from '../services/stripeBilling.js'
|
import { createFeatureCheckout } from '../services/stripeBilling.js'
|
||||||
import { assertOwnProject, getFeatureDoc, listCustomerFeatures, markOrdered, runActivation } from '../services/siteFeatures.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) => {
|
router.get('/', requireSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const customerId = getSessionCustomerId(req)
|
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 })
|
res.json({ projects: await listCustomerFeatures(customerId), setupEstimate: config.funktionen.setupEstimate })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
fail(res, err)
|
fail(res, err)
|
||||||
@@ -38,21 +40,26 @@ router.post('/news/checkout', requireSession, async (req, res) => {
|
|||||||
}
|
}
|
||||||
const customerId = getSessionCustomerId(req)
|
const customerId = getSessionCustomerId(req)
|
||||||
await assertOwnProject(customerId, projectId)
|
await assertOwnProject(customerId, projectId)
|
||||||
// Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv oder eine noch gueltige offene Bestellung -> 409
|
// Pruefen, Sitzung anlegen und eintragen als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine
|
||||||
const existing = await getFeatureDoc(customerId, projectId, 'news')
|
// zwei Sitzungen erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument
|
||||||
const freshOrder = existing?.state === 'ordered' && Date.now() - new Date(existing.orderedAt || 0).getTime() < DAY_MS
|
const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => {
|
||||||
if (existing?.state === 'setup' || existing?.state === 'active' || freshOrder) {
|
// Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv oder eine noch gueltige offene Bestellung -> 409
|
||||||
return res.status(409).json({ error: 'Die News-Pinnwand ist für dieses Projekt schon bestellt.' })
|
const existing = await getFeatureDoc(customerId, projectId, 'news')
|
||||||
}
|
const freshOrder = existing?.state === 'ordered' && Date.now() - new Date(existing.orderedAt || 0).getTime() < DAY_MS
|
||||||
const customer = await getDocument(config.collections.customers, customerId)
|
if (existing?.state === 'setup' || existing?.state === 'active' || freshOrder) {
|
||||||
const now = new Date().toISOString()
|
throw Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 })
|
||||||
const session = await createFeatureCheckout(customer, { projectId, key: 'news', consentAt: now })
|
}
|
||||||
await markOrdered({
|
const customer = await getDocument(config.collections.customers, customerId)
|
||||||
customerId,
|
const now = new Date().toISOString()
|
||||||
projectId,
|
const created = await createFeatureCheckout(customer, { projectId, key: 'news', consentAt: now })
|
||||||
key: 'news',
|
await markOrdered({
|
||||||
sessionId: session.id,
|
customerId,
|
||||||
consent: { text: config.funktionen.consentText, at: now, ip: req.ip, userAgent: req.get('user-agent') },
|
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 })
|
res.json({ url: session.url })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -265,11 +265,17 @@ export async function activateClosedTickets({ now = new Date(), customerId = nul
|
|||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
if (ticket.status !== 'Closed') continue
|
if (ticket.status !== 'Closed') continue
|
||||||
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
|
// Je Dokument gesperrt und neu gelesen: GET /api/funktionen, ein zweiter Tab und der 5-Minuten-Job
|
||||||
...stateFields('active'),
|
// duerfen dieselbe Funktion nicht beide freischalten (und beide benachrichtigen)
|
||||||
activatedAt: now.toISOString(),
|
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
|
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) {
|
async function onFeaturePaid(session) {
|
||||||
const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice)
|
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
|
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) {
|
if (customer?.email) {
|
||||||
await sendCustomerMail(customer.email, {
|
try {
|
||||||
template: 'K15',
|
await sendCustomerMail(customer.email, {
|
||||||
values: {
|
template: 'K15',
|
||||||
kunde_name: customer.name || '',
|
values: {
|
||||||
projekt: await projectName(doc.projectId),
|
kunde_name: customer.name || '',
|
||||||
preis: eur(config.newsSetup.amountCents),
|
projekt: await projectName(doc.projectId),
|
||||||
zeit: config.funktionen.setupEstimate,
|
preis: eur(config.newsSetup.amountCents),
|
||||||
rechnung_url: invoiceUrl,
|
zeit: config.funktionen.setupEstimate,
|
||||||
},
|
rechnung_url: invoiceUrl,
|
||||||
blocks: { rechnung: Boolean(invoiceUrl) },
|
},
|
||||||
}).catch((err) => console.error('[funktionen] K15 fehlgeschlagen:', err.message))
|
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. */
|
/** Stripe-Ereignis eines Funktions-Checkouts (isFeatureCheckoutEvent); idempotent, Fehler -> Stripe wiederholt. */
|
||||||
|
|||||||
@@ -178,3 +178,38 @@ test('aktivierung_mailt', async () => {
|
|||||||
assert.deepEqual(await sf.runActivation(), [])
|
assert.deepEqual(await sf.runActivation(), [])
|
||||||
assert.equal(mails.sent.length - mailsBefore, 1)
|
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)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user