Funktionen: Buchung per Stripe, Webhook, Mails und Freischalt-Job

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-09-30 16:33:48 +00:00
1 parent 770cbe32b7
commit 0cc35bf1a5
12 files changed
+642 -8

No files matched your search

+146 -6
View File
@@ -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
}
+42
View File
@@ -591,6 +591,48 @@ export async function createAboCheckout(customer, consent, productKey, offer = n
return session.url
}
/**
* Checkout fuer die einmalige Einrichtung einer Funktion (News-Pinnwand, 30 € brutto, kein Abo). Die Sitzung
* verfaellt nach 24 Stunden (Stripe-Hoechstwert; fuenf Minuten Luft fuer die Laufzeit des Aufrufs), die
* Rechnung erzeugt Stripe selbst. Nur die Zuordnung steht in den Metadaten, der Zustimmungswortlaut nicht.
* @returns {Promise<{ id: string, url: string }>}
*/
export async function createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) {
const stripe = getStripe()
const [priceId, stripeCustomerId] = await Promise.all([ensurePrice('newsSetup'), getOrCreateStripeCustomer(customer)])
const params = {
mode: 'payment',
customer: stripeCustomerId,
client_reference_id: customer.$id,
line_items: [{ price: priceId, quantity: 1 }],
payment_method_types: ['card', 'sepa_debit'],
locale: 'de',
billing_address_collection: 'required',
customer_update: { address: 'auto', name: 'auto' },
invoice_creation: {
enabled: true,
invoice_data: { description: config.newsSetup.name, footer: config.stripe.invoiceFooter },
},
metadata: { customerId: customer.$id, projectId, featureKey: key, ...(consentAt ? { consentAt } : {}) },
expires_at: Math.floor(Date.now() / 1000) + 24 * 60 * 60 - 5 * 60,
success_url: `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`,
cancel_url: `${config.publicBaseUrl}/dashboard.html#news`,
}
let session
try {
session = await stripe.checkout.sessions.create(params)
} catch (err) {
// SEPA-Lastschrift noch nicht im Stripe-Konto aktiviert -> nur Karte anbieten (wie beim Abo-Checkout)
if (/sepa_debit|payment_method_types/i.test(err.message || '')) {
console.warn('[stripe] SEPA-Lastschrift nicht verfuegbar, Checkout nur mit Karte:', err.message)
session = await stripe.checkout.sessions.create({ ...params, payment_method_types: ['card'] })
} else {
throw err
}
}
return { id: session.id, url: session.url }
}
export async function createBillingPortalUrl(customerId) {
const sub = await getCurrentSubscription(customerId)
if (!sub?.stripeCustomerId) {