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:
parent
d00f1a4cec
commit
466bd83b7e
4 changed files with 104 additions and 10 deletions
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue