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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<D | null>(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err'; t: string } | null>(null); const [confirm, setConfirm] = useState<'cancel' | 'immediate' | null>(null);
|
||||
const [d, setD] = useState<D | null>(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<D>('GET', `/contracts/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
if (!d) return msg ? <Alert kind={msg.k}>{msg.t}</Alert> : <p className="muted" role="status">Wird geladen …</p>;
|
||||
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<HTMLFormElement>) {
|
||||
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 (<>
|
||||
<p><Link href="/vertraege">← Alle Verträge</Link></p>
|
||||
|
|
@ -30,8 +43,26 @@ export default function Vertrag() {
|
|||
<div><dt className="muted small">Verlängerung</dt><dd style={{ margin: 0 }}>{d.renewal === 'auto' ? `automatisch um ${d.renewalTermMonths} Monat(e)` : 'keine'}</dd></div>
|
||||
<div><dt className="muted small">Kündigungsfrist</dt><dd style={{ margin: 0 }}>{d.noticeDays} Tage</dd></div>
|
||||
<div><dt className="muted small">Abrechnung</dt><dd style={{ margin: 0 }}>{INTERVAL[d.price.interval]}</dd></div></dl>
|
||||
<p className="small muted" style={{ marginTop: 16 }}>Preisstand bei Bestellung (Produkt {d.price.sku}, Version {d.price.version}). Spätere Preisänderungen gelten nicht rückwirkend.</p>
|
||||
<p className="small muted" style={{ marginTop: 16 }}>{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.`}</p>
|
||||
{d.resourceId && <p><Link href={`/ressourcen/${d.resourceId}`}>Zugehörige Ressource ansehen</Link></p>}</div>
|
||||
{d.canEdit && <div className="card"><h2>Vertrag anpassen</h2>
|
||||
{!editing ? <><p className="muted small">Nur für Superadmins. Preis und Konditionen werden mit Begründung im Audit-Protokoll festgehalten.</p><button className="btn" onClick={() => setEditing(true)}>Anpassen</button></> : (
|
||||
<form onSubmit={save}>
|
||||
<div className="cols">
|
||||
<Field id="ce-rec" label="Preis je Abrechnung (€)" hint={`Bisher: ${centsIn(d.price.unitRecurringCents)}`}><input id="ce-rec" name="recurring" inputMode="decimal" required defaultValue={centsIn(d.price.unitRecurringCents)} /></Field>
|
||||
<Field id="ce-basis" label="Preis ist"><select id="ce-basis" name="basis" defaultValue={d.price.basis}><option value="net">netto (zzgl. USt)</option><option value="gross">brutto (inkl. USt)</option></select></Field>
|
||||
<Field id="ce-int" label="Abrechnung"><select id="ce-int" name="interval" defaultValue={d.price.interval}>{Object.entries(INTERVAL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}</select></Field>
|
||||
<Field id="ce-setup" label="Einrichtung einmalig (€)"><input id="ce-setup" name="setup" inputMode="decimal" required defaultValue={centsIn(d.price.unitSetupCents)} /></Field>
|
||||
<Field id="ce-disc" label="Rabatt (%)"><input id="ce-disc" name="discount" inputMode="decimal" required defaultValue={String(d.price.discountBp / 100).replace('.', ',')} /></Field>
|
||||
<Field id="ce-end" label="Laufzeit bis" hint="Leer = unbefristet"><input id="ce-end" name="termEnd" type="date" defaultValue={d.termEnd ? new Date(d.termEnd).toLocaleDateString('sv-SE') : ''} /></Field>
|
||||
<Field id="ce-ren" label="Verlängerung"><select id="ce-ren" name="renewal" defaultValue={d.renewal}><option value="auto">automatisch</option><option value="none">keine</option></select></Field>
|
||||
<Field id="ce-rtm" label="Verlängerung um (Monate)"><input id="ce-rtm" name="renewalTermMonths" type="number" min={0} max={120} required defaultValue={d.renewalTermMonths} /></Field>
|
||||
<Field id="ce-nd" label="Kündigungsfrist (Tage)"><input id="ce-nd" name="noticeDays" type="number" min={0} max={365} required defaultValue={d.noticeDays} /></Field>
|
||||
</div>
|
||||
<Field id="ce-reason" label="Grund der Anpassung"><input id="ce-reason" name="reason" required minLength={3} maxLength={300} placeholder="z. B. Unkostenpreis vereinbart" /></Field>
|
||||
<div className="row"><button className="btn primary" type="submit" disabled={saving}>{saving ? 'Wird gespeichert …' : 'Speichern'}</button><button className="btn" type="button" onClick={() => setEditing(false)}>Abbrechen</button></div>
|
||||
</form>)}
|
||||
</div>}
|
||||
{(d.canCancel || pending) && <div className="card"><h2>Kündigung</h2>
|
||||
{confirm ? (<div role="alertdialog" aria-labelledby="cc"><p id="cc"><strong>{confirm === 'immediate' ? 'Vertrag sofort beenden?' : 'Vertrag kündigen?'}</strong> {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.</p>
|
||||
<div className="row"><button className="btn danger" onClick={() => cancel(confirm === 'immediate')}>Ja, kündigen</button><button className="btn" onClick={() => setConfirm(null)}>Abbrechen</button></div></div>)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue