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; }
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue