Add guided Payments operator workspace
Module Package Release / publish-packages (push) Successful in 14s
Module Package Release / publish-packages (push) Successful in 14s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user