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

+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() })