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:
1 parent
90e85f5989
commit
9e21aa0690
2 files changed
+66
-7
No files matched your search
+54
-3
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -373,8 +373,12 @@ export async function createBillingPortalUrl(customerId) {
|
||||
}
|
||||
|
||||
/** Ordentliche Kuendigung zum Ende des bezahlten Monats. */
|
||||
export async function cancelAtPeriodEnd(subscriptionId) {
|
||||
await getStripe().subscriptions.update(subscriptionId, { cancel_at_period_end: true })
|
||||
export async function cancelAtPeriodEnd(subscriptionId, source = 'system') {
|
||||
// cancelSource: der Webhook erkennt daran, dass die Bestaetigung schon verschickt wird
|
||||
await getStripe().subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
metadata: { cancelSource: source },
|
||||
})
|
||||
return syncSubscription(subscriptionId)
|
||||
}
|
||||
|
||||
@@ -383,13 +387,16 @@ export async function cancelAtPeriodEnd(subscriptionId) {
|
||||
* der Wunschtermin liegt (ohne Wunschtermin: Ende des laufenden Monats).
|
||||
* Liefert das Abo-Dokument und den Beendigungszeitpunkt (ISO).
|
||||
*/
|
||||
export async function cancelSubscriptionAt(subscriptionId, requestedDate = null) {
|
||||
export async function cancelSubscriptionAt(subscriptionId, requestedDate = null, source = 'kuendigungsbutton') {
|
||||
const stripe = getStripe()
|
||||
const sub = await stripe.subscriptions.retrieve(subscriptionId)
|
||||
const periodEnd = periodEndOf(sub)
|
||||
const requested = requestedDate ? Math.floor(requestedDate.getTime() / 1000) : null
|
||||
if (!requested || !periodEnd || requested <= periodEnd) {
|
||||
await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true })
|
||||
await stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
metadata: { cancelSource: source },
|
||||
})
|
||||
const doc = await syncSubscription(subscriptionId)
|
||||
return { subscription: doc, effectiveAt: toIso(periodEnd) }
|
||||
}
|
||||
@@ -400,6 +407,7 @@ export async function cancelSubscriptionAt(subscriptionId, requestedDate = null)
|
||||
await stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at: Math.floor(boundary.getTime() / 1000),
|
||||
proration_behavior: 'none',
|
||||
metadata: { cancelSource: source },
|
||||
})
|
||||
const doc = await syncSubscription(subscriptionId)
|
||||
return { subscription: doc, effectiveAt: boundary.toISOString() }
|
||||
|
||||
Reference in new issue
Block a user