diff --git a/apps/api/src/modules/orders/index.ts b/apps/api/src/modules/orders/index.ts index 6682f56..89f045e 100644 --- a/apps/api/src/modules/orders/index.ts +++ b/apps/api/src/modules/orders/index.ts @@ -5,7 +5,9 @@ import type { PoolConnection } from 'mysql2/promise'; import { calculatePrice } from '@kc/platform/pricing'; import { ORDER_MACHINE, CONTRACT_MACHINE, transition, type OrderEvent } from '@kc/platform/statemachine'; import { consumerTerms, effectiveCancelDate } from '@kc/platform/contractterms'; +import { peekRenewalToken, applyRenewalDecision, RenewalTokenError } from '@kc/platform/contractRenewal'; import { one, query, run, tx } from '../../core/db.js'; +import { rl } from '../../core/config.js'; import { audit } from '../../core/audit.js'; import { enqueue } from '../../core/jobs.js'; import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js'; @@ -217,5 +219,18 @@ export const ordersModule: KcModule = { await audit({ actorType: 'user', actorId: a.user.id, orgId: c.org_id, action: 'contract.cancel.revoke', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) }); return { status: 'ok' }; }); + + // ---- Vertrags-Erinnerung per Mail: "Behalten"/"Kündigen" ohne Login, über einen Einmal-Link ------------- + app.get('/contracts/renewal-decision', async (req) => { + const { token } = z.object({ token: z.string().min(20).max(100) }).parse(req.query); + const info = await peekRenewalToken(token); + if (!info) throw badRequest('Link ungültig oder abgelaufen', 'INVALID_TOKEN'); + return info; + }); + app.post('/contracts/renewal-decision', { config: rl(10, '10 minutes') }, async (req) => { + const b = z.object({ token: z.string().min(20).max(100), action: z.enum(['keep', 'cancel']) }).parse(req.body); + try { return await applyRenewalDecision(b.token, b.action); } + catch (e: unknown) { if (e instanceof RenewalTokenError) throw badRequest(e.message, 'INVALID_TOKEN'); throw e; } + }); }, }; diff --git a/apps/web/src/app/vertrag-entscheidung/page.tsx b/apps/web/src/app/vertrag-entscheidung/page.tsx new file mode 100644 index 0000000..bd48014 --- /dev/null +++ b/apps/web/src/app/vertrag-entscheidung/page.tsx @@ -0,0 +1,49 @@ +'use client'; +import { Suspense, useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { api, errMsg } from '@/lib/api'; +import { Alert } from '@/components/ui'; + +interface Info { contractNumber: string; orgName: string; productName: string; termEnd: string } + +function Decision() { + const sp = useSearchParams(); + const token = sp.get('token') ?? ''; + const preselect = sp.get('action') === 'cancel' ? 'cancel' : sp.get('action') === 'keep' ? 'keep' : null; + const [info, setInfo] = useState(null); + const [err, setErr] = useState(''); + const [busy, setBusy] = useState(false); + const [done, setDone] = useState<'keep' | 'cancel' | null>(null); + + useEffect(() => { + if (!token) { setErr('Der Link ist unvollständig.'); return; } + api('GET', `/contracts/renewal-decision?token=${encodeURIComponent(token)}`).then(setInfo).catch((e) => setErr(errMsg(e))); + }, [token]); + + async function decide(action: 'keep' | 'cancel') { + setBusy(true); setErr(''); + try { await api('POST', '/contracts/renewal-decision', { token, action }); setDone(action); } + catch (e) { setErr(errMsg(e)); } finally { setBusy(false); } + } + + return (
+

Vertragsentscheidung

+ {err && {err}} + {done ? ( + + {done === 'keep' ? 'Vielen Dank! Der Vertrag wird weitergeführt.' : 'Die Kündigung wurde erfasst. Unser Team kümmert sich um die Abmeldung und meldet sich bei Rückfragen.'} + + ) : info ? (<> +

+ Vertrag {info.contractNumber} ({info.productName}) für {info.orgName} läuft am{' '} + {new Date(info.termEnd).toLocaleDateString('de-DE')} aus. Möchten Sie ihn behalten oder kündigen? +

+
+ + +
+

„Behalten" verlängert den Vertrag automatisch um eine weitere Laufzeit. „Kündigen" leitet die Abmeldung ein.

+ ) : !err &&

Wird geladen …

} +
); +} +export default function Page() { return ; } diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 2aa3820..1869916 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -53,6 +53,16 @@ const checkLicense = async () => { }; setInterval(() => void checkLicense(), 12 * 3600_000); void checkLicense(); +// Vertrags-Erinnerung: ~30 Tage vor Laufzeitende Mail mit "Behalten"/"Kündigen"-Link (einmal je Vertrag, +// markiert über renewal_reminder_sent_at). Unabhängig von processContractLifecycle oben, die nur zum +// tatsächlichen Laufzeitende selbst verlängert/beendet. +import { sendRenewalReminders } from '@kc/platform/contractRenewal'; +const checkRenewals = async () => { + try { const r = await sendRenewalReminders(); if (r.sent || r.skipped) log(`Vertrags-Erinnerungen: ${r.sent} gesendet, ${r.skipped} übersprungen`); } + catch (e) { log(`Vertrags-Erinnerung fehlgeschlagen: ${(e as Error).message}`); } +}; +setInterval(() => void checkRenewals(), 24 * 3600_000); void checkRenewals(); + // Audit-Aufbewahrung: Einträge jenseits der konfigurierten Frist löschen (Einstellungen > Firma/Audit; Default // spiegelt den DSGVO-Grundsatz der Speicherbegrenzung, keine feste Gesetzeszahl). Idempotent, daher unproblematisch, // mehrfach am Tag zu laufen; kein eigenes Datums-Gating nötig. diff --git a/migrations/037_contract_renewal_reminder.sql b/migrations/037_contract_renewal_reminder.sql new file mode 100644 index 0000000..31df695 --- /dev/null +++ b/migrations/037_contract_renewal_reminder.sql @@ -0,0 +1,27 @@ +-- Erinnerung vor Vertragsende: ~30 Tage vorher eine Mail mit "Behalten"/"Kündigen"-Entscheidung, egal ob der +-- Vertrag automatisch verlängert (renewal='auto') oder ausläuft (renewal='none'). "Behalten" verlängert +-- bewusst um eine Laufzeit (auch bei renewal='none', ohne die Einstellung selbst zu ändern - nächstes Mal wird +-- wieder gefragt). "Kündigen" setzt dieselbe Kündigungslogik wie die eingeloggte Kunden-Kündigung UND legt ein +-- Ticket an, damit Personal die externe Abmeldung (z. B. beim Domain-Registrar) tatsächlich vornehmen kann. +ALTER TABLE contracts ADD COLUMN renewal_reminder_sent_at DATETIME(3) NULL; + +-- Eigene Token-Tabelle statt user_tokens: eine Entscheidung bezieht sich auf einen VERTRAG, nicht auf den +-- einloggenden Nutzer selbst (ein Kunde kann mehrere Verträge haben) - user_tokens hat dafür keine Spalte. +-- Gleiche Hash-/Ablauf-/Einmal-Mechanik wie user_tokens. +CREATE TABLE contract_renewal_tokens ( + id CHAR(36) PRIMARY KEY, + contract_id CHAR(36) NOT NULL REFERENCES contracts(id), + token_hash CHAR(64) NOT NULL UNIQUE, + expires_at DATETIME(3) NOT NULL, + used_at DATETIME(3) NULL, + used_action ENUM('keep','cancel') NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + INDEX (contract_id) +); + +INSERT INTO mail_templates (`key`, name, trigger_text, category, variables_json, subject, body) VALUES +('contract_renewal_reminder', 'Erinnerung vor Vertragsende', 'Automatisch, ca. 30 Tage vor Laufzeitende eines Vertrags (einmalig je Vertrag).', + 'kundencenter', + JSON_ARRAY('name', 'contractNumber', 'productName', 'termEnd', 'keepLink', 'cancelLink'), + 'Ihr Vertrag {{contractNumber}} läuft bald ab', + 'Hallo {{name}},\n\nIhr Vertrag {{contractNumber}} ({{productName}}) läuft am {{termEnd}} aus. Über die Schaltflächen unten können Sie ihn behalten oder kündigen.\n\nBei „Behalten" verlängern wir den Vertrag automatisch um eine weitere Laufzeit. Bei „Kündigen" kümmern wir uns um die Abmeldung. Ohne Rückmeldung läuft der Vertrag nach unseren üblichen Bedingungen weiter bzw. aus.\n\n{{keepLink}}\n{{cancelLink}}\n\nBei Fragen antworten Sie gerne auf diese E-Mail.\n'); diff --git a/packages/platform/src/contractRenewal.ts b/packages/platform/src/contractRenewal.ts new file mode 100644 index 0000000..4c13d26 --- /dev/null +++ b/packages/platform/src/contractRenewal.ts @@ -0,0 +1,111 @@ +import { randomUUID } from 'node:crypto'; +import { one, query, run, tx } from './db.js'; +import { randomToken, sha256 } from './crypto.js'; +import { sendTemplateMail } from './mail.js'; +import { audit } from './audit.js'; +import { addMonths, effectiveCancelDate } from './contractterms.js'; +import { config } from './config.js'; + +/** Erinnerung vor Vertragsende ("Behalten"/"Kündigen"), unabhängig davon, ob der Vertrag automatisch + * verlängert (renewal='auto') oder sonst ausläuft (renewal='none') - siehe docs/Diskussion zu #35-Folgeticket. + * Nicht zu verwechseln mit processContractLifecycle() (@kc/connectors), die die eigentliche Verlängerung/ + * Beendigung zum Laufzeitende durchführt; diese Erinnerung läuft zeitlich VOR dieser Entscheidung und gibt + * dem Kunden die Möglichkeit, aktiv "Kündigen" zu wählen (inkl. Ticket für die externe Abmeldung, z. B. beim + * Domain-Registrar) oder "Behalten" zu wählen (verlängert bewusst um eine Laufzeit, auch bei renewal='none'). */ + +const REMINDER_DAYS_BEFORE = 30; +const TOKEN_TTL_DAYS = 45; // deckt die Frist vor Laufzeitende plus Puffer danach ab + +export interface ReminderRunResult { sent: number; skipped: number } + +/** Findet fällige Verträge und sendet je eine Erinnerungsmail mit Entscheidungslinks; markiert + * renewal_reminder_sent_at, damit das nur einmal passiert (idempotent, täglich vom Worker aufgerufen). */ +export async function sendRenewalReminders(now: Date = new Date()): Promise { + const rows = await query( + `SELECT c.id, c.number, c.org_id, c.term_end, c.price_snapshot_json, g.name AS org_name, b.billing_email, b.contact_name + FROM contracts c JOIN organizations g ON g.id = c.org_id LEFT JOIN billing_profiles b ON b.org_id = g.id + WHERE c.status IN ('active','suspended') AND c.term_end IS NOT NULL AND c.cancel_requested_at IS NULL + AND c.renewal_reminder_sent_at IS NULL AND c.term_end <= DATE_ADD(?, INTERVAL ? DAY)`, + [now, REMINDER_DAYS_BEFORE], + ); + let sent = 0; let skipped = 0; + for (const c of rows) { + if (!c.billing_email) { skipped++; continue; } + const token = randomToken(32); + await run('INSERT INTO contract_renewal_tokens (id, contract_id, token_hash, expires_at) VALUES (?,?,?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? DAY))', [randomUUID(), c.id, sha256(token), TOKEN_TTL_DAYS]); + const snap = typeof c.price_snapshot_json === 'string' ? JSON.parse(c.price_snapshot_json) : c.price_snapshot_json; + const link = (action: 'keep' | 'cancel') => `${config.baseUrl}/vertrag-entscheidung?token=${encodeURIComponent(token)}&action=${action}`; + const status = await sendTemplateMail(c.billing_email, 'contract_renewal_reminder', { + name: c.contact_name || c.org_name, contractNumber: c.number, productName: snap?.name ?? 'Ihr Vertrag', + termEnd: new Date(c.term_end).toLocaleDateString('de-DE'), keepLink: link('keep'), cancelLink: link('cancel'), + }); + await run('UPDATE contracts SET renewal_reminder_sent_at = ? WHERE id = ?', [now, c.id]); + if (status === 'sent') sent++; else skipped++; + } + return { sent, skipped }; +} + +export interface RenewalDecisionInfo { contractId: string; contractNumber: string; orgName: string; productName: string; termEnd: string } +/** Nur lesen (für die Bestätigungsseite), verbraucht den Token NICHT - das macht erst applyRenewalDecision(). */ +export async function peekRenewalToken(token: string): Promise { + const t = await one('SELECT contract_id FROM contract_renewal_tokens WHERE token_hash = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP(3)', [sha256(token)]); + if (!t) return null; + const c = await one('SELECT c.id, c.number, c.term_end, c.price_snapshot_json, g.name AS org_name FROM contracts c JOIN organizations g ON g.id = c.org_id WHERE c.id = ?', [t.contract_id]); + if (!c) return null; + const snap = typeof c.price_snapshot_json === 'string' ? JSON.parse(c.price_snapshot_json) : c.price_snapshot_json; + return { contractId: c.id, contractNumber: c.number, orgName: c.org_name, productName: snap?.name ?? 'Vertrag', termEnd: new Date(c.term_end).toISOString() }; +} + +export class RenewalTokenError extends Error {} + +/** Verbraucht den Token race-sicher (wie consumeToken bei Einladung/Passwort-Reset) und führt die Entscheidung aus: + * "keep" verlängert um eine Laufzeit (bzw. nimmt eine bereits gestellte Kündigung zurück, falls vorhanden); + * "cancel" setzt dieselbe Kündigungslogik wie die eingeloggte Kunden-Kündigung und legt zusätzlich ein internes + * Ticket an, damit Personal die externe Abmeldung (z. B. Domain-Registrar) tatsächlich vornimmt. */ +export async function applyRenewalDecision(token: string, action: 'keep' | 'cancel'): Promise<{ contractNumber: string }> { + return tx(async (c) => { + const hash = sha256(token); + const t = await one('SELECT id, contract_id FROM contract_renewal_tokens WHERE token_hash = ? AND used_at IS NULL AND expires_at > UTC_TIMESTAMP(3) FOR UPDATE', [hash], c); + if (!t) throw new RenewalTokenError('Link ungültig oder abgelaufen'); + const upd = await run('UPDATE contract_renewal_tokens SET used_at = UTC_TIMESTAMP(3), used_action = ? WHERE id = ? AND used_at IS NULL', [action, t.id], c); + if (upd.affectedRows !== 1) throw new RenewalTokenError('Link wurde bereits verwendet'); + + const contract = await one('SELECT * FROM contracts WHERE id = ? FOR UPDATE', [t.contract_id], c); + if (!contract) throw new RenewalTokenError('Vertrag nicht gefunden'); + if (!['active', 'suspended'].includes(contract.status)) throw new RenewalTokenError('Dieser Vertrag ist nicht mehr aktiv'); + + if (action === 'keep') { + if (contract.cancel_requested_at) { + await run('UPDATE contracts SET cancel_requested_at = NULL, cancel_effective_at = NULL WHERE id = ?', [contract.id], c); + await audit({ actorType: 'anonymous', orgId: contract.org_id, action: 'contract.cancel.revoke', resourceType: 'contract', resourceId: contract.id, after: { via: 'renewal_reminder' } }, c); + } else { + // Immer um mindestens eine Laufzeit verlängern (anders als renewedTermEnd/processContractLifecycle, die + // nur ein bereits VERGANGENES Laufzeitende aufholen - hier liegt term_end zum Zeitpunkt der Erinnerung + // noch in der Zukunft, genau das soll die Entscheidung "Behalten" ja bewirken). + const months = Number(contract.renewal_term_months) > 0 ? Number(contract.renewal_term_months) : 12; + let end = addMonths(new Date(contract.term_end ?? new Date()), months); + let guard = 0; while (end <= new Date() && guard++ < 1200) end = addMonths(end, months); // sehr späte Entscheidung: weiter aufholen + await run('UPDATE contracts SET term_end = ? WHERE id = ?', [end, contract.id], c); + await audit({ actorType: 'anonymous', orgId: contract.org_id, action: 'contract.renew', resourceType: 'contract', resourceId: contract.id, after: { termEnd: end.toISOString(), via: 'renewal_reminder' } }, c); + } + } else { + if (!contract.cancel_requested_at) { + const now = new Date(); + const effective = effectiveCancelDate(now, { termEnd: contract.term_end ? new Date(contract.term_end) : null, renewal: contract.renewal, renewalTermMonths: Number(contract.renewal_term_months), noticeDays: Number(contract.notice_days) }); + await run('UPDATE contracts SET cancel_requested_at = ?, cancel_effective_at = ? WHERE id = ?', [now, effective, contract.id], c); + await audit({ actorType: 'anonymous', orgId: contract.org_id, action: 'contract.cancel.request', resourceType: 'contract', resourceId: contract.id, after: { effectiveAt: effective.toISOString(), via: 'renewal_reminder' } }, c); + } + const owner = await one("SELECT user_id FROM memberships WHERE org_id = ? ORDER BY (role = 'owner') DESC LIMIT 1", [contract.org_id], c); + if (owner) { + await run("UPDATE number_sequences SET next_value = LAST_INSERT_ID(next_value + 1) WHERE name = 'ticket'", [], c); + const num = await one('SELECT LAST_INSERT_ID() AS n', [], c); + const ticketId = randomUUID(); const ticketNumber = `T-${num!.n}`; + await run('INSERT INTO tickets (id, number, org_id, subject, status, priority, created_by) VALUES (?,?,?,?,?,?,?)', + [ticketId, ticketNumber, contract.org_id, `Kündigung über Erinnerungsmail: Vertrag ${contract.number}`, 'pending_staff', 'normal', owner.user_id], c); + await run('INSERT INTO ticket_messages (id, ticket_id, author_id, author_kind, body, internal_note) VALUES (?,?,?,?,?,1)', + [randomUUID(), ticketId, owner.user_id, 'customer', `Der Kunde hat über die Erinnerungsmail zum Vertrag ${contract.number} "Kündigen" gewählt. Bitte die externe Abmeldung vornehmen (z. B. Domain beim Registrar kündigen) und das Ticket danach schließen.`], c); + } + } + return { contractNumber: contract.number as string }; + }); +} diff --git a/packages/platform/src/mail.ts b/packages/platform/src/mail.ts index 583da36..20df951 100644 --- a/packages/platform/src/mail.ts +++ b/packages/platform/src/mail.ts @@ -55,25 +55,38 @@ export function renderTemplateText(tpl: string, vars: Record): s const escapeHtml = (s: string): string => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); /** Beschriftung des Schaltflächen-Buttons, je nach Variablenname (endet auf "Link"). */ -const LINK_LABEL: Record = { inviteLink: 'Zugang einrichten', resetLink: 'Passwort zurücksetzen', ticketLink: 'Ticket ansehen', invoiceLink: 'Rechnung ansehen' }; +const LINK_LABEL: Record = { + inviteLink: 'Zugang einrichten', resetLink: 'Passwort zurücksetzen', ticketLink: 'Ticket ansehen', invoiceLink: 'Rechnung ansehen', + keepLink: 'Vertrag behalten', cancelLink: 'Vertrag kündigen', +}; +/** Zweitrangige Schaltfläche (zurückhaltender gestaltet) statt der primären Akzentfarbe - z. B. für "Kündigen" neben "Behalten". */ +const LINK_SECONDARY = new Set(['cancelLink']); /** - * Baut aus dem (Klartext-)Vorlagentext eine einfache, mandantenfarbige HTML-Mail. Eine Variable, deren Name auf - * "Link" endet, wird nicht als nackte URL im Text belassen, sondern als Schaltfläche ans Ende gesetzt (der - * Klartext-Teil der Mail behält den Link weiterhin als sichtbare URL, für Mailprogramme ohne HTML-Ansicht). + * Baut aus dem (Klartext-)Vorlagentext eine einfache, mandantenfarbige HTML-Mail. Jede Variable, deren Name auf + * "Link" endet, wird nicht als nackte URL im Text belassen, sondern als eigene Schaltfläche ans Ende gesetzt + * (mehrere "*Link"-Variablen ergeben mehrere Schaltflächen, z. B. "Behalten"/"Kündigen" nebeneinander). Der + * Klartext-Teil der Mail behält jeden Link weiterhin als sichtbare URL, für Mailprogramme ohne HTML-Ansicht. */ export function renderTemplateHtml(tpl: string, vars: Record): string { - const linkKey = Object.keys(vars).find((k) => /Link$/.test(k) && vars[k]); - const bodyVars = linkKey ? { ...vars, [linkKey]: '' } : vars; + const linkKeys = Object.keys(vars).filter((k) => /Link$/.test(k) && vars[k]); + const bodyVars = linkKeys.length ? { ...vars, ...Object.fromEntries(linkKeys.map((k) => [k, ''])) } : vars; const rendered = tpl.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (k in bodyVars ? escapeHtml(bodyVars[k]!) : m)) .replace(/https?:\/\/[^\s<]+/g, (url) => `${url}`); // weitere Links im Text bleiben klickbar, auch ohne eigenen Button const paragraphs = rendered.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean).map((p) => `

${p.replace(/\n/g, '
')}

`).join(''); - const button = linkKey ? `

${LINK_LABEL[linkKey] ?? 'Öffnen'}

` : ''; + const buttons = linkKeys.map((k) => { + const secondary = LINK_SECONDARY.has(k); + const style = secondary + ? 'display:inline-block;background:#ffffff;color:#3b6fd8;text-decoration:none;padding:11px 23px;border-radius:8px;font-weight:600;border:1px solid #3b6fd8;' + : 'display:inline-block;background:#3b6fd8;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;'; + return `${LINK_LABEL[k] ?? 'Öffnen'}`; + }).join(''); + const buttonRow = buttons ? `

${buttons}

` : ''; return `
Kundencenter
-
${paragraphs}${button}
+
${paragraphs}${buttonRow}
`; }