Funktionen: Buchung per Stripe, Webhook, Mails und Freischalt-Job
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
770cbe32b7
commit
0cc35bf1a5
12 files changed
+642
-8
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user