Add guided Payments operator workspace
Module Package Release / publish-packages (push) Successful in 14s

This commit is contained in:
2026-08-19 13:17:13 +02:00
parent 630a7d39f7
commit a2dcd8f2dd
16 changed files with 1268 additions and 5 deletions
@@ -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>
);
}