From 466bd83b7e12c8943f972cad8bf45644f1f8c18a Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Thu, 1 Oct 2026 12:02:26 +0200 Subject: [PATCH] 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 --- apps/api/src/modules/orders/index.ts | 40 ++++++++++++++++- apps/api/test/domains.test.ts | 5 ++- apps/api/test/orders.test.ts | 26 +++++++++++ .../web/src/app/(app)/vertraege/[id]/page.tsx | 43 ++++++++++++++++--- 4 files changed, 104 insertions(+), 10 deletions(-) diff --git a/apps/api/src/modules/orders/index.ts b/apps/api/src/modules/orders/index.ts index 89f045e..22d35b8 100644 --- a/apps/api/src/modules/orders/index.ts +++ b/apps/api/src/modules/orders/index.ts @@ -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); diff --git a/apps/api/test/domains.test.ts b/apps/api/test/domains.test.ts index 0dd4fda..3921bdb 100644 --- a/apps/api/test/domains.test.ts +++ b/apps/api/test/domains.test.ts @@ -88,8 +88,9 @@ describe('Domains: Preisliste, Aufschlag, Prüfung', () => { const rec = await call(app, admin, 'POST', '/admin/domain-records', { domain: 'https://www.Kunde-Test.com/' }); expect(rec.statusCode).toBe(200); expect((await call(app, admin, 'POST', '/admin/domain-records', { domain: 'kunde-test.com' })).statusCode).toBe(400); const rlr = await call(app, admin, 'GET', '/admin/domain-records'); const rl = rlr.json(); expect(rl[0]).toMatchObject({ domain: 'kunde-test.com', costGrossCents: 1250, costNetCents: 1050, sellGrossCents: 1550, sellNetCents: 1303, profitNetCents: 253, procurement: 'open' }); - expect((await call(app, admin, 'PATCH', `/admin/domain-records/${rec.json().id}`, { procurement: 'ordered', orderedRef: 'KCS-1' })).statusCode).toBe(200); - expect((await call(app, admin, 'GET', '/admin/domain-records?status=ordered')).json()[0]).toMatchObject({ procurement: 'ordered', orderedRef: 'KCS-1' }); + expect((await call(app, admin, 'PATCH', `/admin/domain-records/${rec.json().id}`, { procurement: 'ordered', orderedAt: '2020-04-24' })).statusCode).toBe(200); + expect((await call(app, admin, 'GET', '/admin/domain-records?status=ordered')).json()[0]).toMatchObject({ procurement: 'ordered' }); + expect(new Date((await call(app, admin, 'GET', '/admin/domain-records?status=ordered')).json()[0].orderedAt).getFullYear()).toBe(2020); expect((await call(app, cust, 'GET', '/admin/domain-records')).statusCode).toBe(403); expect((await call(app, sup, 'POST', '/admin/domain-records', { domain: 'x.com' })).statusCode).toBe(403); expect((await call(app, admin, 'GET', '/admin/domain-records?orgId=00000000-0000-4000-8000-000000000000')).json()).toEqual([]); // Kundenfilter await call(app, admin, 'PATCH', '/admin/domain-tlds/com', { active: false }); diff --git a/apps/api/test/orders.test.ts b/apps/api/test/orders.test.ts index 5f34934..8249a31 100644 --- a/apps/api/test/orders.test.ts +++ b/apps/api/test/orders.test.ts @@ -203,6 +203,32 @@ describe('Bruttopreise', () => { }); }); +describe('Vertrag nachträglich anpassen (Superadmin)', () => { + it('berechnet den Preis serverseitig neu, ändert Konditionen und protokolliert; nur Superadmin', async () => { + const admin = await staff('adj-adm@shop.test', 'admin'); const sa = await staff('adj-sa@shop.test', 'superadmin'); + const P = await customer(admin, 'private', 'Anna Anpass', 'adj-p@shop.test'); + const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id; + const prod = (await call(app, admin, 'POST', '/admin/products', { sku: 'ADJ-1', category: 'service', status: 'active', orderableByCustomer: true, requiresApproval: false, + version: { name: 'Altvertrag', taxRateId: t19, setupCents: 0, recurringCents: 0, billingInterval: 'yearly', termMonths: 12, renewal: 'auto', renewalTermMonths: 12, noticeDays: 30, provisioning: {} } })).json(); + await call(app, P.client, 'POST', '/orders', { orgId: P.org, items: [{ productId: prod.id }] }); + await drain(); + const k = (await call(app, P.client, 'GET', '/contracts')).json()[0]; + const body = { recurringCents: 2499, basis: 'gross', reason: 'Unkostenpreis vereinbart', termEnd: '2027-04-24', noticeDays: 14 }; + expect((await call(app, admin, 'PATCH', `/admin/contracts/${k.id}`, body)).statusCode).toBe(403); + expect((await call(app, P.client, 'PATCH', `/admin/contracts/${k.id}`, body)).statusCode).toBe(403); + expect((await call(app, sa, 'PATCH', `/admin/contracts/${k.id}`, { ...body, reason: '' })).statusCode).toBe(400); + expect((await call(app, sa, 'PATCH', `/admin/contracts/${k.id}`, { renewal: 'auto', renewalTermMonths: 0, reason: 'ungültig' })).statusCode).toBe(400); + expect((await call(app, admin, 'GET', `/contracts/${k.id}`)).json().canEdit).toBe(false); + expect((await call(app, sa, 'GET', `/contracts/${k.id}`)).json().canEdit).toBe(true); + expect((await call(app, sa, 'PATCH', `/admin/contracts/${k.id}`, body)).statusCode).toBe(200); + const d = (await call(app, P.client, 'GET', `/contracts/${k.id}`)).json(); + expect(d.price.recurring).toEqual({ net: 2100, tax: 399, gross: 2499 }); expect(d.price.basis).toBe('gross'); expect(d.price.name).toBe('Altvertrag'); + expect(d.noticeDays).toBe(14); expect(new Date(d.termEnd).getFullYear()).toBe(2027); expect(d.canEdit).toBe(false); + const log = await one("SELECT after_json FROM audit_events WHERE action = 'contract.adjust' AND resource_id = ?", [k.id]); + expect(JSON.stringify(log?.after_json)).toContain('Unkostenpreis vereinbart'); + }); +}); + describe('Familytool-Editionen (Paket), Edition und Laufzeitpflege', () => { it('importiert das Paket als Entwürfe, sperrt Aktivierung ohne Anbieter-Erweiterung, liefert Editionen und verlängert die Lizenz mit dem Vertrag', async () => { resetMock(); diff --git a/apps/web/src/app/(app)/vertraege/[id]/page.tsx b/apps/web/src/app/(app)/vertraege/[id]/page.tsx index c6ef3ff..c0ef64c 100644 --- a/apps/web/src/app/(app)/vertraege/[id]/page.tsx +++ b/apps/web/src/app/(app)/vertraege/[id]/page.tsx @@ -1,21 +1,34 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, type FormEvent } from 'react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { api, errMsg } from '@/lib/api'; import { useSession } from '@/lib/session'; -import { Alert, ContractStatusBadge, fmt, INTERVAL, type Price, PriceText } from '@/components/ui'; +import { Alert, ContractStatusBadge, Field, fmt, INTERVAL, type Price, PriceText } from '@/components/ui'; -interface D { id: string; number: string; orgName: string; status: string; productName: string; startedAt: string | null; termEnd: string | null; renewal: string; renewalTermMonths: number; noticeDays: number; cancelRequestedAt: string | null; cancelEffectiveAt: string | null; cancelledAt: string | null; resourceId: string | null; canCancel: boolean; - price: Price & { customerType: string; name: string; sku: string; version: number; terms: { termMonths: number } } } +interface D { id: string; number: string; orgName: string; status: string; productName: string; startedAt: string | null; termEnd: string | null; renewal: string; renewalTermMonths: number; noticeDays: number; cancelRequestedAt: string | null; cancelEffectiveAt: string | null; cancelledAt: string | null; resourceId: string | null; canCancel: boolean; canEdit?: boolean; + price: Price & { customerType: string; name: string; sku: string; version: number; terms: { termMonths: number }; basis: 'net' | 'gross'; unitRecurringCents: number; unitSetupCents: number; adjusted?: { at: string; reason: string } } } +const toCents = (v: string): number => { const t = v.trim(); const n = Number(t.includes(',') ? t.replace(/\./g, '').replace(',', '.') : t); return Number.isFinite(n) ? Math.round(n * 100) : NaN; }; +const centsIn = (c: number): string => (c / 100).toFixed(2).replace('.', ','); export default function Vertrag() { const { id } = useParams<{ id: string }>(); const { can } = useSession(); - const [d, setD] = useState(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [confirm, setConfirm] = useState<'cancel' | 'immediate' | null>(null); + const [d, setD] = useState(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [confirm, setConfirm] = useState<'cancel' | 'immediate' | null>(null); const [editing, setEditing] = useState(false); const [saving, setSaving] = useState(false); const load = useCallback(() => api('GET', `/contracts/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]); useEffect(() => { void load(); }, [load]); if (!d) return msg ? {msg.t} :

Wird geladen …

; async function cancel(immediate: boolean) { try { const r = await api<{ cancelEffectiveAt: string }>('POST', `/contracts/${id}/cancel`, { immediate }); setConfirm(null); setMsg({ k: 'ok', t: `Die Kündigung wurde vorgemerkt und wird wirksam am ${fmt(r.cancelEffectiveAt)}.` }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } } async function revoke() { try { await api('POST', `/contracts/${id}/cancel/revoke`); setMsg({ k: 'ok', t: 'Die Kündigung wurde zurückgenommen.' }); void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } } + async function save(e: FormEvent) { + e.preventDefault(); const f = new FormData(e.currentTarget); const g = (k: string) => String(f.get(k) ?? ''); + const recurringCents = toCents(g('recurring')); const setupCents = toCents(g('setup')); + if (Number.isNaN(recurringCents) || Number.isNaN(setupCents)) { setMsg({ k: 'err', t: 'Bitte die Preise als Zahl angeben, z. B. 12,50.' }); return; } + setSaving(true); + try { + await api('PATCH', `/admin/contracts/${id}`, { recurringCents, setupCents, basis: g('basis'), interval: g('interval'), discountBp: Math.round(Number(g('discount').replace(',', '.')) * 100), + termEnd: g('termEnd') || null, renewal: g('renewal'), renewalTermMonths: Number(g('renewalTermMonths')), noticeDays: Number(g('noticeDays')), reason: g('reason') }); + setEditing(false); setMsg({ k: 'ok', t: 'Der Vertrag wurde angepasst.' }); void load(); + } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setSaving(false); } + } const priv = d.price.customerType === 'private'; const pending = !!d.cancelEffectiveAt && ['active', 'suspended'].includes(d.status); return (<>

← Alle Verträge

@@ -30,8 +43,26 @@ export default function Vertrag() {
Verlängerung
{d.renewal === 'auto' ? `automatisch um ${d.renewalTermMonths} Monat(e)` : 'keine'}
Kündigungsfrist
{d.noticeDays} Tage
Abrechnung
{INTERVAL[d.price.interval]}
-

Preisstand bei Bestellung (Produkt {d.price.sku}, Version {d.price.version}). Spätere Preisänderungen gelten nicht rückwirkend.

+

{d.price.adjusted ? `Preis zuletzt angepasst am ${fmt(d.price.adjusted.at)}.` : `Preisstand bei Bestellung (Produkt ${d.price.sku}, Version ${d.price.version}). Spätere Preisänderungen gelten nicht rückwirkend.`}

{d.resourceId &&

Zugehörige Ressource ansehen

} + {d.canEdit &&

Vertrag anpassen

+ {!editing ? <>

Nur für Superadmins. Preis und Konditionen werden mit Begründung im Audit-Protokoll festgehalten.

: ( +
+
+ + + + + + + + + +
+ +
+
)} +
} {(d.canCancel || pending) &&

Kündigung

{confirm ? (

{confirm === 'immediate' ? 'Vertrag sofort beenden?' : 'Vertrag kündigen?'} {confirm === 'immediate' ? 'Die Ressource wird gesperrt.' : 'Die Kündigung wird zum nächstmöglichen Termin wirksam; bis dahin läuft der Vertrag weiter.'} Die Aktion wird protokolliert.

)