Abo: Kuendigungsbestaetigung auch bei Kuendigung ueber das Stripe-Kundenportal

Webhook customer.subscription.updated erkennt neu gesetzte Kuendigungen.
Kuendigungs- und Widerrufsseite markieren ihre Kuendigung per Metadatum
cancelSource, damit keine doppelte Bestaetigung verschickt wird.
Admin-Hinweis auch bei zurueckgenommener Kuendigung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-25 17:45:04 +00:00
1 parent 90e85f5989
commit 9e21aa0690
2 files changed
+66 -7

No files matched your search

+54 -3
View File
@@ -191,6 +191,35 @@ async function sendContractConfirmation(subDoc) {
})
}
/** Kuendigung ueber "Abo verwalten" (Stripe-Kundenportal): Bestaetigung in Textform + Hinweis an uns. */
async function sendPortalCancellationConfirmation(sub, createdUnix) {
const receivedAt = new Date((createdUnix || Date.now() / 1000) * 1000)
const endsAt = sub.currentPeriodEnd
await sendCustomerMail(sub.email, 'Bestätigung Ihrer Kündigung – WEBklar Wartungs-Abo', [
'Guten Tag,',
'',
`wir bestätigen Ihre Kündigung des WEBklar Wartungs-Abos, eingegangen am ${fmtDateTime(receivedAt)} über „Abo verwalten“ im Kundenportal.`,
'',
`Ihr Wartungs-Abo endet zum ${fmtDate(endsAt)} (Ende des Abrechnungsmonats). Bis dahin bleiben alle Leistungen verfügbar,`,
'danach erfolgen keine weiteren Abbuchungen. Nicht genutzte Zeit aus dem Stundenkontingent entfällt mit Vertragsende.',
'Ihre Daten, den Domain-AuthCode und Ihr Postfach stellen wir Ihnen auf Wunsch gemäß § 9 der Vertragsbedingungen bereit.',
'',
'Falls Sie das nicht veranlasst haben, melden Sie sich bitte umgehend bei uns.',
'',
'Viele Grüße',
'Ihr WEBklar-Team',
'',
'WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler · Telefon 0176 23726355 · kenso.gri@gmail.com',
])
await updateDocument(config.collections.subscriptions, sub.$id, {
cancelRequestedAt: receivedAt.toISOString(),
}).catch(() => {})
await sendAdminNotice(`Kündigung (Kundenportal): ${sub.email}`, [
`Abo ${sub.stripeSubscriptionId} endet zum ${fmtDate(endsAt)}. Bestätigung an den Kunden ist raus.`,
'Hosting/Domain/Postfach gemäß Ziff. 9 der Abo-Bedingungen abwickeln (Export, AuthCode).',
])
}
async function handleStripeEvent(event) {
const obj = event.data.object
switch (event.type) {
@@ -224,9 +253,31 @@ async function handleStripeEvent(event) {
return
}
case 'customer.subscription.created':
case 'customer.subscription.updated':
await syncSubscription(obj.id)
return
case 'customer.subscription.updated': {
const sub = await syncSubscription(obj.id)
const prev = event.data.previous_attributes || {}
const becameCanceled =
(prev.cancel_at_period_end === false && obj.cancel_at_period_end === true) ||
('cancel_at' in prev && !prev.cancel_at && obj.cancel_at)
const reactivated =
(prev.cancel_at_period_end === true && obj.cancel_at_period_end === false) ||
('cancel_at' in prev && prev.cancel_at && !obj.cancel_at)
// Kuendigung ueber das Stripe-Kundenportal oder das Dashboard: Bestaetigung hier verschicken.
// Ueber Kuendigungs-/Widerrufsseite gesetzte Kuendigungen tragen cancelSource und sind schon bestaetigt.
if (becameCanceled && !obj.metadata?.cancelSource && sub) {
await sendPortalCancellationConfirmation(sub, event.created).catch((e) =>
console.error('[abo] Kuendigungsbestaetigung (Kundenportal) fehlgeschlagen:', e.message)
)
}
if (reactivated && sub) {
await sendAdminNotice(`Kündigung zurückgenommen: ${sub.email}`, [
`Abo ${obj.id} läuft wieder normal weiter (Kündigung aufgehoben).`,
])
}
return
}
case 'customer.subscription.deleted': {
const sub = await syncSubscription(obj.id)
await sendAdminNotice(`Abo beendet: ${sub?.email || ''}`, [
@@ -306,7 +357,7 @@ router.post('/public/cancel', async (req, res) => {
sub = await findActiveSubscriptionByEmail(d.email)
if (sub && d.kind === 'ordentlich') {
const requested = d.effective ? new Date(`${d.effective}T12:00:00Z`) : null
const result = await cancelSubscriptionAt(sub.stripeSubscriptionId, requested)
const result = await cancelSubscriptionAt(sub.stripeSubscriptionId, requested, 'kuendigungsbutton')
sub = result.subscription
effectiveAt = result.effectiveAt
}
@@ -371,7 +422,7 @@ router.post('/public/withdraw', async (req, res) => {
sub = await findActiveSubscriptionByEmail(d.email)
if (sub) {
// Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId)
sub = await cancelAtPeriodEnd(sub.stripeSubscriptionId, 'widerruf')
await updateDocument(config.collections.subscriptions, sub.$id, {
withdrawalRequestedAt: receivedAt.toISOString(),
})