1 Commits
Author SHA1 Message Date
zemion a2dcd8f2dd Add guided Payments operator workspace
Module Package Release / publish-packages (push) Successful in 14s
2026-08-19 13:17:13 +02:00
16 changed files with 1268 additions and 5 deletions
+8
View File
@@ -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 checksum-bound evidence reference. Payments rejects changed replays, partial or
cross-currency matches, cross-tenant evidence, and a second settlement. 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, Online payment providers, applicant checkout, partial payments, refunds,
reversals, Ledger posting, and XRechnung are intentionally separate next reversals, Ledger posting, and XRechnung are intentionally separate next
slices. See [docs/PAYMENTS_DOMAIN.md](docs/PAYMENTS_DOMAIN.md). slices. See [docs/PAYMENTS_DOMAIN.md](docs/PAYMENTS_DOMAIN.md).
@@ -23,4 +29,6 @@ Focused verification:
```sh ```sh
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \ PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
cd webui && npm run test:interface-pattern
``` ```
+23
View File
@@ -39,6 +39,29 @@ closed. Corrections, reversals, refunds, chargebacks, partial payments, and
overpayments require future append-only adjustment types and must never mutate overpayments require future append-only adjustment types and must never mutate
the original evidence silently. 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 ## Access, privacy, and audit
Payment readers see obligation and reconciliation metadata. Writers create Payment readers see obligation and reconciliation metadata. Writers create
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-payments" name = "govoplan-payments"
version = "0.1.19" version = "0.1.20"
description = "Replay-safe payment obligations and reconciliation evidence for GovOPlaN." description = "Replay-safe payment obligations and reconciliation evidence for GovOPlaN."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN Payments module.""" """GovOPlaN Payments module."""
__version__ = "0.1.19" __version__ = "0.1.20"
+73 -3
View File
@@ -10,15 +10,20 @@ from govoplan_core.core.modules import (
CapabilityDocumentation, CapabilityDocumentation,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec, MigrationSpec,
ModuleContext, ModuleContext,
ModuleInterfaceProvider, ModuleInterfaceProvider,
ModuleManifest, ModuleManifest,
NavItem,
PermissionDefinition, PermissionDefinition,
ProductAreaContribution,
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.payments import CAPABILITY_PAYMENT_REQUESTS from govoplan_core.core.payments import CAPABILITY_PAYMENT_REQUESTS
from govoplan_core.core.provider_governance import declared_module_architecture 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_core.db.base import Base
from govoplan_payments.backend.db import models as payment_models from govoplan_payments.backend.db import models as payment_models
from govoplan_payments.backend.service import SqlPaymentRequestProvider from govoplan_payments.backend.service import SqlPaymentRequestProvider
@@ -26,7 +31,7 @@ from govoplan_payments.backend.service import SqlPaymentRequestProvider
MODULE_ID = "payments" MODULE_ID = "payments"
MODULE_NAME = "Payments" MODULE_NAME = "Payments"
MODULE_VERSION = "0.1.19" MODULE_VERSION = "0.1.20"
READ_SCOPE = "payments:payment:read" READ_SCOPE = "payments:payment:read"
WRITE_SCOPE = "payments:payment:write" WRITE_SCOPE = "payments:payment:write"
RECONCILE_SCOPE = "payments:payment:reconcile" RECONCILE_SCOPE = "payments:payment:reconcile"
@@ -122,6 +127,68 @@ manifest = ModuleManifest(
permissions=(READ_SCOPE,), 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=( provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_PAYMENT_REQUESTS, version="1.0.0"), ModuleInterfaceProvider(name=CAPABILITY_PAYMENT_REQUESTS, version="1.0.0"),
), ),
@@ -167,7 +234,8 @@ manifest = ModuleManifest(
body=( body=(
"Payments owns the tenant-bound payment ID, human payment reference, requested amount and currency, lifecycle events, and reconciliation evidence. " "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. " "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." "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", layer="configured",
@@ -183,6 +251,8 @@ manifest = ModuleManifest(
metadata={ metadata={
"help_contexts": [ "help_contexts": [
"payments.request", "payments.request",
"payments.workspace",
"payments.request.create",
"payments.reconciliation.manual", "payments.reconciliation.manual",
"payments.state.requested", "payments.state.requested",
"payments.state.paid", "payments.state.paid",
@@ -206,7 +276,7 @@ manifest = ModuleManifest(
test_ref="tests/test_payments.py", test_ref="tests/test_payments.py",
known_limits=( known_limits=(
"Only full manual payment reconciliation is implemented; partial payments, refunds, reversals, and corrections need explicit governed flows.", "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",), supported_authority_modes=("native_authoritative",),
owned_concepts=( owned_concepts=(
+14
View File
@@ -21,6 +21,7 @@ from govoplan_payments.backend.service import (
PaymentError, PaymentError,
SqlPaymentRequestProvider, SqlPaymentRequestProvider,
) )
from govoplan_payments.backend.manifest import manifest
NOW = datetime(2026, 8, 19, 10, 0, tzinfo=UTC) NOW = datetime(2026, 8, 19, 10, 0, tzinfo=UTC)
@@ -199,6 +200,19 @@ class PaymentTests(unittest.TestCase):
self.assertEqual(1, len(requested)) self.assertEqual(1, len(requested))
self.assertEqual((), self.provider.list_payments(self.session, tenant_id="tenant-2")) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+31
View File
@@ -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
}
}
}
+33
View File
@@ -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, /<WorkspaceFrame/, "Payments uses the central full-height module frame");
assert.match(page, /<PageLayout/, "Payments uses the central headed page frame");
assert.match(page, /<PageActionBar[\s\S]*variant="collection"/, "Payments declares the collection action archetype");
assert.match(page, /reloadAction=/, "Payments provides the required reload slot");
assert.match(page, /createAction=/, "Payments provides the far-right create slot");
assert.match(page, /<MetricGrid/, "Payments summary geometry is centralized");
assert.match(page, /<FilterBar/, "Payments filters use the central bar");
assert.match(page, /<DataGrid/, "Payments rows use the central data grid");
assert.match(page, /<TableActionGroup/, "Payments keeps one stable ordered row action set");
assert.match(page, /disabledReason=/, "permission and state blockers remain actionable");
assert.match(page, /stale/, "refresh failures retain explicit stale-data state");
assert.match(createDialog, /<Dialog[\s\S]*<DialogForm/, "request creation composes central dialog anatomy");
assert.match(createDialog, /useUnsavedDraftGuard/, "request drafts use the shared discard guard");
assert.match(createDialog, /idempotency_key/, "request creation exposes replay protection");
assert.match(reconcileDialog, /<DescriptionList/, "reconciliation presents exact immutable facts semantically");
assert.match(reconcileDialog, /version[\s\S]*checksum/, "reconciliation captures immutable evidence binding");
assert.match(reconcileDialog, /useUnsavedDraftGuard/, "reconciliation drafts use the shared discard guard");
assert.doesNotMatch(styles, /\.page-heading|\.action-toolbar|\.dialog-panel|\.data-grid/, "Payments does not redefine shared page, toolbar, dialog, or table anatomy");
assert.doesNotMatch(`${page}\n${createDialog}\n${reconcileDialog}`, /window\.alert|\balert\s*\(/, "Payments does not use global alerts");
console.log("Payments interface-pattern contracts passed.");
+138
View File
@@ -0,0 +1,138 @@
import {
ApiError,
apiFetch,
apiPath,
type ApiSettings
} from "@govoplan/core-webui";
export type PaymentStatus = "requested" | "paid";
export type EvidenceReference = {
kind: string;
owner_module: string;
evidence_id: string;
tenant_id: string;
version?: string | null;
checksum?: string | null;
};
export type PaymentReconciliation = {
reconciliation_id: string;
mode: "manual";
amount_minor: number;
currency: string;
transaction_reference: string;
evidence_ref: EvidenceReference;
received_at: string;
recorded_at: string;
recorded_by_ref: string;
};
export type PaymentEvent = {
event_id: string;
event_type: string;
status: PaymentStatus;
occurred_at: string;
actor_ref: string;
payload: Record<string, unknown>;
};
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<string, string>;
metadata: Record<string, unknown>;
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<string, string>;
metadata: Record<string, unknown>;
};
export type ManualPaymentReconciliationCreate = {
amount_minor: number;
currency: string;
transaction_reference: string;
evidence_ref: EvidenceReference;
idempotency_key: string;
received_at: string;
metadata: Record<string, unknown>;
};
export async function listPaymentRequests(
settings: ApiSettings,
filters: { status?: PaymentStatus; sourceResourceId?: string; limit?: number } = {},
signal?: AbortSignal
): Promise<PaymentRequest[]> {
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<PaymentRequest> {
return apiFetch<PaymentRequest>(settings, "/api/v1/payments/requests", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function reconcileManualPayment(
settings: ApiSettings,
paymentId: string,
payload: ManualPaymentReconciliationCreate
): Promise<PaymentRequest> {
return apiFetch<PaymentRequest>(
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);
}
@@ -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<ReconciliationDraft>(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<K extends keyof ReconciliationDraft>(key: K, value: ReconciliationDraft[K]) {
setDraft((current) => ({ ...current, [key]: value }));
setDirty(true);
}
async function submit(): Promise<boolean> {
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 (
<Dialog
open={open}
title="Record manual payment"
description="Confirm a full offline receipt against immutable evidence. Payments rejects any amount or currency mismatch."
size="wide"
closeDisabled={busy}
onClose={close}
interfaceId="payments.reconciliation.manual.dialog"
helpContextId="payments.reconciliation.manual"
helpModuleId="payments"
notices={error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
footer={(
<>
<Button type="button" onClick={close} disabled={busy}>Cancel</Button>
<Button type="submit" form={FORM_ID} variant="primary" disabled={busy || !payment}>
{busy ? "Recording…" : "Record payment as paid"}
</Button>
</>
)}
>
<DialogForm id={FORM_ID} onSubmit={handleSubmit}>
{payment && (
<DialogSection variant="inset" className="payments-reconciliation-warning">
<h3 className="payments-dialog-section-title">Exact obligation</h3>
<DescriptionList columns={2} density="compact">
<DescriptionItem term="Payment reference">{payment.payment_reference}</DescriptionItem>
<DescriptionItem term="Amount"><span className="payments-readonly-amount">{formatAmount(payment.amount_minor, payment.currency)}</span></DescriptionItem>
<DescriptionItem term="Source">{payment.source.module}:{payment.source.resource_type}:{payment.source.resource_id}</DescriptionItem>
<DescriptionItem term="Current state">Requested</DescriptionItem>
</DescriptionList>
<p className="payments-dialog-copy">This action appends reconciliation evidence and marks the obligation paid. It cannot be silently undone; correction or reversal requires a future governed adjustment flow.</p>
</DialogSection>
)}
<DialogSection variant="separated">
<h3 className="payments-dialog-section-title">Receipt</h3>
<FormGrid columns={2}>
<FormField label="External transaction reference" helpContextId="payments.reconciliation.field.transaction-reference" helpModuleId="payments">
<input required maxLength={255} value={draft.transactionReference} onChange={(event) => change("transactionReference", event.target.value)} />
</FormField>
<FormField label="Payment received date and time">
<DateTimeField required value={draft.receivedAt} onChange={(value) => change("receivedAt", value)} aria-label="Payment received date and time" />
</FormField>
</FormGrid>
</DialogSection>
<DialogSection variant="separated">
<h3 className="payments-dialog-section-title">Immutable evidence</h3>
<p className="payments-dialog-section-copy">Payments stores only this typed reference. The evidence bytes and retention remain with the owning module.</p>
<FormGrid columns={2}>
<FormField label="Evidence owner module">
<input required maxLength={120} value={draft.evidenceOwnerModule} onChange={(event) => change("evidenceOwnerModule", event.target.value)} />
</FormField>
<FormField label="Evidence kind">
<input required maxLength={120} value={draft.evidenceKind} onChange={(event) => change("evidenceKind", event.target.value)} />
</FormField>
<FormField label="Evidence ID">
<input required maxLength={255} value={draft.evidenceId} onChange={(event) => change("evidenceId", event.target.value)} />
</FormField>
<FormField label="Evidence version" help="Provide a version or checksum; both may be supplied.">
<input maxLength={255} value={draft.evidenceVersion} onChange={(event) => change("evidenceVersion", event.target.value)} />
</FormField>
<FormField label="Evidence checksum" help="Provide a checksum or version; both may be supplied.">
<input maxLength={255} value={draft.evidenceChecksum} onChange={(event) => change("evidenceChecksum", event.target.value)} />
</FormField>
</FormGrid>
</DialogSection>
<DialogSection variant="inset">
<h3 className="payments-dialog-section-title">Replay protection</h3>
<p className="payments-dialog-section-copy">Retry this key only for this exact payment and evidence. A changed replay conflicts instead of creating ambiguous settlement evidence.</p>
<FormField label="Idempotency key" helpContextId="payments.reconciliation.field.replay-key" helpModuleId="payments">
<input required maxLength={255} value={draft.idempotencyKey} onChange={(event) => change("idempotencyKey", event.target.value)} />
</FormField>
</DialogSection>
</DialogForm>
</Dialog>
);
}
@@ -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<PaymentRequestDraft>(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<K extends keyof PaymentRequestDraft>(key: K, value: PaymentRequestDraft[K]) {
setDraft((current) => ({ ...current, [key]: value }));
setDirty(true);
}
async function submit(): Promise<boolean> {
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 (
<Dialog
open={open}
title="Create payment request"
description="Create one fixed, source-bound obligation. The returned payment reference remains stable for the owning procedure."
size="wide"
closeDisabled={busy}
onClose={close}
interfaceId="payments.request.create.dialog"
helpContextId="payments.request.create"
helpModuleId="payments"
notices={error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
footer={(
<>
<Button type="button" onClick={close} disabled={busy}>Cancel</Button>
<Button type="submit" form={FORM_ID} variant="primary" disabled={busy}>
{busy ? "Creating…" : "Create request"}
</Button>
</>
)}
>
<DialogForm id={FORM_ID} onSubmit={handleSubmit}>
<DialogSection>
<h3 className="payments-dialog-section-title">Owning source</h3>
<p className="payments-dialog-section-copy">Use the stable reference of the Case, Workflow, or other procedure that owns this obligation.</p>
<FormGrid columns={2}>
<FormField label="Source module" helpContextId="payments.request.field.source-module" helpModuleId="payments">
<input required maxLength={120} value={draft.sourceModule} onChange={(event) => change("sourceModule", event.target.value)} />
</FormField>
<FormField label="Resource type" helpContextId="payments.request.field.resource-type" helpModuleId="payments">
<input required maxLength={120} value={draft.sourceResourceType} onChange={(event) => change("sourceResourceType", event.target.value)} />
</FormField>
<FormField label="Source resource ID" helpContextId="payments.request.field.source-id" helpModuleId="payments">
<input required maxLength={255} value={draft.sourceResourceId} onChange={(event) => change("sourceResourceId", event.target.value)} />
</FormField>
<FormField label="Payment subject" helpContextId="payments.request.field.subject" helpModuleId="payments">
<input required maxLength={1000} value={draft.subject} onChange={(event) => change("subject", event.target.value)} />
</FormField>
</FormGrid>
</DialogSection>
<DialogSection variant="separated">
<h3 className="payments-dialog-section-title">Obligation</h3>
<FormGrid columns={2}>
<FormField label="Amount" help="Enter the major currency amount, for example 30.00.">
<input required inputMode="decimal" placeholder="0.00" value={draft.amount} onChange={(event) => change("amount", event.target.value)} />
</FormField>
<FormField label="Currency" help="Three-letter ISO currency code.">
<input required maxLength={3} value={draft.currency} onChange={(event) => change("currency", event.target.value.toUpperCase())} />
</FormField>
<FormField label="Due date and time" help="Optional. The local time is converted to an absolute timestamp.">
<DateTimeField value={draft.dueAt} onChange={(value) => change("dueAt", value)} aria-label="Payment due date and time" />
</FormField>
</FormGrid>
</DialogSection>
<DialogSection variant="separated">
<h3 className="payments-dialog-section-title">Procedure context</h3>
<p className="payments-dialog-section-copy">Optional references make the source visible without copying applicant or form data into Payments.</p>
<FormGrid columns={2}>
<FormField label="Case reference">
<input maxLength={255} value={draft.caseRef} onChange={(event) => change("caseRef", event.target.value)} />
</FormField>
<FormField label="Workflow reference">
<input maxLength={255} value={draft.workflowRef} onChange={(event) => change("workflowRef", event.target.value)} />
</FormField>
</FormGrid>
</DialogSection>
<DialogSection variant="inset">
<h3 className="payments-dialog-section-title">Replay protection</h3>
<p className="payments-dialog-section-copy">Retry with this key only for the same source, amount, currency, subject, dates, and context. Reusing it for changed values is rejected.</p>
<FormField label="Idempotency key" helpContextId="payments.request.field.replay-key" helpModuleId="payments">
<input required maxLength={255} value={draft.idempotencyKey} onChange={(event) => change("idempotencyKey", event.target.value)} />
</FormField>
</DialogSection>
</DialogForm>
</Dialog>
);
}
@@ -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<PaymentRequest[]>([]);
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<Date | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [reconcilingPayment, setReconcilingPayment] = useState<PaymentRequest | null>(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<DataGridColumn<PaymentRequest>[]>(() => [
{
id: "reference",
header: "Payment",
width: "1.2fr",
minWidth: 220,
sortable: true,
filterable: true,
value: (payment) => `${payment.payment_reference} ${payment.subject}`,
render: (payment) => <div className="payments-source"><strong>{payment.subject}</strong><span>{payment.payment_reference}</span></div>
},
{
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) => (
<div className="payments-source">
<strong>{payment.source.module}:{payment.source.resource_type}</strong>
<span>{payment.source.resource_id}</span>
{(payment.context_refs.case || payment.context_refs.workflow) && <span>{[payment.context_refs.case && `Case ${payment.context_refs.case}`, payment.context_refs.workflow && `Workflow ${payment.context_refs.workflow}`].filter(Boolean).join(" · ")}</span>}
</div>
)
},
{
id: "amount",
header: "Amount",
width: 130,
minWidth: 120,
align: "right",
sortable: true,
sortValue: (payment) => payment.amount_minor,
render: (payment) => <strong className="payments-amount">{formatAmount(payment.amount_minor, payment.currency)}</strong>
},
{
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) => <StatusBadge status={payment.status === "paid" ? "active" : "pending"} label={payment.status === "paid" ? "Paid" : "Requested"} />
},
{
id: "dates",
header: "Due / settled",
width: 190,
minWidth: 170,
sortable: true,
sortValue: (payment) => payment.settled_at ?? payment.due_at ?? payment.requested_at,
render: (payment) => <div className="payments-dates"><strong>{payment.status === "paid" ? `Settled ${formatDateTime(payment.settled_at)}` : `Due ${formatDateTime(payment.due_at)}`}</strong><span>Requested {formatDateTime(payment.requested_at)}</span></div>
},
{
id: "evidence",
header: "Reconciliation evidence",
width: "1fr",
minWidth: 210,
filterable: true,
value: reconciliationEvidence,
render: (payment) => (
<div className="payments-evidence">
<strong>{payment.reconciliation?.transaction_reference ?? "Not reconciled"}</strong>
<span>{reconciliationEvidence(payment)}</span>
</div>
)
},
{
id: "actions",
header: "Actions",
width: 88,
minWidth: 88,
sticky: "end",
align: "right",
resizable: false,
render: (payment) => (
<TableActionGroup
label={`Actions for ${payment.payment_reference}`}
actions={[
{
id: "reconcile",
label: "Record manual payment",
icon: <CheckCircle2 size={16} />,
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 = (
<Button
variant="primary"
onClick={() => setCreateOpen(true)}
disabled={!canCreate}
disabledReason={!canCreate ? "The payments:payment:write permission is required. Ask a Payments administrator to grant a payment operator role." : undefined}
interfaceId="payments.request.create"
helpContextId="payments.request.create"
helpModuleId="payments"
>
<Plus size={16} aria-hidden="true" /> Create request
</Button>
);
return (
<WorkspaceFrame as="main" height="viewport" surface="plain" label="Payments workspace" interfaceId="payments.workspace" helpContextId="payments.workspace" helpModuleId="payments">
<PageLayout
mode="standalone"
title="Payment requests"
description="Track source-bound obligations and record exact manual receipts against immutable evidence."
loading={loading}
loadingLabel="Loading payment requests"
success={success}
interfaceId="payments.workspace.page"
helpContextId="payments.workspace"
helpModuleId="payments"
actions={(
<PageActionBar
variant="collection"
label="Payment request actions"
interfaceId="payments.workspace.actions"
helpContextId="payments.workspace"
helpModuleId="payments"
reloadAction={<IconButton label="Reload payment requests" icon={<RefreshCw size={17} />} variant="ghost" onClick={() => void reload()} disabled={refreshing} />}
helpAction={<DocumentationHelpLink reference={{ topicId: "payments.requests-and-reconciliation", documentationType: "user" }} label="Open Payments documentation" />}
createAction={createButton}
/>
)}
notices={staleError ? (
<DismissibleAlert tone="warning" resetKey={staleError}>
<div>The loaded payment list may be stale because refresh failed: {staleError}</div>
<div className="payments-notice-action"><Button type="button" onClick={() => void reload()}>Retry reload</Button></div>
</DismissibleAlert>
) : null}
>
{!canRead ? (
<StatePanel
size="fill"
tone="warning"
title="Payment access is unavailable"
description="The payments:payment:read permission is required. Ask a Payments administrator to grant a payment reader, operator, or auditor role."
/>
) : initialError ? (
<StatePanel
size="fill"
tone="danger"
title="Payment requests could not be loaded"
description={initialError}
actions={<Button type="button" onClick={() => void reload()}>Retry</Button>}
/>
) : (
<>
<MetricGrid columns={4} density="compact" spacing="none" minimum="compact" collapseAt="standard">
<MetricCard density="compact" label="All requests" value={payments.length} detail={loadedAt ? `Updated ${loadedAt.toLocaleTimeString()}` : "Not loaded"} />
<MetricCard density="compact" tone="warning" label="Requested" value={requestedCount} detail="Awaiting receipt" />
<MetricCard density="compact" tone="good" label="Paid" value={paidCount} detail="Evidence recorded" />
<MetricCard density="compact" tone={overdueCount ? "danger" : "neutral"} label="Overdue" value={overdueCount} detail="Requested past due time" />
</MetricGrid>
<FilterBar surface="panel" className="payments-filter-bar">
<select aria-label="Filter payment state" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as "all" | PaymentStatus)}>
<option value="all">All states</option>
<option value="requested">Requested</option>
<option value="paid">Paid</option>
</select>
<input aria-label="Filter by source or payment reference" placeholder="Source, Case, Workflow, or payment reference" value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value)} />
{(statusFilter !== "all" || sourceFilter) && <Button type="button" variant="ghost" onClick={() => { setStatusFilter("all"); setSourceFilter(""); }}>Clear filters</Button>}
</FilterBar>
<Card title={`${filteredPayments.length} payment request${filteredPayments.length === 1 ? "" : "s"}`} interfaceId="payments.requests.list" helpContextId="payments.workspace.list" helpModuleId="payments">
<DataGrid
id="payments.requests"
storageKey="govoplan.payments.requests.grid"
rows={filteredPayments}
columns={columns}
getRowKey={(payment) => 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"
/>
</Card>
</>
)}
</PageLayout>
<PaymentRequestDialog open={createOpen} settings={settings} onClose={() => setCreateOpen(false)} onCreated={handleCreated} />
<ManualReconciliationDialog
open={Boolean(reconcilingPayment)}
settings={settings}
tenantId={tenantId}
payment={reconcilingPayment}
onClose={() => setReconcilingPayment(null)}
onReconciled={handleReconciled}
/>
</WorkspaceFrame>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default, paymentsModule } from "./module";
export * from "./api/payments";
+50
View File
@@ -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;
+19
View File
@@ -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; }
}
+29
View File
@@ -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"]
}