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

@ -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; }
});
},
};