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)} />
); }