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,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<Info | null>(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<Info>('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 (<main id="main" className="center"><div className="auth card" style={{ maxWidth: 480 }}>
<h1>Vertragsentscheidung</h1>
{err && <Alert kind="err">{err}</Alert>}
{done ? (
<Alert kind="ok">
{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.'}
</Alert>
) : info ? (<>
<p className="muted">
Vertrag <strong>{info.contractNumber}</strong> ({info.productName}) für <strong>{info.orgName}</strong> läuft am{' '}
<strong>{new Date(info.termEnd).toLocaleDateString('de-DE')}</strong> aus. Möchten Sie ihn behalten oder kündigen?
</p>
<div className="row" style={{ marginTop: 16 }}>
<button className="btn primary" disabled={busy} autoFocus={preselect === 'keep'} onClick={() => decide('keep')}>{busy ? '…' : 'Vertrag behalten'}</button>
<button className="btn danger" disabled={busy} autoFocus={preselect === 'cancel'} onClick={() => decide('cancel')}>{busy ? '…' : 'Vertrag kündigen'}</button>
</div>
<p className="muted small" style={{ marginTop: 16 }}>„Behalten" verlängert den Vertrag automatisch um eine weitere Laufzeit. „Kündigen" leitet die Abmeldung ein.</p>
</>) : !err && <p className="muted" role="status">Wird geladen …</p>}
</div></main>);
}
export default function Page() { return <Suspense><Decision /></Suspense>; }