Let superadmins adjust contract price and terms

New PATCH /admin/contracts/:id (permission contracts.edit, superadmin
only) recalculates the price snapshot server-side and can change term
end, renewal and notice period. A reason is required and before/after
values go to the audit log. Changing the term end re-arms the renewal
reminder. The contract page gets a matching edit form.

Also fixes the domain test after replacing the KCS order number with
the order date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kundencenter 2026-10-01 12:02:26 +02:00
parent d00f1a4cec
commit 466bd83b7e
4 changed files with 104 additions and 10 deletions

View file

@ -53,7 +53,7 @@ export const ordersModule: KcModule = {
permissions: {
staff: {
support: ['orders.read', 'contracts.read'], accounting: ['orders.read', 'contracts.read'],
admin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'], superadmin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'],
admin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'], superadmin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write', 'contracts.edit'],
},
org: { owner: ['orders.read', 'orders.create', 'contracts.read', 'contracts.cancel'], admin: ['orders.read', 'orders.create', 'contracts.read', 'contracts.cancel'], member: ['orders.read', 'contracts.read'] },
},
@ -188,7 +188,7 @@ export const ordersModule: KcModule = {
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const c = await one(`${CONTRACT_SQL} WHERE c.id = ?`, [id]);
if (!c || !canInOrg(a.principal, c.org_id, 'contracts.read', 'contracts.read')) throw notFound();
return { ...contractView(c), canCancel: ['active', 'suspended'].includes(c.status) && !c.cancel_requested_at && canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write') };
return { ...contractView(c), canEdit: can(a.principal, 'contracts.edit') && !['cancelled', 'expired', 'failed'].includes(c.status), canCancel: ['active', 'suspended'].includes(c.status) && !c.cancel_requested_at && canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write') };
});
/** Kündigung: zum nächstmöglichen Termin unter Beachtung von Laufzeit und Frist; sofort nur durch Personal. */
app.post('/contracts/:id/cancel', async (req) => {
@ -220,6 +220,42 @@ export const ordersModule: KcModule = {
return { status: 'ok' };
});
/** Nachträgliche Anpassung (nur Superadmin), z. B. Preis für übernommene Altverträge. Der Preis wird serverseitig neu berechnet. */
app.patch('/admin/contracts/:id', async (req) => {
const a = requirePermission(req, 'contracts.edit');
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const b = z.object({
basis: z.enum(['net', 'gross']).optional(), interval: z.enum(['once', 'monthly', 'yearly']).optional(),
recurringCents: z.number().int().min(0).max(100_000_000).optional(), setupCents: z.number().int().min(0).max(100_000_000).optional(),
discountBp: z.number().int().min(0).max(10000).optional(),
termEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), renewal: z.enum(['auto', 'none']).optional(),
renewalTermMonths: z.number().int().min(0).max(120).optional(), noticeDays: z.number().int().min(0).max(365).optional(),
reason: z.string().trim().min(3).max(300),
}).parse(req.body);
return tx(async (c) => {
const k = await one('SELECT * FROM contracts WHERE id = ? FOR UPDATE', [id], c);
if (!k) throw notFound();
if (['cancelled', 'expired', 'failed'].includes(k.status)) throw badRequest('Beendete Verträge können nicht mehr angepasst werden', 'CONTRACT_ENDED');
const snap = typeof k.price_snapshot_json === 'string' ? JSON.parse(k.price_snapshot_json) : k.price_snapshot_json;
let price;
try {
price = calculatePrice({ basis: b.basis ?? snap.basis, interval: b.interval ?? snap.interval, setupCents: b.setupCents ?? snap.unitSetupCents, recurringCents: b.recurringCents ?? snap.unitRecurringCents, taxBp: snap.taxBp, quantity: snap.quantity, discountBp: b.discountBp ?? snap.discountBp, currency: snap.currency });
} catch (e) { if (e instanceof RangeError) throw badRequest(e.message); throw e; }
const renewal = b.renewal ?? k.renewal; const renewalTermMonths = b.renewalTermMonths ?? Number(k.renewal_term_months); const noticeDays = b.noticeDays ?? Number(k.notice_days);
if (renewal === 'auto' && renewalTermMonths < 1) throw badRequest('Bei automatischer Verlängerung muss die Verlängerungsdauer mindestens 1 Monat sein');
const termEnd = b.termEnd === undefined ? k.term_end : b.termEnd === null ? null : new Date(`${b.termEnd}T00:00:00`);
const next = { ...snap, ...price, terms: { ...(snap.terms ?? {}), renewal, renewalTermMonths, noticeDays }, adjusted: { at: new Date().toISOString(), by: a.user.id, reason: b.reason } };
// Neues Laufzeitende = neue Erinnerung fällig
const termChanged = b.termEnd !== undefined && String(termEnd ?? '') !== String(k.term_end ?? '');
await run(`UPDATE contracts SET price_snapshot_json = ?, term_end = ?, renewal = ?, renewal_term_months = ?, notice_days = ?${termChanged ? ', renewal_reminder_sent_at = NULL' : ''} WHERE id = ?`,
[JSON.stringify(next), termEnd, renewal, renewalTermMonths, noticeDays, id], c);
await audit({ actorType: 'user', actorId: a.user.id, orgId: k.org_id, action: 'contract.adjust', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req),
before: { recurring: snap.recurring, setup: snap.setup, basis: snap.basis, interval: snap.interval, discountBp: snap.discountBp, termEnd: k.term_end, renewal: k.renewal, renewalTermMonths: k.renewal_term_months, noticeDays: k.notice_days },
after: { recurring: price.recurring, setup: price.setup, basis: price.basis, interval: price.interval, discountBp: price.discountBp, termEnd, renewal, renewalTermMonths, noticeDays, reason: b.reason } }, c);
return { ok: true };
});
});
// ---- 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);