From a2dcd8f2dd9de5ac67d885a832cc08eb2f888ae2 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 19 Aug 2026 13:17:13 +0200 Subject: [PATCH] Add guided Payments operator workspace --- README.md | 8 + docs/PAYMENTS_DOMAIN.md | 23 ++ pyproject.toml | 2 +- src/govoplan_payments/__init__.py | 2 +- src/govoplan_payments/backend/manifest.py | 76 +++- tests/test_payments.py | 14 + webui/package.json | 31 ++ webui/scripts/test-interface-pattern.mjs | 33 ++ webui/src/api/payments.ts | 138 +++++++ .../payments/ManualReconciliationDialog.tsx | 249 ++++++++++++ .../payments/PaymentRequestDialog.tsx | 240 ++++++++++++ webui/src/features/payments/PaymentsPage.tsx | 357 ++++++++++++++++++ webui/src/index.ts | 2 + webui/src/module.ts | 50 +++ webui/src/styles/payments.css | 19 + webui/tsconfig.json | 29 ++ 16 files changed, 1268 insertions(+), 5 deletions(-) create mode 100644 webui/package.json create mode 100644 webui/scripts/test-interface-pattern.mjs create mode 100644 webui/src/api/payments.ts create mode 100644 webui/src/features/payments/ManualReconciliationDialog.tsx create mode 100644 webui/src/features/payments/PaymentRequestDialog.tsx create mode 100644 webui/src/features/payments/PaymentsPage.tsx create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts create mode 100644 webui/src/styles/payments.css create mode 100644 webui/tsconfig.json diff --git a/README.md b/README.md index 3dca4d8..3dcddac 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ amount/currency, external transaction reference, and a same-tenant versioned or checksum-bound evidence reference. Payments rejects changed replays, partial or cross-currency matches, cross-tenant evidence, and a second settlement. +Version 0.1.20 adds the permission-aware Payments operator workspace at +`/payments`. It uses the shared Core page, action, form, dialog, and table +grammar, keeps Reload and Create in stable collection slots, and guides request +creation and exact evidence-bound manual reconciliation without exposing raw +JSON. + Online payment providers, applicant checkout, partial payments, refunds, reversals, Ledger posting, and XRechnung are intentionally separate next slices. See [docs/PAYMENTS_DOMAIN.md](docs/PAYMENTS_DOMAIN.md). @@ -23,4 +29,6 @@ Focused verification: ```sh PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \ /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests + +cd webui && npm run test:interface-pattern ``` diff --git a/docs/PAYMENTS_DOMAIN.md b/docs/PAYMENTS_DOMAIN.md index 5d95ed0..f2cd527 100644 --- a/docs/PAYMENTS_DOMAIN.md +++ b/docs/PAYMENTS_DOMAIN.md @@ -39,6 +39,29 @@ closed. Corrections, reversals, refunds, chargebacks, partial payments, and overpayments require future append-only adjustment types and must never mutate the original evidence silently. +## Operator workspace + +The permission-aware `/payments` workspace is the operator projection of this +contract. Readers can filter requested and paid obligations and inspect their +source, Case/Workflow context references, amount, due or settled time, and +immutable evidence reference. Writers create fixed obligations in a guided +dialog; the UI supplies an explicit replay key and never copies applicant or +Form content into Payments. + +Reconciliation uses a separate consequential dialog. Amount and currency are +fixed from the selected obligation rather than editable. The operator records +the external transaction reference, receipt time, evidence owner, kind, ID, +and at least one immutable version or checksum. The dialog explains that paid +state cannot be silently undone and that a governed adjustment is required. +Missing permissions remain visible with the exact scope and responsible +administrator. + +Reload is always available in the collection action bar. A failed refresh +preserves the last successful result and labels it stale; an initial failure +uses a whole-surface retry state. The workspace also distinguishes loading, +empty, permission-blocked, conflict, replay-success, and ordinary success +states. + ## Access, privacy, and audit Payment readers see obligation and reconciliation metadata. Writers create diff --git a/pyproject.toml b/pyproject.toml index 95ab708..2f643a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-payments" -version = "0.1.19" +version = "0.1.20" description = "Replay-safe payment obligations and reconciliation evidence for GovOPlaN." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_payments/__init__.py b/src/govoplan_payments/__init__.py index 7146c3f..e167a0e 100644 --- a/src/govoplan_payments/__init__.py +++ b/src/govoplan_payments/__init__.py @@ -1,3 +1,3 @@ """GovOPlaN Payments module.""" -__version__ = "0.1.19" +__version__ = "0.1.20" diff --git a/src/govoplan_payments/backend/manifest.py b/src/govoplan_payments/backend/manifest.py index edcd4a2..1515916 100644 --- a/src/govoplan_payments/backend/manifest.py +++ b/src/govoplan_payments/backend/manifest.py @@ -10,15 +10,20 @@ from govoplan_core.core.modules import ( CapabilityDocumentation, DocumentationLink, DocumentationTopic, + FrontendModule, + FrontendRoute, MigrationSpec, ModuleContext, ModuleInterfaceProvider, ModuleManifest, + NavItem, PermissionDefinition, + ProductAreaContribution, RoleTemplate, ) from govoplan_core.core.payments import CAPABILITY_PAYMENT_REQUESTS from govoplan_core.core.provider_governance import declared_module_architecture +from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base from govoplan_payments.backend.db import models as payment_models from govoplan_payments.backend.service import SqlPaymentRequestProvider @@ -26,7 +31,7 @@ from govoplan_payments.backend.service import SqlPaymentRequestProvider MODULE_ID = "payments" MODULE_NAME = "Payments" -MODULE_VERSION = "0.1.19" +MODULE_VERSION = "0.1.20" READ_SCOPE = "payments:payment:read" WRITE_SCOPE = "payments:payment:write" RECONCILE_SCOPE = "payments:payment:reconcile" @@ -122,6 +127,68 @@ manifest = ModuleManifest( permissions=(READ_SCOPE,), ), ), + nav_items=( + NavItem( + path="/payments", + label="Payments", + icon="landmark", + required_any=(READ_SCOPE,), + order=73, + surface_id="payments.navigation", + ), + ), + frontend=FrontendModule( + module_id=MODULE_ID, + package_name="@govoplan/payments-webui", + routes=( + FrontendRoute( + path="/payments", + component="PaymentsPage", + required_any=(READ_SCOPE,), + order=73, + surface_id="payments.workspace", + ), + ), + nav_items=( + NavItem( + path="/payments", + label="Payments", + icon="landmark", + required_any=(READ_SCOPE,), + order=73, + surface_id="payments.navigation", + ), + ), + product_areas=( + ProductAreaContribution( + id="services-cases", + module_id=MODULE_ID, + label="i18n:govoplan-core.product_area.services_cases", + icon="landmark", + description="i18n:govoplan-core.product_area.services_cases_description", + surface_ids=("payments.navigation", "payments.workspace"), + order=20, + ), + ), + view_surfaces=( + ViewSurface( + id="payments.request.create", + module_id=MODULE_ID, + kind="section", + label="Create payment request", + parent_id="payments.workspace", + order=30, + ), + ViewSurface( + id="payments.reconciliation.manual", + module_id=MODULE_ID, + kind="section", + label="Record manual payment", + parent_id="payments.workspace", + order=40, + ), + ), + ), provides_interfaces=( ModuleInterfaceProvider(name=CAPABILITY_PAYMENT_REQUESTS, version="1.0.0"), ), @@ -167,7 +234,8 @@ manifest = ModuleManifest( body=( "Payments owns the tenant-bound payment ID, human payment reference, requested amount and currency, lifecycle events, and reconciliation evidence. " "A Case, Workflow, or other procedure calls the payments.requests capability with its own source reference and a replay key; it keeps the returned payment ID instead of writing Payments tables. " - "The first supported receipt path is manual reconciliation of a full payment. The operator must record the exact amount and currency, external transaction reference, received time, and a same-tenant EvidenceReference carrying a version or checksum. A mismatch, duplicate settlement under another key, cross-tenant evidence, partial amount, or timezone-free timestamp fails closed. " + "The Payments workspace lists requested and paid obligations with source, due or settled times, and reconciliation evidence. A writer creates a request through the guided dialog; a reconciler uses the separate consequential dialog, which fixes the amount and currency and requires an external transaction reference plus a same-tenant versioned or checksum-bound EvidenceReference. Reload preserves loaded data and marks it stale when refresh fails. Missing create or reconciliation authority remains visible with the required permission and responsible administrator. " + "The first supported receipt path is manual reconciliation of a full payment. A mismatch, duplicate settlement under another key, cross-tenant evidence, partial amount, or timezone-free timestamp fails closed. " "Successful requests and reconciliations append payment events and API actions add audit evidence when Audit is installed. There is no silent correction: reversal, refund, partial payment, online checkout, provider callbacks, Ledger posting, and XRechnung remain explicit future flows." ), layer="configured", @@ -183,6 +251,8 @@ manifest = ModuleManifest( metadata={ "help_contexts": [ "payments.request", + "payments.workspace", + "payments.request.create", "payments.reconciliation.manual", "payments.state.requested", "payments.state.paid", @@ -206,7 +276,7 @@ manifest = ModuleManifest( test_ref="tests/test_payments.py", known_limits=( "Only full manual payment reconciliation is implemented; partial payments, refunds, reversals, and corrections need explicit governed flows.", - "No online payment provider, callback, ledger posting, XRechnung, applicant payment page, or dedicated operator WebUI is included yet.", + "No online payment provider, callback, ledger posting, XRechnung, or applicant payment page is included yet; the operator workspace covers fixed requests and full manual reconciliation only.", ), supported_authority_modes=("native_authoritative",), owned_concepts=( diff --git a/tests/test_payments.py b/tests/test_payments.py index 9f898de..cbc4dcd 100644 --- a/tests/test_payments.py +++ b/tests/test_payments.py @@ -21,6 +21,7 @@ from govoplan_payments.backend.service import ( PaymentError, SqlPaymentRequestProvider, ) +from govoplan_payments.backend.manifest import manifest NOW = datetime(2026, 8, 19, 10, 0, tzinfo=UTC) @@ -199,6 +200,19 @@ class PaymentTests(unittest.TestCase): self.assertEqual(1, len(requested)) self.assertEqual((), self.provider.list_payments(self.session, tenant_id="tenant-2")) + def test_manifest_exposes_permission_bounded_operator_workspace(self) -> None: + self.assertEqual("0.1.20", manifest.version) + self.assertIsNotNone(manifest.frontend) + assert manifest.frontend is not None + self.assertEqual("@govoplan/payments-webui", manifest.frontend.package_name) + self.assertEqual("/payments", manifest.frontend.routes[0].path) + self.assertEqual(("payments:payment:read",), manifest.frontend.routes[0].required_any) + self.assertEqual("payments.workspace", manifest.frontend.routes[0].surface_id) + self.assertEqual("payments.navigation", manifest.frontend.nav_items[0].surface_id) + surface_ids = {surface.id for surface in manifest.frontend.view_surfaces} + self.assertIn("payments.request.create", surface_ids) + self.assertIn("payments.reconciliation.manual", surface_ids) + if __name__ == "__main__": unittest.main() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..715c6ed --- /dev/null +++ b/webui/package.json @@ -0,0 +1,31 @@ +{ + "name": "@govoplan/payments-webui", + "version": "0.1.20", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./styles/payments.css": "./src/styles/payments.css" + }, + "scripts": { + "test:interface-pattern": "node scripts/test-interface-pattern.mjs" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.18", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/scripts/test-interface-pattern.mjs b/webui/scripts/test-interface-pattern.mjs new file mode 100644 index 0000000..a28faae --- /dev/null +++ b/webui/scripts/test-interface-pattern.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(fileURLToPath(new URL("..", import.meta.url))); +const read = (path) => readFileSync(resolve(root, path), "utf8"); +const page = read("src/features/payments/PaymentsPage.tsx"); +const createDialog = read("src/features/payments/PaymentRequestDialog.tsx"); +const reconcileDialog = read("src/features/payments/ManualReconciliationDialog.tsx"); +const styles = read("src/styles/payments.css"); + +assert.match(page, /; +}; + +export type PaymentRequest = { + payment_id: string; + tenant_id: string; + payment_reference: string; + source: { + module: string; + resource_type: string; + resource_id: string; + }; + amount_minor: number; + currency: string; + subject: string; + status: PaymentStatus; + requested_at: string; + requested_by_ref: string; + due_at?: string | null; + settled_at?: string | null; + context_refs: Record; + metadata: Record; + reconciliation?: PaymentReconciliation | null; + events: PaymentEvent[]; + replayed: boolean; +}; + +export type PaymentRequestCreate = { + source_module: string; + source_resource_type: string; + source_resource_id: string; + amount_minor: number; + currency: string; + subject: string; + idempotency_key: string; + due_at?: string | null; + context_refs: Record; + metadata: Record; +}; + +export type ManualPaymentReconciliationCreate = { + amount_minor: number; + currency: string; + transaction_reference: string; + evidence_ref: EvidenceReference; + idempotency_key: string; + received_at: string; + metadata: Record; +}; + +export async function listPaymentRequests( + settings: ApiSettings, + filters: { status?: PaymentStatus; sourceResourceId?: string; limit?: number } = {}, + signal?: AbortSignal +): Promise { + const response = await apiFetch<{ payments: PaymentRequest[] }>( + settings, + apiPath("/api/v1/payments/requests", { + status: filters.status, + source_resource_id: filters.sourceResourceId, + limit: filters.limit ?? 200 + }), + { signal } + ); + return response.payments; +} + +export function createPaymentRequest( + settings: ApiSettings, + payload: PaymentRequestCreate +): Promise { + return apiFetch(settings, "/api/v1/payments/requests", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function reconcileManualPayment( + settings: ApiSettings, + paymentId: string, + payload: ManualPaymentReconciliationCreate +): Promise { + return apiFetch( + settings, + `/api/v1/payments/requests/${encodeURIComponent(paymentId)}/manual-reconciliations`, + { method: "POST", body: JSON.stringify(payload) } + ); +} + +export function paymentApiErrorMessage(reason: unknown): string { + if (reason instanceof ApiError) { + try { + const payload = JSON.parse(reason.body) as { detail?: unknown }; + if (typeof payload.detail === "string") return payload.detail; + } catch { + // The response body may be plain text. + } + if (reason.status === 409) return "The payment changed or this replay key is already bound to different evidence. Reload and review the current state."; + if (reason.status === 403) return "Your current role does not permit this payment action."; + } + return reason instanceof Error ? reason.message : String(reason); +} diff --git a/webui/src/features/payments/ManualReconciliationDialog.tsx b/webui/src/features/payments/ManualReconciliationDialog.tsx new file mode 100644 index 0000000..222695a --- /dev/null +++ b/webui/src/features/payments/ManualReconciliationDialog.tsx @@ -0,0 +1,249 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { + Button, + DateTimeField, + DescriptionItem, + DescriptionList, + Dialog, + DialogForm, + DialogSection, + DismissibleAlert, + FormField, + FormGrid, + useUnsavedChanges, + useUnsavedDraftGuard, + type ApiSettings +} from "@govoplan/core-webui"; +import { + paymentApiErrorMessage, + reconcileManualPayment, + type PaymentRequest +} from "../../api/payments"; + +type ManualReconciliationDialogProps = { + open: boolean; + settings: ApiSettings; + tenantId: string; + payment: PaymentRequest | null; + onClose: () => void; + onReconciled: (payment: PaymentRequest) => void; +}; + +type ReconciliationDraft = { + transactionReference: string; + receivedAt: string; + evidenceOwnerModule: string; + evidenceKind: string; + evidenceId: string; + evidenceVersion: string; + evidenceChecksum: string; + idempotencyKey: string; +}; + +const FORM_ID = "payments-manual-reconciliation-form"; + +function localDateTime(date = new Date()): string { + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); + return local.toISOString().slice(0, 16); +} + +function replayKey(): string { + const suffix = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `payments-ui-reconciliation-${suffix}`; +} + +function emptyDraft(): ReconciliationDraft { + return { + transactionReference: "", + receivedAt: localDateTime(), + evidenceOwnerModule: "files", + evidenceKind: "document", + evidenceId: "", + evidenceVersion: "", + evidenceChecksum: "", + idempotencyKey: replayKey() + }; +} + +function formatAmount(amountMinor: number, currency: string): string { + try { + return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amountMinor / 100); + } catch { + return `${(amountMinor / 100).toFixed(2)} ${currency}`; + } +} + +export default function ManualReconciliationDialog({ + open, + settings, + tenantId, + payment, + onClose, + onReconciled +}: ManualReconciliationDialogProps) { + const [draft, setDraft] = useState(emptyDraft); + const [dirty, setDirty] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const { requestDiscard } = useUnsavedChanges(); + + function reset() { + setDraft(emptyDraft()); + setDirty(false); + setError(""); + } + + useEffect(() => { + if (open) reset(); + }, [open, payment?.payment_id]); + + function change(key: K, value: ReconciliationDraft[K]) { + setDraft((current) => ({ ...current, [key]: value })); + setDirty(true); + } + + async function submit(): Promise { + if (!payment) return false; + if (!draft.transactionReference.trim() || !draft.evidenceOwnerModule.trim() || !draft.evidenceKind.trim() || !draft.evidenceId.trim()) { + setError("Transaction reference and evidence owner, kind, and ID are required."); + return false; + } + if (!draft.evidenceVersion.trim() && !draft.evidenceChecksum.trim()) { + setError("Provide an evidence version or checksum so the receipt evidence is immutable."); + return false; + } + if (!draft.receivedAt) { + setError("Payment received time is required."); + return false; + } + + setBusy(true); + setError(""); + try { + const reconciled = await reconcileManualPayment(settings, payment.payment_id, { + amount_minor: payment.amount_minor, + currency: payment.currency, + transaction_reference: draft.transactionReference.trim(), + evidence_ref: { + tenant_id: tenantId, + owner_module: draft.evidenceOwnerModule.trim(), + kind: draft.evidenceKind.trim(), + evidence_id: draft.evidenceId.trim(), + version: draft.evidenceVersion.trim() || null, + checksum: draft.evidenceChecksum.trim() || null + }, + idempotency_key: draft.idempotencyKey.trim(), + received_at: new Date(draft.receivedAt).toISOString(), + metadata: {} + }); + setDirty(false); + onReconciled(reconciled); + return true; + } catch (reason) { + setError(paymentApiErrorMessage(reason)); + return false; + } finally { + setBusy(false); + } + } + + useUnsavedDraftGuard({ + dirty: open && dirty, + title: "Discard the reconciliation draft?", + message: "No payment state has changed yet. Save the exact receipt evidence before leaving or discard this draft.", + onSave: submit, + onDiscard: reset, + enabled: open + }); + + function close() { + if (busy) return; + if (dirty) requestDiscard(onClose); + else onClose(); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + void submit(); + } + + return ( + {error} : null} + footer={( + <> + + + + )} + > + + {payment && ( + +

Exact obligation

+ + {payment.payment_reference} + {formatAmount(payment.amount_minor, payment.currency)} + {payment.source.module}:{payment.source.resource_type}:{payment.source.resource_id} + Requested + +

This action appends reconciliation evidence and marks the obligation paid. It cannot be silently undone; correction or reversal requires a future governed adjustment flow.

+
+ )} + + +

Receipt

+ + + change("transactionReference", event.target.value)} /> + + + change("receivedAt", value)} aria-label="Payment received date and time" /> + + +
+ + +

Immutable evidence

+

Payments stores only this typed reference. The evidence bytes and retention remain with the owning module.

+ + + change("evidenceOwnerModule", event.target.value)} /> + + + change("evidenceKind", event.target.value)} /> + + + change("evidenceId", event.target.value)} /> + + + change("evidenceVersion", event.target.value)} /> + + + change("evidenceChecksum", event.target.value)} /> + + +
+ + +

Replay protection

+

Retry this key only for this exact payment and evidence. A changed replay conflicts instead of creating ambiguous settlement evidence.

+ + change("idempotencyKey", event.target.value)} /> + +
+
+
+ ); +} diff --git a/webui/src/features/payments/PaymentRequestDialog.tsx b/webui/src/features/payments/PaymentRequestDialog.tsx new file mode 100644 index 0000000..a931625 --- /dev/null +++ b/webui/src/features/payments/PaymentRequestDialog.tsx @@ -0,0 +1,240 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { + Button, + DateTimeField, + Dialog, + DialogForm, + DialogSection, + DismissibleAlert, + FormField, + FormGrid, + useUnsavedChanges, + useUnsavedDraftGuard, + type ApiSettings +} from "@govoplan/core-webui"; +import { + createPaymentRequest, + paymentApiErrorMessage, + type PaymentRequest, + type PaymentRequestCreate +} from "../../api/payments"; + +type PaymentRequestDialogProps = { + open: boolean; + settings: ApiSettings; + onClose: () => void; + onCreated: (payment: PaymentRequest) => void; +}; + +type PaymentRequestDraft = { + sourceModule: string; + sourceResourceType: string; + sourceResourceId: string; + subject: string; + amount: string; + currency: string; + dueAt: string; + caseRef: string; + workflowRef: string; + idempotencyKey: string; +}; + +const FORM_ID = "payments-create-request-form"; + +function replayKey(prefix: string): string { + const suffix = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${prefix}-${suffix}`; +} + +function emptyDraft(): PaymentRequestDraft { + return { + sourceModule: "cases", + sourceResourceType: "case", + sourceResourceId: "", + subject: "", + amount: "", + currency: "EUR", + dueAt: "", + caseRef: "", + workflowRef: "", + idempotencyKey: replayKey("payments-ui-request") + }; +} + +function amountToMinor(value: string): number | null { + const normalized = value.trim().replace(",", "."); + if (!/^\d+(?:\.\d{1,2})?$/.test(normalized)) return null; + const [whole, fraction = ""] = normalized.split("."); + const result = Number(whole) * 100 + Number(fraction.padEnd(2, "0")); + return Number.isSafeInteger(result) && result > 0 ? result : null; +} + +export default function PaymentRequestDialog({ open, settings, onClose, onCreated }: PaymentRequestDialogProps) { + const [draft, setDraft] = useState(emptyDraft); + const [dirty, setDirty] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const { requestDiscard } = useUnsavedChanges(); + + function reset() { + setDraft(emptyDraft()); + setDirty(false); + setError(""); + } + + useEffect(() => { + if (open) reset(); + }, [open]); + + function change(key: K, value: PaymentRequestDraft[K]) { + setDraft((current) => ({ ...current, [key]: value })); + setDirty(true); + } + + async function submit(): Promise { + const amountMinor = amountToMinor(draft.amount); + if (!draft.sourceModule.trim() || !draft.sourceResourceType.trim() || !draft.sourceResourceId.trim() || !draft.subject.trim()) { + setError("Source, source ID, and payment subject are required."); + return false; + } + if (amountMinor === null) { + setError("Enter a positive amount with no more than two decimal places."); + return false; + } + if (!/^[A-Za-z]{3}$/.test(draft.currency.trim())) { + setError("Currency must be a three-letter ISO code."); + return false; + } + + const contextRefs = Object.fromEntries([ + ["case", draft.caseRef.trim()], + ["workflow", draft.workflowRef.trim()] + ].filter((entry): entry is [string, string] => Boolean(entry[1]))); + const payload: PaymentRequestCreate = { + source_module: draft.sourceModule.trim(), + source_resource_type: draft.sourceResourceType.trim(), + source_resource_id: draft.sourceResourceId.trim(), + amount_minor: amountMinor, + currency: draft.currency.trim().toUpperCase(), + subject: draft.subject.trim(), + idempotency_key: draft.idempotencyKey.trim(), + due_at: draft.dueAt ? new Date(draft.dueAt).toISOString() : null, + context_refs: contextRefs, + metadata: {} + }; + + setBusy(true); + setError(""); + try { + const payment = await createPaymentRequest(settings, payload); + setDirty(false); + onCreated(payment); + return true; + } catch (reason) { + setError(paymentApiErrorMessage(reason)); + return false; + } finally { + setBusy(false); + } + } + + useUnsavedDraftGuard({ + dirty: open && dirty, + title: "Discard the payment request draft?", + message: "The payment request has not been created. Save it before leaving or discard the draft.", + onSave: submit, + onDiscard: reset, + enabled: open + }); + + function close() { + if (busy) return; + if (dirty) requestDiscard(onClose); + else onClose(); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + void submit(); + } + + return ( + {error} : null} + footer={( + <> + + + + )} + > + + +

Owning source

+

Use the stable reference of the Case, Workflow, or other procedure that owns this obligation.

+ + + change("sourceModule", event.target.value)} /> + + + change("sourceResourceType", event.target.value)} /> + + + change("sourceResourceId", event.target.value)} /> + + + change("subject", event.target.value)} /> + + +
+ + +

Obligation

+ + + change("amount", event.target.value)} /> + + + change("currency", event.target.value.toUpperCase())} /> + + + change("dueAt", value)} aria-label="Payment due date and time" /> + + +
+ + +

Procedure context

+

Optional references make the source visible without copying applicant or form data into Payments.

+ + + change("caseRef", event.target.value)} /> + + + change("workflowRef", event.target.value)} /> + + +
+ + +

Replay protection

+

Retry with this key only for the same source, amount, currency, subject, dates, and context. Reusing it for changed values is rejected.

+ + change("idempotencyKey", event.target.value)} /> + +
+
+
+ ); +} diff --git a/webui/src/features/payments/PaymentsPage.tsx b/webui/src/features/payments/PaymentsPage.tsx new file mode 100644 index 0000000..ce57485 --- /dev/null +++ b/webui/src/features/payments/PaymentsPage.tsx @@ -0,0 +1,357 @@ +import { CheckCircle2, Plus, RefreshCw } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Button, + Card, + DataGrid, + DismissibleAlert, + DocumentationHelpLink, + FilterBar, + IconButton, + MetricCard, + MetricGrid, + PageActionBar, + PageLayout, + StatePanel, + StatusBadge, + TableActionGroup, + WorkspaceFrame, + hasScope, + type DataGridColumn, + type PlatformRouteContext +} from "@govoplan/core-webui"; +import { + listPaymentRequests, + paymentApiErrorMessage, + type PaymentRequest, + type PaymentStatus +} from "../../api/payments"; +import ManualReconciliationDialog from "./ManualReconciliationDialog"; +import PaymentRequestDialog from "./PaymentRequestDialog"; + +function formatAmount(amountMinor: number, currency: string): string { + try { + return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(amountMinor / 100); + } catch { + return `${(amountMinor / 100).toFixed(2)} ${currency}`; + } +} + +function formatDateTime(value?: string | null): string { + if (!value) return "—"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +function reconciliationEvidence(payment: PaymentRequest): string { + const evidence = payment.reconciliation?.evidence_ref; + if (!evidence) return "Not recorded"; + const immutableRef = evidence.version ? `version ${evidence.version}` : `checksum ${String(evidence.checksum).slice(0, 12)}…`; + return `${evidence.owner_module}:${evidence.evidence_id} · ${immutableRef}`; +} + +export default function PaymentsPage({ settings, auth }: PlatformRouteContext) { + const [payments, setPayments] = useState([]); + const [statusFilter, setStatusFilter] = useState<"all" | PaymentStatus>("all"); + const [sourceFilter, setSourceFilter] = useState(""); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [initialError, setInitialError] = useState(""); + const [staleError, setStaleError] = useState(""); + const [success, setSuccess] = useState(""); + const [loadedAt, setLoadedAt] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [reconcilingPayment, setReconcilingPayment] = useState(null); + const loadedRef = useRef(false); + + const canRead = hasScope(auth, "payments:payment:read"); + const canCreate = hasScope(auth, "payments:payment:write"); + const canReconcile = hasScope(auth, "payments:payment:reconcile"); + const tenantId = auth.active_tenant?.id ?? auth.tenant.id; + + async function reload(signal?: AbortSignal) { + if (!canRead) { + setLoading(false); + return; + } + if (loadedRef.current) setRefreshing(true); + else setLoading(true); + setInitialError(""); + try { + const result = await listPaymentRequests(settings, {}, signal); + setPayments(result); + setLoadedAt(new Date()); + setStaleError(""); + loadedRef.current = true; + } catch (reason) { + if (reason instanceof Error && reason.name === "AbortError") return; + const message = paymentApiErrorMessage(reason); + if (loadedRef.current) setStaleError(message); + else setInitialError(message); + } finally { + setLoading(false); + setRefreshing(false); + } + } + + useEffect(() => { + loadedRef.current = false; + const controller = new AbortController(); + void reload(controller.signal); + return () => controller.abort(); + }, [settings, canRead]); + + const filteredPayments = useMemo(() => { + const sourceQuery = sourceFilter.trim().toLocaleLowerCase(); + return payments.filter((payment) => { + if (statusFilter !== "all" && payment.status !== statusFilter) return false; + if (!sourceQuery) return true; + return [ + payment.source.module, + payment.source.resource_type, + payment.source.resource_id, + payment.context_refs.case, + payment.context_refs.workflow, + payment.payment_reference, + payment.subject + ].filter(Boolean).join(" ").toLocaleLowerCase().includes(sourceQuery); + }); + }, [payments, sourceFilter, statusFilter]); + + const requestedCount = payments.filter((payment) => payment.status === "requested").length; + const paidCount = payments.filter((payment) => payment.status === "paid").length; + const overdueCount = payments.filter((payment) => payment.status === "requested" && payment.due_at && new Date(payment.due_at) < new Date()).length; + + const columns = useMemo[]>(() => [ + { + id: "reference", + header: "Payment", + width: "1.2fr", + minWidth: 220, + sortable: true, + filterable: true, + value: (payment) => `${payment.payment_reference} ${payment.subject}`, + render: (payment) =>
{payment.subject}{payment.payment_reference}
+ }, + { + id: "source", + header: "Owning source", + width: "1.1fr", + minWidth: 210, + sortable: true, + filterable: true, + value: (payment) => `${payment.source.module} ${payment.source.resource_type} ${payment.source.resource_id} ${payment.context_refs.case ?? ""} ${payment.context_refs.workflow ?? ""}`, + render: (payment) => ( +
+ {payment.source.module}:{payment.source.resource_type} + {payment.source.resource_id} + {(payment.context_refs.case || payment.context_refs.workflow) && {[payment.context_refs.case && `Case ${payment.context_refs.case}`, payment.context_refs.workflow && `Workflow ${payment.context_refs.workflow}`].filter(Boolean).join(" · ")}} +
+ ) + }, + { + id: "amount", + header: "Amount", + width: 130, + minWidth: 120, + align: "right", + sortable: true, + sortValue: (payment) => payment.amount_minor, + render: (payment) => {formatAmount(payment.amount_minor, payment.currency)} + }, + { + id: "status", + header: "State", + width: 115, + minWidth: 105, + sortable: true, + filterable: true, + filterType: "list", + value: (payment) => payment.status, + list: { + options: [ + { value: "requested", label: "Requested" }, + { value: "paid", label: "Paid" } + ], + display: "pill" + }, + render: (payment) => + }, + { + id: "dates", + header: "Due / settled", + width: 190, + minWidth: 170, + sortable: true, + sortValue: (payment) => payment.settled_at ?? payment.due_at ?? payment.requested_at, + render: (payment) =>
{payment.status === "paid" ? `Settled ${formatDateTime(payment.settled_at)}` : `Due ${formatDateTime(payment.due_at)}`}Requested {formatDateTime(payment.requested_at)}
+ }, + { + id: "evidence", + header: "Reconciliation evidence", + width: "1fr", + minWidth: 210, + filterable: true, + value: reconciliationEvidence, + render: (payment) => ( +
+ {payment.reconciliation?.transaction_reference ?? "Not reconciled"} + {reconciliationEvidence(payment)} +
+ ) + }, + { + id: "actions", + header: "Actions", + width: 88, + minWidth: 88, + sticky: "end", + align: "right", + resizable: false, + render: (payment) => ( + , + onClick: () => setReconcilingPayment(payment), + disabled: payment.status === "paid" || !canReconcile, + disabledReason: payment.status === "paid" + ? "This payment is already reconciled. A correction requires a governed adjustment flow." + : !canReconcile + ? "The payments:payment:reconcile permission is required. Ask a Payments administrator to grant a reconciliation role." + : undefined + } + ]} + /> + ) + } + ], [canReconcile]); + + function handleCreated(payment: PaymentRequest) { + setPayments((current) => [payment, ...current.filter((item) => item.payment_id !== payment.payment_id)]); + setCreateOpen(false); + setSuccess(payment.replayed + ? `Payment request ${payment.payment_reference} was returned from the existing replay key.` + : `Payment request ${payment.payment_reference} was created.`); + } + + function handleReconciled(payment: PaymentRequest) { + setPayments((current) => current.map((item) => item.payment_id === payment.payment_id ? payment : item)); + setReconcilingPayment(null); + setSuccess(payment.replayed + ? `Existing reconciliation for ${payment.payment_reference} was returned from the replay key.` + : `Payment ${payment.payment_reference} was recorded as paid with immutable evidence.`); + } + + const createButton = ( + + ); + + return ( + + } variant="ghost" onClick={() => void reload()} disabled={refreshing} />} + helpAction={} + createAction={createButton} + /> + )} + notices={staleError ? ( + +
The loaded payment list may be stale because refresh failed: {staleError}
+
+
+ ) : null} + > + {!canRead ? ( + + ) : initialError ? ( + void reload()}>Retry} + /> + ) : ( + <> + + + + + + + + + + setSourceFilter(event.target.value)} /> + {(statusFilter !== "all" || sourceFilter) && } + + + + payment.payment_id} + initialSort={{ columnId: "dates", direction: "desc" }} + emptyText="No payment requests have been created." + filteredEmptyText="No payment requests match the current filters." + emptyAction={createButton} + emptyActionColumnId="actions" + /> + + + )} +
+ + setCreateOpen(false)} onCreated={handleCreated} /> + setReconcilingPayment(null)} + onReconciled={handleReconciled} + /> +
+ ); +} diff --git a/webui/src/index.ts b/webui/src/index.ts new file mode 100644 index 0000000..203c19a --- /dev/null +++ b/webui/src/index.ts @@ -0,0 +1,2 @@ +export { default, paymentsModule } from "./module"; +export * from "./api/payments"; diff --git a/webui/src/module.ts b/webui/src/module.ts new file mode 100644 index 0000000..ed65d9e --- /dev/null +++ b/webui/src/module.ts @@ -0,0 +1,50 @@ +import { createElement, lazy } from "react"; +import type { PlatformWebModule } from "@govoplan/core-webui"; +import "./styles/payments.css"; + +const PaymentsPage = lazy(() => import("./features/payments/PaymentsPage")); + +export const paymentsModule: PlatformWebModule = { + id: "payments", + label: "Payments", + version: "0.1.20", + optionalDependencies: ["files", "audit", "cases", "workflow_engine", "ledger", "xrechnung"], + routes: [ + { + path: "/payments", + anyOf: ["payments:payment:read"], + order: 73, + surfaceId: "payments.workspace", + render: (context) => createElement(PaymentsPage, context) + } + ], + navItems: [ + { + to: "/payments", + label: "Payments", + iconName: "landmark", + anyOf: ["payments:payment:read"], + order: 73, + surfaceId: "payments.navigation" + } + ], + productAreas: [ + { + id: "services-cases", + moduleId: "payments", + label: "i18n:govoplan-core.product_area.services_cases", + description: "i18n:govoplan-core.product_area.services_cases_description", + iconName: "landmark", + surfaceIds: ["payments.navigation", "payments.workspace"], + order: 20 + } + ], + viewSurfaces: [ + { id: "payments.navigation", moduleId: "payments", kind: "navigation", label: "Payments navigation", order: 10 }, + { id: "payments.workspace", moduleId: "payments", kind: "route", label: "Payment request workspace", order: 20 }, + { id: "payments.request.create", moduleId: "payments", kind: "section", label: "Create payment request", parentId: "payments.workspace", order: 30 }, + { id: "payments.reconciliation.manual", moduleId: "payments", kind: "section", label: "Record manual payment", parentId: "payments.workspace", order: 40 } + ] +}; + +export default paymentsModule; diff --git a/webui/src/styles/payments.css b/webui/src/styles/payments.css new file mode 100644 index 0000000..12b85a1 --- /dev/null +++ b/webui/src/styles/payments.css @@ -0,0 +1,19 @@ +.payments-page .page-layout-body { display: grid; gap: 18px; } +.payments-filter-bar { justify-content: flex-start; } +.payments-filter-bar input { min-width: min(320px, 100%); } +.payments-amount { font-variant-numeric: tabular-nums; white-space: nowrap; } +.payments-source { min-width: 0; display: grid; gap: 2px; } +.payments-source span { overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.payments-dates { display: grid; gap: 3px; font-size: 12px; } +.payments-dates span { color: var(--muted); } +.payments-evidence { min-width: 0; display: grid; gap: 2px; font-size: 12px; overflow-wrap: anywhere; } +.payments-notice-action { margin-top: 8px; } +.payments-dialog-copy { margin: 0; color: var(--muted); line-height: 1.5; } +.payments-dialog-section-title { margin: 0 0 8px; color: var(--text-strong); font-size: 14px; } +.payments-dialog-section-copy { margin: 0 0 12px; color: var(--muted); line-height: 1.5; } +.payments-readonly-amount { color: var(--text-strong); font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; } +.payments-reconciliation-warning { border-left: 3px solid var(--amber); } +@media (max-width: 760px) { + .payments-filter-bar input, + .payments-filter-bar select { width: 100%; min-width: 0; } +} diff --git a/webui/tsconfig.json b/webui/tsconfig.json new file mode 100644 index 0000000..523effe --- /dev/null +++ b/webui/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"], + "lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"], + "react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"], + "react/*": ["../../govoplan-core/webui/node_modules/@types/react/*"] + } + }, + "include": ["src", "../../govoplan-core/webui/src/vite-env.d.ts"] +}