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) {
|
async function handleStripeEvent(event) {
|
||||||
const obj = event.data.object
|
const obj = event.data.object
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
@@ -224,9 +253,31 @@ async function handleStripeEvent(event) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case 'customer.subscription.created':
|
case 'customer.subscription.created':
|
||||||
case 'customer.subscription.updated':
|
|
||||||
await syncSubscription(obj.id)
|
await syncSubscription(obj.id)
|
||||||
return
|
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': {
|
case 'customer.subscription.deleted': {
|
||||||
const sub = await syncSubscription(obj.id)
|
const sub = await syncSubscription(obj.id)
|
||||||
await sendAdminNotice(`Abo beendet: ${sub?.email || ''}`, [
|
await sendAdminNotice(`Abo beendet: ${sub?.email || ''}`, [
|
||||||
@@ -306,7 +357,7 @@ router.post('/public/cancel', async (req, res) => {
|
|||||||
sub = await findActiveSubscriptionByEmail(d.email)
|
sub = await findActiveSubscriptionByEmail(d.email)
|
||||||
if (sub && d.kind === 'ordentlich') {
|
if (sub && d.kind === 'ordentlich') {
|
||||||
const requested = d.effective ? new Date(`${d.effective}T12:00:00Z`) : null
|
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
|
sub = result.subscription
|
||||||
effectiveAt = result.effectiveAt
|
effectiveAt = result.effectiveAt
|
||||||
}
|
}
|
||||||
@@ -371,7 +422,7 @@ router.post('/public/withdraw', async (req, res) => {
|
|||||||
sub = await findActiveSubscriptionByEmail(d.email)
|
sub = await findActiveSubscriptionByEmail(d.email)
|
||||||
if (sub) {
|
if (sub) {
|
||||||
// Keine weiteren Abbuchungen; Wertersatz/Erstattung rechnet das Team ab
|
// 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, {
|
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||||||
withdrawalRequestedAt: receivedAt.toISOString(),
|
withdrawalRequestedAt: receivedAt.toISOString(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -373,8 +373,12 @@ export async function createBillingPortalUrl(customerId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Ordentliche Kuendigung zum Ende des bezahlten Monats. */
|
/** Ordentliche Kuendigung zum Ende des bezahlten Monats. */
|
||||||
export async function cancelAtPeriodEnd(subscriptionId) {
|
export async function cancelAtPeriodEnd(subscriptionId, source = 'system') {
|
||||||
await getStripe().subscriptions.update(subscriptionId, { cancel_at_period_end: true })
|
// 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)
|
return syncSubscription(subscriptionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,13 +387,16 @@ export async function cancelAtPeriodEnd(subscriptionId) {
|
|||||||
* der Wunschtermin liegt (ohne Wunschtermin: Ende des laufenden Monats).
|
* der Wunschtermin liegt (ohne Wunschtermin: Ende des laufenden Monats).
|
||||||
* Liefert das Abo-Dokument und den Beendigungszeitpunkt (ISO).
|
* 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 stripe = getStripe()
|
||||||
const sub = await stripe.subscriptions.retrieve(subscriptionId)
|
const sub = await stripe.subscriptions.retrieve(subscriptionId)
|
||||||
const periodEnd = periodEndOf(sub)
|
const periodEnd = periodEndOf(sub)
|
||||||
const requested = requestedDate ? Math.floor(requestedDate.getTime() / 1000) : null
|
const requested = requestedDate ? Math.floor(requestedDate.getTime() / 1000) : null
|
||||||
if (!requested || !periodEnd || requested <= periodEnd) {
|
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)
|
const doc = await syncSubscription(subscriptionId)
|
||||||
return { subscription: doc, effectiveAt: toIso(periodEnd) }
|
return { subscription: doc, effectiveAt: toIso(periodEnd) }
|
||||||
}
|
}
|
||||||
@@ -400,6 +407,7 @@ export async function cancelSubscriptionAt(subscriptionId, requestedDate = null)
|
|||||||
await stripe.subscriptions.update(subscriptionId, {
|
await stripe.subscriptions.update(subscriptionId, {
|
||||||
cancel_at: Math.floor(boundary.getTime() / 1000),
|
cancel_at: Math.floor(boundary.getTime() / 1000),
|
||||||
proration_behavior: 'none',
|
proration_behavior: 'none',
|
||||||
|
metadata: { cancelSource: source },
|
||||||
})
|
})
|
||||||
const doc = await syncSubscription(subscriptionId)
|
const doc = await syncSubscription(subscriptionId)
|
||||||
return { subscription: doc, effectiveAt: boundary.toISOString() }
|
return { subscription: doc, effectiveAt: boundary.toISOString() }
|
||||||
|
|||||||
Reference in new issue
Block a user