Wartungs-Abo v3: Mindestlaufzeit 12 Monate
- Bedingungen § 8: 12 Monate ab Leistungsbeginn, Kuendigung zum Ende bis 1 Monat vorher, danach monatlich; Sonderkuendigung bei Geschaeftsaufgabe (1 Monat Frist), einvernehmliche Beendigung jederzeit; gilt beidseitig - Kuendigungslogik computeCancellationDate (Mindestlaufzeit, verpasste Frist, Wunschtermin, Geschaeftsaufgabe, Monatsende) + cancelAt gespiegelt - Stripe-Kundenportal: Kuendigung aus (Stripe kennt keine Mindestlaufzeit) - Bestellseite, Abo-Karte, Vertragsbestaetigung, Checkout-Text, PDF, Leitfaden angepasst; termsVersion 2026-09-25-v3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e21aa0690
commit
9b563482ff
11 files changed
+112
-56
No files matched your search
@@ -93,14 +93,8 @@ export async function ensurePortalConfiguration() {
|
||||
invoice_history: { enabled: true },
|
||||
payment_method_update: { enabled: true },
|
||||
customer_update: { enabled: true, allowed_updates: ['address', 'name', 'email'] },
|
||||
subscription_cancel: {
|
||||
enabled: true,
|
||||
mode: 'at_period_end',
|
||||
cancellation_reason: {
|
||||
enabled: true,
|
||||
options: ['too_expensive', 'unused', 'switched_service', 'other'],
|
||||
},
|
||||
},
|
||||
// Kuendigung nur ueber die eigene Kuendigungsseite: Stripe kennt die Mindestlaufzeit nicht
|
||||
subscription_cancel: { enabled: false },
|
||||
},
|
||||
metadata: { webklar: 'wartungs-abo' },
|
||||
})
|
||||
@@ -170,6 +164,7 @@ export async function syncSubscription(subscriptionId) {
|
||||
startedAt: toIso(sub.start_date),
|
||||
currentPeriodEnd: toIso(periodEndOf(sub)),
|
||||
cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end || sub.cancel_at),
|
||||
cancelAt: sub.cancel_at ? toIso(sub.cancel_at) : sub.cancel_at_period_end ? toIso(periodEndOf(sub)) : null,
|
||||
canceledAt: toIso(sub.canceled_at),
|
||||
endedAt: toIso(sub.ended_at),
|
||||
termsVersion: meta.termsVersion || null,
|
||||
@@ -185,11 +180,50 @@ export async function syncSubscription(subscriptionId) {
|
||||
}
|
||||
|
||||
function addMonths(date, months) {
|
||||
// Monatsende abfangen (31.01. + 1 Monat = 28./29.02., wie bei Stripe)
|
||||
const d = new Date(date)
|
||||
const day = d.getUTCDate()
|
||||
d.setUTCDate(1)
|
||||
d.setUTCMonth(d.getUTCMonth() + months)
|
||||
const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate()
|
||||
d.setUTCDate(Math.min(day, last))
|
||||
return d
|
||||
}
|
||||
|
||||
/**
|
||||
* Beendigungszeitpunkt nach § 8 der Bedingungen (Version 3):
|
||||
* - Leistungsbeginn = Ende der Probezeit (Beginn nach Widerrufsfrist) bzw. Abo-Start
|
||||
* - Mindestlaufzeit 12 Abrechnungsmonate; Kuendigung bis 1 Monat vor deren Ende wirkt zum Ende,
|
||||
* spaeter eingehende Kuendigungen zum Ende des darauffolgenden Abrechnungsmonats
|
||||
* - nach der Mindestlaufzeit: zum Ende des laufenden Abrechnungsmonats
|
||||
* - Geschaeftsaufgabe: 1 Monat Frist zum Ende eines Abrechnungsmonats
|
||||
* - Wunschtermin: fruehestens zum berechneten Zeitpunkt, sonst zum Ende des Abrechnungsmonats,
|
||||
* in dem der Wunschtermin liegt
|
||||
*/
|
||||
export function computeCancellationDate({ serviceStart, receivedAt = new Date(), requestedDate = null, kind = 'ordentlich' }) {
|
||||
const start = new Date(serviceStart)
|
||||
const boundary = (k) => addMonths(start, k)
|
||||
const firstBoundaryFrom = (minK, notBefore) => {
|
||||
let k = Math.max(1, minK)
|
||||
while (boundary(k) < notBefore) k += 1
|
||||
return k
|
||||
}
|
||||
const minTermK = config.abo.minTermMonths
|
||||
let k
|
||||
if (kind === 'geschaeftsaufgabe') {
|
||||
k = firstBoundaryFrom(1, addMonths(receivedAt, 1))
|
||||
} else {
|
||||
const deadline = addMonths(boundary(minTermK), -1)
|
||||
k = receivedAt <= deadline ? minTermK : firstBoundaryFrom(minTermK + 1, receivedAt)
|
||||
}
|
||||
if (requestedDate && requestedDate > boundary(k)) k = firstBoundaryFrom(k, requestedDate)
|
||||
return { effectiveAt: boundary(k), minTermEnd: boundary(minTermK), serviceStart: start }
|
||||
}
|
||||
|
||||
export function serviceStartOf(sub) {
|
||||
return new Date(((sub.trial_end || sub.start_date) || 0) * 1000)
|
||||
}
|
||||
|
||||
function subscriptionIdOfInvoice(invoice) {
|
||||
return (
|
||||
invoice.subscription ||
|
||||
@@ -333,8 +367,8 @@ export async function createAboCheckout(customer, consent) {
|
||||
custom_text: {
|
||||
submit: {
|
||||
message: lateStart
|
||||
? 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab. Danach 50,00 € monatlich, monatlich kündbar.'
|
||||
: 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, monatlich kündbar.',
|
||||
? 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab. Danach 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.'
|
||||
: 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.',
|
||||
},
|
||||
},
|
||||
success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`,
|
||||
@@ -382,35 +416,29 @@ export async function cancelAtPeriodEnd(subscriptionId, source = 'system') {
|
||||
return syncSubscription(subscriptionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kuendigung zu einem Wunschtermin: wirksam zum Ende des Abrechnungsmonats, in dem
|
||||
* der Wunschtermin liegt (ohne Wunschtermin: Ende des laufenden Monats).
|
||||
* Liefert das Abo-Dokument und den Beendigungszeitpunkt (ISO).
|
||||
*/
|
||||
export async function cancelSubscriptionAt(subscriptionId, requestedDate = null, source = 'kuendigungsbutton') {
|
||||
/** Kuendigung nach § 8 (Mindestlaufzeit, Wunschtermin, Geschaeftsaufgabe). */
|
||||
export async function cancelSubscriptionAt(subscriptionId, requestedDate = null, source = 'kuendigungsbutton', kind = 'ordentlich', receivedAt = new Date()) {
|
||||
const stripe = getStripe()
|
||||
const sub = await stripe.subscriptions.retrieve(subscriptionId)
|
||||
const { effectiveAt, minTermEnd } = computeCancellationDate({
|
||||
serviceStart: serviceStartOf(sub),
|
||||
receivedAt,
|
||||
requestedDate,
|
||||
kind,
|
||||
})
|
||||
const periodEnd = periodEndOf(sub)
|
||||
const requested = requestedDate ? Math.floor(requestedDate.getTime() / 1000) : null
|
||||
if (!requested || !periodEnd || requested <= periodEnd) {
|
||||
const effectiveUnix = Math.floor(effectiveAt.getTime() / 1000)
|
||||
if (periodEnd && Math.abs(effectiveUnix - periodEnd) < 3600) {
|
||||
await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true, metadata: { cancelSource: source } })
|
||||
} else {
|
||||
await stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
cancel_at: effectiveUnix,
|
||||
proration_behavior: 'none',
|
||||
metadata: { cancelSource: source },
|
||||
})
|
||||
const doc = await syncSubscription(subscriptionId)
|
||||
return { subscription: doc, effectiveAt: toIso(periodEnd) }
|
||||
}
|
||||
let boundary = new Date(periodEnd * 1000)
|
||||
for (let i = 0; i < 36 && boundary.getTime() / 1000 < requested; i += 1) {
|
||||
boundary = addMonths(new Date(periodEnd * 1000), i + 1)
|
||||
}
|
||||
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() }
|
||||
return { subscription: doc, effectiveAt: effectiveAt.toISOString(), minTermEnd: minTermEnd.toISOString() }
|
||||
}
|
||||
|
||||
export function constructWebhookEvent(rawBody, signature) {
|
||||
|
||||
Reference in new issue
Block a user