diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts index 22cec91..ecfd309 100644 --- a/apps/api/src/modules/invoices/index.ts +++ b/apps/api/src/modules/invoices/index.ts @@ -96,6 +96,25 @@ export const invoicesModule: KcModule = { return rows.map(invoiceView); }); + /** Mahnwesen-Überblick: überfällige Rechnungen und aktive Verträge, die vermutlich neu in Rechnung gestellt werden müssen + * (letzte Rechnung liegt länger zurück als die Verlängerungslaufzeit des Vertrags). Nur Personal. */ + app.get('/invoices/reminders', async (req) => { + requirePermission(req, 'invoices.read'); + const overdue = (await query(`${INVOICE_SQL} WHERE v.status = 'open' AND v.due_date < CURDATE() ORDER BY v.due_date`)).map(invoiceView); + const contracts = await query(` + SELECT c.id, c.number, c.org_id, c.started_at, c.renewal_term_months, g.name AS org_name, g.customer_number, + JSON_VALUE(c.price_snapshot_json, '$.name') AS product_name, JSON_VALUE(c.price_snapshot_json, '$.recurring.gross') AS last_gross_cents, + (SELECT MAX(i.issue_date) FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.id WHERE ii.contract_id = c.id AND i.status IN ('open', 'paid')) AS last_invoiced + FROM contracts c JOIN organizations g ON g.id = c.org_id + WHERE c.status = 'active' AND c.renewal = 'auto' AND c.renewal_term_months > 0`); + const dueContracts = contracts + .map((c) => { const since = c.last_invoiced ?? c.started_at; const due = new Date(since); due.setMonth(due.getMonth() + c.renewal_term_months); + return { id: c.id, number: c.number, orgId: c.org_id, orgName: c.org_name, customerNumber: c.customer_number, productName: c.product_name, lastInvoicedAt: c.last_invoiced, dueSince: due.toISOString().slice(0, 10), lastGrossCents: c.last_gross_cents === null ? null : Number(c.last_gross_cents) }; }) + .filter((c) => new Date(c.dueSince) <= new Date()) + .sort((a, b) => a.dueSince.localeCompare(b.dueSince)); + return { overdueInvoices: overdue, dueContracts }; + }); + app.post('/invoices', async (req) => { const a = requirePermission(req, 'invoices.write'); const b = z.object({ orgId: z.string().uuid(), note: z.string().trim().max(500).optional(), paymentMethod: z.string().max(50).optional() }).parse(req.body); diff --git a/apps/api/test/invoices.test.ts b/apps/api/test/invoices.test.ts index c2a1b17..57ec9f7 100644 --- a/apps/api/test/invoices.test.ts +++ b/apps/api/test/invoices.test.ts @@ -1,10 +1,13 @@ import { beforeAll, describe, expect, it } from 'vitest'; import type { FastifyInstance } from 'fastify'; import { buildApp } from '../src/server.js'; +import { run } from '../src/core/db.js'; +import { runOnce } from '../../worker/src/jobs.js'; import { call, code, login, makeUser } from './helpers.js'; let app: FastifyInstance; beforeAll(async () => { app = await buildApp(); await app.ready(); }); +const drain = async () => { for (let i = 0; i < 30; i++) { await run("UPDATE jobs SET run_at = UTC_TIMESTAMP(3) WHERE status IN ('scheduled','retrying')"); if (!(await runOnce(() => undefined))) break; } }; async function staff(email: string, role: string) { await makeUser({ email, kind: 'staff', staffRole: role }); const { client } = await login(app, email); const s = (await call(app, client, 'POST', '/auth/mfa/setup')).json(); await call(app, client, 'POST', '/auth/mfa/confirm', { code: code(s.secret) }); return client; @@ -130,3 +133,48 @@ describe('Rechnungsposition aus Produkt mit Laufzeit-Staffel', () => { expect((await call(app, acc, 'GET', `/invoices/suggest-from-product/${prod.json().id}?termMonths=6`)).statusCode).toBe(400); }); }); + +describe('Mahnwesen: überfällige Rechnungen und fällige Verträge', () => { + it('Überfällige offene Rechnungen und Verträge ohne aktuelle Rechnung werden gefunden; bezahlte/aktuelle/inaktive nicht', async () => { + const admin = await staff('rem-admin@example.com', 'superadmin'); const acc = await staff('rem-acc@example.com', 'accounting'); const support = await staff('rem-support@example.com', 'support'); + await call(app, admin, 'PUT', '/admin/company-settings', COMPANY); + const A = await customer(admin, 'Kunde A', 'rem-a@example.com'); const B = await customer(admin, 'Kunde B', 'rem-b@example.com'); const C = await customer(admin, 'Kunde C', 'rem-c@example.com'); + 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: 'HOST-REM', category: 'hosting', orderableByCustomer: true, requiresApproval: false, customerActions: [], status: 'active', + version: { name: 'Hosting Monatlich', taxRateId: t19, setupCents: 0, recurringCents: 999, billingInterval: 'monthly', termMonths: 0, renewal: 'auto', renewalTermMonths: 1, noticeDays: 30, provisioning: {}, termPrices: [] }, + })).json(); + + // A: Vertrag seit 40 Tagen, nie in Rechnung gestellt -> fällig + const orderA = (await call(app, admin, 'POST', '/orders', { orgId: A.org, items: [{ productId: prod.id }] })).json(); await drain(); + const contractA = (await call(app, admin, 'GET', `/orders/${orderA.id}`)).json().items[0].contractId; + await run('UPDATE contracts SET started_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 40 DAY) WHERE id = ?', [contractA]); + + // B: Vertrag seit 40 Tagen, aber vor 5 Tagen bereits in Rechnung gestellt -> nicht fällig + const orderB = (await call(app, admin, 'POST', '/orders', { orgId: B.org, items: [{ productId: prod.id }] })).json(); await drain(); + const contractB = (await call(app, admin, 'GET', `/orders/${orderB.id}`)).json().items[0].contractId; + await run('UPDATE contracts SET started_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 40 DAY) WHERE id = ?', [contractB]); + const invB = (await call(app, acc, 'POST', '/invoices', { orgId: B.org })).json(); + await call(app, acc, 'PUT', `/invoices/${invB.id}/items`, { items: [{ description: 'x', quantity: 1, unitPriceNetCents: 999, taxBp: 1900, contractId: contractB }] }); + await call(app, acc, 'POST', `/invoices/${invB.id}/issue`); + await run('UPDATE invoices SET issue_date = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 5 DAY) WHERE id = ?', [invB.id]); + + // C: Vertrag erst seit 2 Tagen -> nicht fällig + const orderC = (await call(app, admin, 'POST', '/orders', { orgId: C.org, items: [{ productId: prod.id }] })).json(); await drain(); + + // überfällige Rechnung bei A: ausgestellt, Zahlungsziel überschritten, offen + const invA = (await call(app, acc, 'POST', '/invoices', { orgId: A.org })).json(); + await call(app, acc, 'PUT', `/invoices/${invA.id}/items`, { items: [{ description: 'überfällig', quantity: 1, unitPriceNetCents: 5000, taxBp: 1900 }] }); + await call(app, acc, 'POST', `/invoices/${invA.id}/issue`, { dueDate: new Date(Date.now() - 3 * 86400000).toISOString().slice(0, 10) }); + + const r = (await call(app, acc, 'GET', '/invoices/reminders')).json(); + expect(r.overdueInvoices.map((i: any) => i.id)).toContain(invA.id); + expect(r.overdueInvoices.map((i: any) => i.id)).not.toContain(invB.id); + const dueOrgs = r.dueContracts.map((c: any) => c.orgId); + expect(dueOrgs).toContain(A.org); expect(dueOrgs).not.toContain(B.org); expect(dueOrgs).not.toContain(C.org); + const entryA = r.dueContracts.find((c: any) => c.orgId === A.org); expect(entryA.productName).toBe('Hosting Monatlich'); expect(entryA.lastInvoicedAt).toBeNull(); + + expect((await call(app, support, 'GET', '/invoices/reminders')).statusCode).toBe(200); // Support liest mit + expect((await call(app, A.client, 'GET', '/invoices/reminders')).statusCode).toBe(403); // Kunden nicht + }); +}); diff --git a/apps/web/src/app/(app)/rechnungen/[id]/page.tsx b/apps/web/src/app/(app)/rechnungen/[id]/page.tsx index c689917..2a4755c 100644 --- a/apps/web/src/app/(app)/rechnungen/[id]/page.tsx +++ b/apps/web/src/app/(app)/rechnungen/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState, type FormEvent } from 'react'; -import { useParams } from 'next/navigation'; +import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'; +import { useParams, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { api, errMsg } from '@/lib/api'; import { useSession } from '@/lib/session'; @@ -17,7 +17,7 @@ interface Contract { id: string; number: string; productName: string | null } interface Dom { id: string; domain: string; sellNetCents: number | null } export default function RechnungDetail() { - const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('invoices.read'); + const { id } = useParams<{ id: string }>(); const sp = useSearchParams(); const { can } = useSession(); const staff = can('invoices.read'); const [inv, setInv] = useState(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [rows, setRows] = useState([emptyRow()]); const [busy, setBusy] = useState(false); const [products, setProducts] = useState([]); const [contracts, setContracts] = useState([]); const [domains, setDomains] = useState([]); @@ -36,6 +36,9 @@ export default function RechnungDetail() { if (!pickId) return; setErr(''); try { const r = await api<{ items: Suggested[] }>('GET', `/invoices/suggest-from-${kind}/${pickId}${termMonths ? `?termMonths=${termMonths}` : ''}`); appendSuggested(r.items); } catch (x) { setErr(errMsg(x)); } } + // Von der "Fällig"-Seite kommend: Position des Vertrags einmalig automatisch übernehmen. + const fromContract = sp.get('fromContract'); const appliedRef = useRef(false); + useEffect(() => { if (fromContract && inv?.status === 'draft' && inv.items.length === 0 && !appliedRef.current) { appliedRef.current = true; void takeFrom('contract', fromContract); } }, [fromContract, inv]); // eslint-disable-line react-hooks/exhaustive-deps async function saveItems(e: FormEvent) { e.preventDefault(); const items = rows.filter((r) => r.description.trim() && r.unitPrice.trim()).map((r) => ({ description: r.description.trim(), quantity: Number(r.quantity.replace(',', '.')), unitPriceNetCents: Math.round(Number(r.unitPrice.replace(',', '.')) * 100), taxBp: Math.round(Number(r.taxBp.replace(',', '.')) * 100) })); diff --git a/apps/web/src/app/(app)/rechnungen/faellig/page.tsx b/apps/web/src/app/(app)/rechnungen/faellig/page.tsx new file mode 100644 index 0000000..3e30d3f --- /dev/null +++ b/apps/web/src/app/(app)/rechnungen/faellig/page.tsx @@ -0,0 +1,46 @@ +'use client'; +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { api, errMsg } from '@/lib/api'; +import { Alert, Empty, eur, fmt } from '@/components/ui'; + +interface OverdueInv { id: string; number: string | null; orgName: string; customerNumber: string; dueDate: string | null; totalGrossCents: number } +interface DueContract { id: string; number: string; orgId: string; orgName: string; customerNumber: string; productName: string | null; lastInvoicedAt: string | null; dueSince: string; lastGrossCents: number | null } +interface Data { overdueInvoices: OverdueInv[]; dueContracts: DueContract[] } + +export default function Faellig() { + const r = useRouter(); + const [d, setD] = useState(null); const [err, setErr] = useState(''); const [busy, setBusy] = useState(''); + useEffect(() => { api('GET', '/invoices/reminders').then(setD).catch((e) => setErr(errMsg(e))); }, []); + async function createFor(c: DueContract) { + setErr(''); setBusy(c.id); + try { const inv = await api<{ id: string }>('POST', '/invoices', { orgId: c.orgId }); r.push(`/rechnungen/${inv.id}?fromContract=${c.id}`); } + catch (x) { setErr(errMsg(x)); setBusy(''); } + } + return (<> +

Fällig

← Rechnungen
+

Überfällige, bereits ausgestellte Rechnungen und aktive Verträge, bei denen die letzte Rechnung länger zurückliegt als ihre Verlängerungslaufzeit.

+ {err && {err}} + {d === null ?

Wird geladen …

: (<> +
+

Überfällige Rechnungen ({d.overdueInvoices.length})

+ {d.overdueInvoices.length === 0 ? : +
+ {d.overdueInvoices.map((i) => ( + ))} +
Nr.KundeFällig seitBetrag
{i.number}{i.orgName} ({i.customerNumber}){i.dueDate ? fmt(i.dueDate) : '–'}{eur(i.totalGrossCents)}
} +
+
+

Verträge ohne aktuelle Rechnung ({d.dueContracts.length})

+ {d.dueContracts.length === 0 ? : +
+ {d.dueContracts.map((c) => ( + + ))} +
VertragKundeProduktZuletzt berechnetFällig seitLetzter Betrag
{c.number}{c.orgName} ({c.customerNumber}){c.productName ?? '–'}{c.lastInvoicedAt ? fmt(c.lastInvoicedAt) : nie}{fmt(c.dueSince)}{c.lastGrossCents === null ? '–' : eur(c.lastGrossCents)}
} +

Grundlage ist die Verlängerungslaufzeit des Vertrags, nicht das Abrechnungsintervall des Produkts. Bei manuell abweichender Abrechnung bitte selbst prüfen.

+
+ )} + ); +} diff --git a/apps/web/src/app/(app)/rechnungen/page.tsx b/apps/web/src/app/(app)/rechnungen/page.tsx index 528893d..32da919 100644 --- a/apps/web/src/app/(app)/rechnungen/page.tsx +++ b/apps/web/src/app/(app)/rechnungen/page.tsx @@ -1,3 +1,8 @@ 'use client'; +import Link from 'next/link'; +import { useSession } from '@/lib/session'; import { InvoiceList } from '@/components/InvoiceList'; -export default function Rechnungen() { return (<>

Rechnungen

); } +export default function Rechnungen() { + const { can } = useSession(); + return (<>

Rechnungen

{can('invoices.read') && Fällig}
); +}