Add contract renewal reminder emails (keep/cancel decision links)

Sends a one-time email ~30 days before any contract's term_end
(whether renewal='auto' or 'none'), with "Behalten" and "Kündigen"
action links. "Behalten" extends the contract by one renewal term;
"Kündigen" sets the same cancellation effective-date logic as the
customer-facing cancel flow and opens a staff ticket so the external
deregistration (e.g. with a domain registrar) actually gets done.

Uses a dedicated single-use token table (contract_renewal_tokens)
since contracts, unlike user_tokens, are not scoped to a single user.
The public confirmation page lives at /vertrag-entscheidung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-10-01 11:51:19 +02:00
parent 29c7273f4d
commit dad9528295
6 changed files with 233 additions and 8 deletions

View file

@ -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<ReminderRunResult> {
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<RenewalDecisionInfo | null> {
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 };
});
}

View file

@ -55,25 +55,38 @@ export function renderTemplateText(tpl: string, vars: Record<string, string>): s
const escapeHtml = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/** Beschriftung des Schaltflächen-Buttons, je nach Variablenname (endet auf "Link"). */
const LINK_LABEL: Record<string, string> = { inviteLink: 'Zugang einrichten', resetLink: 'Passwort zurücksetzen', ticketLink: 'Ticket ansehen', invoiceLink: 'Rechnung ansehen' };
const LINK_LABEL: Record<string, string> = {
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, string>): 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) => `<a href="${url}" style="color:#3b6fd8;">${url}</a>`); // 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 style="margin:0 0 16px;">${p.replace(/\n/g, '<br>')}</p>`).join('');
const button = linkKey ? `<p style="margin:24px 0 0;"><a href="${escapeHtml(vars[linkKey]!)}" style="display:inline-block;background:#3b6fd8;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:8px;font-weight:600;">${LINK_LABEL[linkKey] ?? 'Öffnen'}</a></p>` : '';
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 `<a href="${escapeHtml(vars[k]!)}" style="${style}margin-right:12px;">${LINK_LABEL[k] ?? 'Öffnen'}</a>`;
}).join('');
const buttonRow = buttons ? `<p style="margin:24px 0 0;">${buttons}</p>` : '';
return `<!doctype html><html><body style="margin:0;padding:32px 16px;background:#f4f6fa;font-family:-apple-system,Segoe UI,Roboto,sans-serif;">
<table role="presentation" width="100%" style="max-width:520px;margin:0 auto;background:#ffffff;border:1px solid #e3e8f0;border-radius:12px;">
<tr><td style="padding:28px 32px;">
<div style="font-weight:700;font-size:18px;color:#173568;margin-bottom:20px;">Kundencenter</div>
<div style="color:#172033;font-size:15px;line-height:1.55;">${paragraphs}${button}</div>
<div style="color:#172033;font-size:15px;line-height:1.55;">${paragraphs}${buttonRow}</div>
</td></tr></table>
</body></html>`;
}