Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/app/invoice/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -503,6 +505,16 @@ export default function InvoiceDetailPage({ params }: Props) {
: false;
const canDownloadPDF = isCreator || isContributor;
const canAct = isCreator || isRecipient;
const invoicePayerHistory = new Map<string, number>(
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;
Expand Down Expand Up @@ -1140,6 +1152,10 @@ export default function InvoiceDetailPage({ params }: Props) {
<InvoiceSentiment invoice={invoice} />
<ReconciliationPanel invoice={invoice} />
</div>
<FraudRiskPanel
invoice={invoice}
anomalyFlags={anomalyFlags}
/>
<PayerWalletHealth payments={invoice.payments} />
</div>

Expand Down
57 changes: 47 additions & 10 deletions src/app/invoice/compare/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,42 @@ function CompareInvoicesContent() {
const router = useRouter();
const [invoiceA, setInvoiceA] = useState<Invoice | null>(null);
const [invoiceB, setInvoiceB] = useState<Invoice | null>(null);
const [batchInvoices, setBatchInvoices] = useState<Invoice[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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);
Expand All @@ -42,7 +71,7 @@ function CompareInvoicesContent() {
};

fetchInvoices();
}, [idA, idB]);
}, [idA, idB, batchIds]);

if (loading) {
return (
Expand Down Expand Up @@ -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 (
<main className="w-full">
<CompareInvoicesView
invoiceA={invoiceA}
invoiceB={invoiceB}
onPayA={handlePayA}
onPayB={handlePayB}
/>
{batchInvoices.length > 2 && (
<p className="mx-auto max-w-7xl px-4 pt-6 text-sm text-gray-500 dark:text-gray-400">
Batch comparison: invoice #{batchInvoices[0].id} is compared with {batchInvoices.length - 1} other invoices.
</p>
)}
{comparisons.map(([baseline, invoice]) => (
<CompareInvoicesView
key={`${baseline.id}-${invoice.id}`}
invoiceA={baseline}
invoiceB={invoice}
onPayA={handlePayA}
onPayB={() => router.push(`/invoice/${invoice.id}`)}
/>
))}
</main>
);
}
Expand Down
Loading