diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index 5116f484..fc29ab7c 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -79,8 +79,10 @@ import InvoiceTimeline from "@/components/InvoiceTimeline"; import HistoryLog from "@/components/invoice/HistoryLog"; import AdvancedStatusTimeline from "@/components/invoice/AdvancedStatusTimeline"; import InvoiceSentiment from "@/components/invoice/InvoiceSentiment"; +import FraudRiskPanel from "@/components/invoice/FraudRiskPanel"; import PayerWalletHealth from "@/components/invoice/PayerWalletHealth"; import ReconciliationPanel from "@/components/invoice/ReconciliationPanel"; +import { detectAnomalies } from "@/lib/anomalyDetector"; import InvoiceExportButton from "@/components/InvoiceExportButton"; // Only rendered once an invoice transitions to "released", and pulls in the // canvas-confetti animation library — load it on demand instead of in the @@ -503,6 +505,16 @@ export default function InvoiceDetailPage({ params }: Props) { : false; const canDownloadPDF = isCreator || isContributor; const canAct = isCreator || isRecipient; + const invoicePayerHistory = new Map( + invoice.payments.map((payment) => [payment.payer, 1] as const), + ); + const anomalyFlags = invoice.payments + .flatMap((payment) => detectAnomalies(payment, invoice, invoicePayerHistory)) + .filter((flag, index, flags) => + flags.findIndex((candidate) => + candidate.type === flag.type && candidate.payer === flag.payer, + ) === index, + ); const recipientShare = publicKey ? invoice.recipients.find((recipient) => recipient.address === publicKey) : undefined; @@ -1140,6 +1152,10 @@ export default function InvoiceDetailPage({ params }: Props) { + diff --git a/src/app/invoice/compare/page.tsx b/src/app/invoice/compare/page.tsx index df42ab9b..0620566f 100644 --- a/src/app/invoice/compare/page.tsx +++ b/src/app/invoice/compare/page.tsx @@ -12,13 +12,42 @@ function CompareInvoicesContent() { const router = useRouter(); const [invoiceA, setInvoiceA] = useState(null); const [invoiceB, setInvoiceB] = useState(null); + const [batchInvoices, setBatchInvoices] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const idA = searchParams.get("a"); const idB = searchParams.get("b"); + const batchIds = searchParams.get("ids"); useEffect(() => { + setError(null); + if (batchIds !== null) { + const ids = [...new Set(batchIds.split(",").map((id) => id.trim()).filter(Boolean))]; + if (ids.length < 2 || ids.length > 10) { + setError("Batch comparison requires between 2 and 10 distinct invoice IDs."); + setLoading(false); + return; + } + const fetchBatch = async () => { + try { + setLoading(true); + setError(null); + const invoices = await Promise.all(ids.map((id) => splitClient.getInvoice(id))); + setBatchInvoices(invoices); + setInvoiceA(invoices[0]); + setInvoiceB(invoices[1]); + } catch (e) { + setError(`Failed to load batch invoices: ${String(e)}`); + } finally { + setLoading(false); + } + }; + fetchBatch(); + return; + } + + setBatchInvoices([]); if (!idA || !idB) { setError("Missing invoice IDs. Use /compare?a=[id]&b=[id]"); setLoading(false); @@ -42,7 +71,7 @@ function CompareInvoicesContent() { }; fetchInvoices(); - }, [idA, idB]); + }, [idA, idB, batchIds]); if (loading) { return ( @@ -76,18 +105,26 @@ function CompareInvoicesContent() { router.push(`/invoice/${invoiceA!.id}`); }; - const handlePayB = () => { - router.push(`/invoice/${invoiceB!.id}`); - }; + const comparisons = batchInvoices.length > 2 + ? batchInvoices.slice(1).map((invoice) => [batchInvoices[0], invoice] as const) + : [[invoiceA, invoiceB] as const]; return (
- + {batchInvoices.length > 2 && ( +

+ Batch comparison: invoice #{batchInvoices[0].id} is compared with {batchInvoices.length - 1} other invoices. +

+ )} + {comparisons.map(([baseline, invoice]) => ( + router.push(`/invoice/${invoice.id}`)} + /> + ))}
); } diff --git a/src/components/BulkActionBar.tsx b/src/components/BulkActionBar.tsx index 77313932..dd715144 100644 --- a/src/components/BulkActionBar.tsx +++ b/src/components/BulkActionBar.tsx @@ -10,7 +10,10 @@ import { } from "@/lib/archiveInvoices"; import { exportInvoicesBulk, + getBulkInvoiceAudit, printInvoicesBulk, + recordBulkInvoiceOperation, + type BulkInvoiceOperation, type BulkExportFormat, } from "@/lib/bulkInvoiceOps"; @@ -54,6 +57,10 @@ export default function BulkActionBar({ } | null>(null); const [showUndoToast, setShowUndoToast] = useState(false); const [showExportMenu, setShowExportMenu] = useState(false); + const [pendingOperation, setPendingOperation] = useState(null); + const [operationError, setOperationError] = useState(null); + const [auditEntries, setAuditEntries] = useState>([]); + const [showAudit, setShowAudit] = useState(false); const pendingCount = selectedInvoices.filter( (inv) => inv.status === "Pending", @@ -79,9 +86,23 @@ export default function BulkActionBar({ setShowUndoToast(true); setTimeout(() => setShowUndoToast(false), getUndoTimeout()); onArchiveChange?.(); + setPendingOperation(null); + setOperationError(null); + } catch (error) { + setOperationError(`Could not archive invoices: ${String(error)}`); + return; } finally { setArchiveLoading(false); } + try { + recordBulkInvoiceOperation({ + operation: "archive", + invoiceIds: idsToArchive, + timestamp: Date.now(), + }); + } catch (error) { + setOperationError(`Invoices were archived, but the audit entry could not be saved: ${String(error)}`); + } }; const handleUnarchive = async () => { @@ -98,9 +119,23 @@ export default function BulkActionBar({ setShowUndoToast(true); setTimeout(() => setShowUndoToast(false), getUndoTimeout()); onArchiveChange?.(); + setPendingOperation(null); + setOperationError(null); + } catch (error) { + setOperationError(`Could not unarchive invoices: ${String(error)}`); + return; } finally { setArchiveLoading(false); } + try { + recordBulkInvoiceOperation({ + operation: "unarchive", + invoiceIds: idsToUnarchive, + timestamp: Date.now(), + }); + } catch (error) { + setOperationError(`Invoices were unarchived, but the audit entry could not be saved: ${String(error)}`); + } }; const handleUndo = () => { @@ -112,6 +147,62 @@ export default function BulkActionBar({ } setShowUndoToast(false); onArchiveChange?.(); + try { + recordBulkInvoiceOperation({ + operation: "undo", + invoiceIds: undoAction.ids, + timestamp: Date.now(), + details: undoAction.isArchive ? "Undo archive" : "Undo unarchive", + }); + setOperationError(null); + } catch (error) { + setOperationError(`Undo completed, but the audit entry could not be saved: ${String(error)}`); + } + }; + + const confirmOperation = async () => { + if (!pendingOperation) return; + if (pendingOperation === "archive") { + await handleArchive(); + } else if (pendingOperation === "unarchive") { + await handleUnarchive(); + } else { + setArchiveLoading(true); + try { + await onBulkCancel(); + setPendingOperation(null); + setOperationError(null); + try { + recordBulkInvoiceOperation({ + operation: "cancel", + invoiceIds: selectedInvoices + .filter((invoice) => invoice.status === "Pending") + .map((invoice) => invoice.id), + timestamp: Date.now(), + }); + } catch (error) { + setOperationError(`Invoices were cancelled, but the audit entry could not be saved: ${String(error)}`); + } + } catch (error) { + setOperationError(`Could not cancel invoices: ${String(error)}`); + } finally { + setArchiveLoading(false); + } + } + }; + + const toggleAudit = () => { + if (showAudit) { + setShowAudit(false); + return; + } + try { + setAuditEntries(getBulkInvoiceAudit()); + setOperationError(null); + setShowAudit(true); + } catch (error) { + setOperationError(`Could not load the local operation audit: ${String(error)}`); + } }; // ── Export ───────────────────────────────────────────────────────────────── @@ -166,8 +257,12 @@ export default function BulkActionBar({ {pendingCount > 0 && ( @@ -177,7 +272,10 @@ export default function BulkActionBar({ {!archivedOnly && archivedCount < selectedCount && ( + + + + + )} + {/* Undo toast */} {showUndoToast && undoAction && (
diff --git a/src/components/CompareInvoicesView.tsx b/src/components/CompareInvoicesView.tsx index 1d59f6be..301dbb16 100644 --- a/src/components/CompareInvoicesView.tsx +++ b/src/components/CompareInvoicesView.tsx @@ -15,6 +15,7 @@ interface Props { } type DiffMode = "all" | "diffs-only"; +type ComparisonFilter = "all" | "financial" | "status" | "participants"; /** Returns a highlight wrapper class when two values differ */ function diffClass(a: string | number, b: string | number): string { @@ -48,6 +49,7 @@ function formatDelta(a: bigint, b: bigint): { label: string; positive: boolean; */ export default function CompareInvoicesView({ invoiceA, invoiceB, onPayA, onPayB }: Props) { const [diffMode, setDiffMode] = useState("all"); + const [comparisonFilter, setComparisonFilter] = useState("all"); const [activeSection, setActiveSection] = useState<"overview" | "details">("overview"); const totalA = invoiceA.recipients.reduce((s, r) => s + r.amount, 0n); @@ -167,10 +169,16 @@ export default function CompareInvoicesView({ invoiceA, invoiceB, onPayA, onPayB }, ]; - const visibleRows = - diffMode === "diffs-only" - ? rows.filter((r) => String(r.rawA) !== String(r.rawB)) - : rows; + const visibleRows = rows.filter((row) => { + const isDifferent = String(row.rawA) !== String(row.rawB); + const matchesDiffMode = diffMode === "all" || isDifferent; + const matchesCategory = + comparisonFilter === "all" || + (comparisonFilter === "financial" && ["Total Amount", "Funded", "Progress", "Token"].includes(row.label)) || + (comparisonFilter === "status" && ["Status", "Deadline"].includes(row.label)) || + (comparisonFilter === "participants" && ["Recipients", "Payments"].includes(row.label)); + return matchesDiffMode && matchesCategory; + }); const diffCount = rows.filter((r) => String(r.rawA) !== String(r.rawB)).length; @@ -178,6 +186,43 @@ export default function CompareInvoicesView({ invoiceA, invoiceB, onPayA, onPayB const totalDelta = formatDelta(totalA, totalB); const fundedDelta = formatDelta(invoiceA.funded, invoiceB.funded); + const exportComparison = (format: "csv" | "json") => { + const rowsToExport = visibleRows.map((row) => ({ + field: row.label, + invoiceA: row.rawA, + invoiceB: row.rawB, + differs: String(row.rawA) !== String(row.rawB), + })); + const content = + format === "json" + ? JSON.stringify( + { + invoiceIds: [String(invoiceA.id), String(invoiceB.id)], + filters: { diffMode, category: comparisonFilter }, + differences: diffCount, + rows: rowsToExport, + }, + null, + 2, + ) + : [ + "Field,Invoice A,Invoice B,Different", + ...rowsToExport.map((row) => + [row.field, row.invoiceA, row.invoiceB, row.differs] + .map((value) => `"${String(value).replace(/"/g, '""')}"`) + .join(","), + ), + ].join("\n"); + const mimeType = format === "json" ? "application/json" : "text/csv"; + const blob = new Blob([content], { type: `${mimeType};charset=utf-8` }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `invoice-comparison-${invoiceA.id}-vs-${invoiceB.id}.${format}`; + anchor.click(); + URL.revokeObjectURL(url); + }; + return (
Highlighted rows differ
-
+
+ + + + )} @@ -781,7 +781,7 @@ export default function DashboardClient() { {/* Bulk reminder results */} {compareMode && (

- Select up to 2 invoices to compare side-by-side. + Select 2–10 invoices to compare against a baseline.

)} diff --git a/src/components/invoice/FraudRiskPanel.tsx b/src/components/invoice/FraudRiskPanel.tsx index 1bfd76a3..4560b6e0 100644 --- a/src/components/invoice/FraudRiskPanel.tsx +++ b/src/components/invoice/FraudRiskPanel.tsx @@ -20,7 +20,7 @@ interface Props { * FraudRiskPanel — displays fraud risk assessment for an invoice * * Shows a score gauge, tier classification, and expandable signal breakdown table. - * High-risk invoices (score > 70) can trigger a confirmation requirement. + * Scores are heuristic review signals and do not block invoice actions. */ export default function FraudRiskPanel({ invoice, @@ -46,7 +46,7 @@ export default function FraudRiskPanel({ {/* Header with score gauge */}

- Fraud Risk Assessment + Heuristic Risk Assessment

{/* Score gauge */} @@ -99,7 +99,7 @@ export default function FraudRiskPanel({

{isHighRisk && (

- ⚠️ Requires confirmation + ⚠️ Review recommended

)}
@@ -108,14 +108,17 @@ export default function FraudRiskPanel({ {/* High-risk warning banner */} {isHighRisk && ( -
+

- ⚠️ High fraud risk detected. Please review the signals below before - proceeding with payment. + ⚠️ Elevated heuristic risk signals detected. Review the breakdown; this score is not proof of fraud and may include false positives.

)} +

+ Heuristic estimate only; this is not a trained machine-learning model. +

+ {/* Signal breakdown */}