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:
ClaudeandClaude Opus 5.5 committed 2026-09-25 17:59:49 +00:00
1 parent 9e21aa0690
commit 9b563482ff
11 files changed
+112 -56

No files matched your search

+59 -31
View File
@@ -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) {