250 lines
9.5 KiB
TypeScript
250 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|