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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user