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

+4 -2
View File
@@ -61,7 +61,7 @@ export const config = {
priceAmountCents: 5000,
productName: 'WEBklar Wartungs-Abo',
productDescription:
'Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland (Sicherheitsupdates, SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit), 1 Domain inklusive Verwaltung, 1 E-Mail-Postfach (5 GB) und 1 Stunde Arbeitszeit pro Monat (12 Monate nutzbar). Keine Mindestlaufzeit, monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.',
'Betrieb und Pflege Ihrer Website bzw. Web-Anwendung auf unserem Server in Deutschland (Sicherheitsupdates, SSL, tägliche Datensicherung, Überwachung der Erreichbarkeit), 1 Domain inklusive Verwaltung, 1 E-Mail-Postfach (5 GB) und 1 Stunde Arbeitszeit pro Monat (12 Monate nutzbar). Mindestlaufzeit 12 Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.',
statementDescriptor: 'WEBKLAR WARTUNG',
productImage: 'https://project.webklar.com/img/wartungs-abo-produkt.png',
// Kleinunternehmer: Pflichthinweis auf jeder Stripe-Rechnung
@@ -72,7 +72,9 @@ export const config = {
abo: {
// Bei jeder Textaenderung an Bedingungen oder Bestellseite hochziehen (wird mit der Zustimmung gespeichert)
termsVersion: '2026-09-25-v2',
termsVersion: '2026-09-25-v3',
// Mindestlaufzeit ab Leistungsbeginn; Kuendigung zum Ende bis 1 Monat vorher, danach monatlich
minTermMonths: 12,
minutesPerMonth: 60,
creditValidityMonths: 12,
usageRoundingMinutes: 15,
+29 -9
View File
@@ -34,6 +34,21 @@ function addDays(iso, days) {
return d
}
function addMonthsIso(iso, months) {
const d = new Date(iso)
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.toISOString()
}
function minTermEndOf(sub) {
const start = sub?.trialEnd || sub?.startedAt
return start ? addMonthsIso(start, config.abo.minTermMonths) : null
}
function publicSubscription(sub) {
if (!sub) return null
const active = ACTIVE_STATUSES.has(sub.status)
@@ -45,6 +60,8 @@ function publicSubscription(sub) {
startedAt: sub.startedAt,
currentPeriodEnd: sub.currentPeriodEnd,
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
cancelAt: sub.cancelAt || (sub.cancelAtPeriodEnd ? sub.currentPeriodEnd : null),
minTermEnd: minTermEndOf(sub),
endedAt: sub.endedAt,
priceAmount: sub.priceAmount,
startChoice: sub.startChoice || null,
@@ -162,7 +179,8 @@ async function sendContractConfirmation(subDoc) {
' Preis: 50,00 € pro Monat (gemäß § 19 UStG ohne Umsatzsteuer)',
` Vertragsschluss: ${fmtDate(subDoc.startedAt)}`,
` Leistungsbeginn: ${lateStart ? fmtDate(subDoc.trialEnd) : fmtDate(subDoc.startedAt)}`,
' Laufzeit: unbestimmt, keine Mindestlaufzeit, jederzeit zum Ende des Abrechnungsmonats kündbar',
` Laufzeit: Mindestlaufzeit 12 Monate bis ${fmtDate(minTermEndOf(subDoc))}; Kündigung zu diesem Termin bis einen Monat vorher,`,
' danach läuft das Abo unbefristet und ist jederzeit zum Ende des Abrechnungsmonats kündbar',
' Enthalten: Betrieb und Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach,',
' 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)',
'',
@@ -194,13 +212,13 @@ 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
const endsAt = sub.cancelAt || 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,`,
`Ihr Wartungs-Abo endet zum ${fmtDate(endsAt)}. 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.',
'',
@@ -330,7 +348,7 @@ function validDeclaration(body) {
name,
email,
customerNumber: String(body?.customerNumber || '').trim().slice(0, 50),
kind: body?.kind === 'ausserordentlich' ? 'ausserordentlich' : 'ordentlich',
kind: ['ausserordentlich', 'geschaeftsaufgabe'].includes(body?.kind) ? body.kind : 'ordentlich',
reason: String(body?.reason || '').trim().slice(0, 2000),
effective: /^\d{4}-\d{2}-\d{2}$/.test(String(body?.effective || '')) ? String(body.effective) : '',
}
@@ -355,9 +373,9 @@ router.post('/public/cancel', async (req, res) => {
let effectiveAt = null
try {
sub = await findActiveSubscriptionByEmail(d.email)
if (sub && d.kind === 'ordentlich') {
if (sub && d.kind !== 'ausserordentlich') {
const requested = d.effective ? new Date(`${d.effective}T12:00:00Z`) : null
const result = await cancelSubscriptionAt(sub.stripeSubscriptionId, requested, 'kuendigungsbutton')
const result = await cancelSubscriptionAt(sub.stripeSubscriptionId, requested, 'kuendigungsbutton', d.kind, receivedAt)
sub = result.subscription
effectiveAt = result.effectiveAt
}
@@ -370,8 +388,9 @@ router.post('/public/cancel', async (req, res) => {
console.error('[abo] Kuendigung automatisch nicht moeglich:', err.message)
}
const kindLabel = { ordentlich: 'ordentliche Kündigung', ausserordentlich: 'außerordentliche Kündigung', geschaeftsaufgabe: 'Sonderkündigung wegen Geschäftsaufgabe' }[d.kind]
const effectiveText = effectiveAt
? `Ihr Wartungs-Abo endet zum ${fmtDate(effectiveAt)} (Ende des Abrechnungsmonats).`
? `Ihr Wartungs-Abo endet zum ${fmtDate(effectiveAt)}.${d.kind === 'geschaeftsaufgabe' ? ' Bitte senden Sie uns innerhalb von zwei Wochen einen Nachweis der Geschäftsaufgabe (z. B. Gewerbeabmeldung) an kenso.gri@gmail.com.' : ''}`
: d.kind === 'ausserordentlich'
? 'Ihre außerordentliche Kündigung prüfen wir umgehend und bestätigen Ihnen das Vertragsende gesondert.'
: 'Wir ordnen Ihre Kündigung Ihrem Vertrag zu und bestätigen Ihnen das Vertragsende gesondert.'
@@ -379,7 +398,7 @@ router.post('/public/cancel', async (req, res) => {
await sendCustomerMail(d.email, 'Eingangsbestätigung Ihrer Kündigung – Webklar Wartungs-Abo', [
`Guten Tag ${d.name},`,
'',
`wir haben Ihre ${d.kind === 'ausserordentlich' ? 'außerordentliche' : 'ordentliche'} Kündigung am ${fmtDateTime(receivedAt)} erhalten.`,
`wir haben Ihre ${kindLabel} am ${fmtDateTime(receivedAt)} erhalten.`,
'',
` Name: ${d.name}`,
` E-Mail: ${d.email}`,
@@ -398,7 +417,7 @@ router.post('/public/cancel', async (req, res) => {
await sendAdminNotice(`Kündigung eingegangen: ${d.email}`, [
`${d.name} <${d.email}> ${d.customerNumber ? `(Kd.-Nr. ${d.customerNumber})` : ''}`,
`Art: ${d.kind}, Zeitpunkt: ${d.effective || 'nächstmöglich'}`,
`Art: ${kindLabel}, gewünschter Zeitpunkt: ${d.effective || 'nächstmöglich'}`,
d.reason ? `Grund: ${d.reason}` : '',
effectiveAt
? `Automatisch in Stripe zum ${fmtDate(effectiveAt)} gekündigt (${sub.stripeSubscriptionId}).`
@@ -406,6 +425,7 @@ router.post('/public/cancel', async (req, res) => {
? `Abo ${sub.stripeSubscriptionId} gefunden, aber NICHT automatisch gekündigt (außerordentlich) -> prüfen und Vertragsende bestätigen!`
: 'KEIN aktives Abo zu dieser E-Mail gefunden -> bitte manuell zuordnen und Vertragsende bestätigen!',
d.kind === 'ausserordentlich' ? 'Außerordentliche Kündigung -> Grund prüfen, ggf. sofort beenden.' : '',
d.kind === 'geschaeftsaufgabe' ? 'Sonderkündigung Geschäftsaufgabe -> Nachweis (z. B. Gewerbeabmeldung) anfordern/prüfen; ohne Nachweis Kündigungstermin in Stripe auf Ende der Mindestlaufzeit setzen.' : '',
])
return res.json({ ok: true, receivedAt: receivedAt.toISOString() })
+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) {