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:
parent
29c7273f4d
commit
dad9528295
6 changed files with 233 additions and 8 deletions
|
|
@ -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; }
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
49
apps/web/src/app/vertrag-entscheidung/page.tsx
Normal file
49
apps/web/src/app/vertrag-entscheidung/page.tsx
Normal 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>; }
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue