From f1153b0f4193399897a149d61007eee1f9a908bf Mon Sep 17 00:00:00 2001 From: ice-man123 Date: Sun, 27 Sep 2026 15:04:27 +0000 Subject: [PATCH] feat: bulk ops, advanced sort, messaging, duplicate detection (#810 #811 #812 #813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #810 — Bulk invoice operations (export, print, archive) - Add src/lib/bulkInvoiceOps.ts with exportInvoicesBulk (CSV + JSON), printInvoicesBulk (opens print-ready window), archiveInvoicesBulk - Extend BulkActionBar with a Print button and an Export dropdown that lets the user choose CSV or JSON format #811 — Advanced sort options for invoice lists - Extend DashboardSortId and SORT_OPTIONS in dashboardFilters.ts with 7 new sort keys: deadline-farthest, funded-pct-desc/asc, recipients-desc/asc, creator-asc/desc - Export SORT_GROUPS for grouped UI rendering - Add AdvancedSortSelect component (grouped select) - Wire AdvancedSortSelect into DashboardClient in place of plain setInput(e.target.value)} + placeholder="Type a message…" + maxLength={500} + aria-label="Message input" + className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + + + ) : ( +

+ {currentAddress + ? "Only the creator and recipients may send messages." + : "Connect your wallet to send messages."} +

+ )} + + )} + + ); +} diff --git a/src/components/DashboardClient.tsx b/src/components/DashboardClient.tsx index 66d0967b..4ee18559 100644 --- a/src/components/DashboardClient.tsx +++ b/src/components/DashboardClient.tsx @@ -39,6 +39,7 @@ import { useInvoiceTags } from "@/hooks/useInvoiceTags"; import { invoiceHasTag } from "@/lib/invoiceTags"; import InvoiceTable from "@/components/InvoiceTable"; import FilterPresetDropdown from "@/components/invoices/FilterPresetDropdown"; +import AdvancedSortSelect from "@/components/invoices/AdvancedSortSelect"; import { apiFetch } from "@/lib/apiClient"; // ── URL helpers ────────────────────────────────────────────────────────────── @@ -557,18 +558,11 @@ export default function DashboardClient() { > Sort by - + onChange={(s) => pushParams({ sort: s })} + /> {/* Saved presets */} diff --git a/src/components/DuplicateMergeModal.tsx b/src/components/DuplicateMergeModal.tsx new file mode 100644 index 00000000..40fac47b --- /dev/null +++ b/src/components/DuplicateMergeModal.tsx @@ -0,0 +1,312 @@ +"use client"; + +import { useState } from "react"; +import type { Invoice } from "@stellar-split/sdk"; +import { formatAmount } from "@stellar-split/sdk"; +import { + findAllDuplicates, + mergeInvoiceGroup, + undoMerge, + describeDifferences, + type DuplicateGroup, + type MergeStrategy, +} from "@/lib/invoiceDuplicates"; + +// ── Helper ──────────────────────────────────────────────────────────────────── + +function truncateAddr(addr: string): string { + if (addr.length <= 14) return addr; + return `${addr.slice(0, 8)}…${addr.slice(-6)}`; +} + +function totalAmount(inv: Invoice): bigint { + return inv.recipients.reduce((s, r) => s + r.amount, 0n); +} + +function formatDeadline(deadline: number): string { + if (!deadline) return "No deadline"; + return new Date(deadline * 1000).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +interface GroupCardProps { + group: DuplicateGroup; + onMerge: (group: DuplicateGroup, strategy: MergeStrategy) => void; +} + +function DuplicateGroupCard({ group, onMerge }: GroupCardProps) { + const [strategy, setStrategy] = useState("keep-newest"); + const [expanded, setExpanded] = useState(false); + + const diffs = + group.invoices.length === 2 + ? describeDifferences(group.invoices[0], group.invoices[1]) + : []; + + return ( +
+ {/* Header */} + + + {expanded && ( +
+ {/* Reason */} +

{group.reason}

+ + {/* Differences */} + {diffs.length > 0 && ( +
    + {diffs.map((diff, i) => ( +
  • + + {diff} +
  • + ))} +
+ )} + + {/* Invoice list */} +
+ {group.invoices.map((inv) => ( +
+
+ + #{String(inv.id)} + + + {inv.status} + +
+
+ {formatAmount(totalAmount(inv))} USDC + {formatDeadline(inv.deadline)} + + {inv.recipients.length} recipient{inv.recipients.length !== 1 ? "s" : ""} + +
+
+ ))} +
+ + {/* Merge controls */} +
+ + + +
+
+ )} +
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +interface Props { + invoices: Invoice[]; + onClose: () => void; + /** Called after a merge so the parent can refresh its invoice list. */ + onMerged?: (result: { primaryId: string; mergedIds: string[] }) => void; +} + +/** + * DuplicateMergeModal — detects duplicate invoices and lets the user merge + * them by nominating a primary invoice and hiding the rest. + * + * Issue #813: Implement invoice duplicate detection and merge. + */ +export default function DuplicateMergeModal({ + invoices, + onClose, + onMerged, +}: Props) { + const [groups] = useState(() => findAllDuplicates(invoices)); + const [mergeResults, setMergeResults] = useState< + Array<{ primaryId: string; mergedIds: string[]; totalAmount: string }> + >([]); + const [lastUndo, setLastUndo] = useState(null); + + const handleMerge = (group: DuplicateGroup, strategy: MergeStrategy) => { + const result = mergeInvoiceGroup(group, strategy); + setMergeResults((prev) => [result, ...prev]); + setLastUndo(result.mergedIds); + onMerged?.({ primaryId: result.primaryId, mergedIds: result.mergedIds }); + }; + + const handleUndo = () => { + if (!lastUndo) return; + undoMerge(lastUndo); + setMergeResults((prev) => + prev.filter((r) => r.mergedIds.join(",") !== lastUndo!.join(",")), + ); + setLastUndo(null); + }; + + return ( + /* Backdrop */ +
+
+ {/* Header */} +
+
+

+ Duplicate Invoice Detection +

+

+ {groups.length === 0 + ? "No duplicates found in the current list." + : `Found ${groups.length} duplicate group${groups.length !== 1 ? "s" : ""} across ${invoices.length} invoices.`} +

+
+ +
+ + {/* Merge results summary */} + {mergeResults.length > 0 && ( +
+

+ {mergeResults.length} merge{mergeResults.length !== 1 ? "s" : ""} applied +

+ {lastUndo && ( + + )} +
+ )} + + {/* Body */} +
+ {groups.length === 0 ? ( +
+ +

All clear!

+

+ No duplicate invoices were detected. +

+
+ ) : ( + groups.map((group) => ( + + )) + )} +
+ + {/* Footer */} +
+

+ Merged invoices are hidden locally; no on-chain changes are made. +

+ +
+
+
+ ); +} diff --git a/src/components/invoices/AdvancedSortSelect.tsx b/src/components/invoices/AdvancedSortSelect.tsx new file mode 100644 index 00000000..55ee618e --- /dev/null +++ b/src/components/invoices/AdvancedSortSelect.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { + SORT_OPTIONS, + SORT_GROUPS, + type DashboardSortId, +} from "@/lib/dashboardFilters"; + +interface Props { + value: DashboardSortId; + onChange: (sort: DashboardSortId) => void; + className?: string; + id?: string; +} + +/** + * AdvancedSortSelect — a grouped onChange(e.target.value as DashboardSortId)} + className={[ + "min-h-9 rounded-lg bg-gray-100 dark:bg-gray-800", + "border border-gray-300 dark:border-gray-700", + "px-3 py-1.5 text-sm", + "focus:outline-none focus:ring-2 focus:ring-indigo-500", + className, + ] + .filter(Boolean) + .join(" ")} + aria-label="Sort invoices" + > + {SORT_GROUPS.map((group) => { + const options = SORT_OPTIONS.filter((o) => o.group === group); + if (options.length === 0) return null; + return ( + + {options.map((o) => ( + + ))} + + ); + })} + + {/* Ungrouped options as fallback */} + {SORT_OPTIONS.filter((o) => !o.group).map((o) => ( + + ))} + + ); +} diff --git a/src/lib/bulkInvoiceOps.ts b/src/lib/bulkInvoiceOps.ts new file mode 100644 index 00000000..3f135f78 --- /dev/null +++ b/src/lib/bulkInvoiceOps.ts @@ -0,0 +1,260 @@ +/** + * bulkInvoiceOps.ts — Bulk operations for multiple invoices: export, print, archive. + * + * Issue #810: Add invoice bulk operations — export, print, archive multiple. + */ + +import type { Invoice } from "@stellar-split/sdk"; +import { formatAmount } from "@stellar-split/sdk"; +import { archiveInvoices, unarchiveInvoices } from "@/lib/archiveInvoices"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type BulkExportFormat = "csv" | "json"; + +export interface BulkExportResult { + filename: string; + content: string; + mimeType: string; +} + +export interface BulkArchiveResult { + archivedIds: string[]; + skippedIds: string[]; +} + +export interface BulkPrintOptions { + includeQR?: boolean; + showRecipients?: boolean; +} + +// ── CSV Export ──────────────────────────────────────────────────────────────── + +function escapeCSV(value: string): string { + const str = String(value); + if (str.includes(",") || str.includes('"') || str.includes("\n")) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +function invoicesToCSV(invoices: Invoice[]): string { + const headers = [ + "Invoice ID", + "Status", + "Creator", + "Total Amount (USDC)", + "Funded (USDC)", + "Recipient Count", + "Deadline", + "Created", + ]; + + const rows = invoices.map((inv) => { + const total = inv.recipients.reduce((sum, r) => sum + r.amount, 0n); + const deadline = + inv.deadline > 0 + ? new Date(inv.deadline * 1000).toISOString().split("T")[0] + : ""; + return [ + escapeCSV(String(inv.id)), + escapeCSV(inv.status), + escapeCSV(inv.creator), + escapeCSV(formatAmount(total)), + escapeCSV(formatAmount(inv.funded)), + escapeCSV(String(inv.recipients.length)), + escapeCSV(deadline), + escapeCSV(""), + ].join(","); + }); + + return [headers.map(escapeCSV).join(","), ...rows].join("\n"); +} + +// ── JSON Export ─────────────────────────────────────────────────────────────── + +function invoicesToJSON(invoices: Invoice[]): string { + const exportData = invoices.map((inv) => { + const total = inv.recipients.reduce((sum, r) => sum + r.amount, 0n); + return { + id: String(inv.id), + status: inv.status, + creator: inv.creator, + totalAmount: formatAmount(total), + funded: formatAmount(inv.funded), + recipientCount: inv.recipients.length, + deadline: + inv.deadline > 0 + ? new Date(inv.deadline * 1000).toISOString() + : null, + recipients: inv.recipients.map((r) => ({ + address: r.address, + amount: formatAmount(r.amount), + })), + }; + }); + return JSON.stringify(exportData, null, 2); +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Export a list of invoices as CSV or JSON. + * Triggers a browser file download automatically. + */ +export function exportInvoicesBulk( + invoices: Invoice[], + format: BulkExportFormat = "csv", +): BulkExportResult { + const timestamp = new Date().toISOString().slice(0, 10); + + if (format === "json") { + const content = invoicesToJSON(invoices); + const result: BulkExportResult = { + filename: `invoices-export-${timestamp}.json`, + content, + mimeType: "application/json", + }; + triggerDownload(result); + return result; + } + + const content = invoicesToCSV(invoices); + const result: BulkExportResult = { + filename: `invoices-export-${timestamp}.csv`, + content, + mimeType: "text/csv", + }; + triggerDownload(result); + return result; +} + +function triggerDownload({ filename, content, mimeType }: BulkExportResult) { + if (typeof window === "undefined") return; + 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 = filename; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); +} + +/** + * Print a set of invoices using the browser print dialog. + * Opens a new window with a printable HTML summary. + */ +export function printInvoicesBulk( + invoices: Invoice[], + options: BulkPrintOptions = {}, +): void { + if (typeof window === "undefined") return; + + const { showRecipients = true } = options; + + const invoiceRows = invoices + .map((inv) => { + const total = inv.recipients.reduce((sum, r) => sum + r.amount, 0n); + const deadline = + inv.deadline > 0 + ? new Date(inv.deadline * 1000).toLocaleDateString() + : "—"; + + const recipientRows = showRecipients + ? inv.recipients + .map( + (r) => + `${r.address} + ${formatAmount(r.amount)} USDC`, + ) + .join("") + : ""; + + return ` +
+
+ Invoice #${inv.id} + ${inv.status} +
+
Creator: ${inv.creator}
+
Total: ${formatAmount(total)} USDC
+
Funded: ${formatAmount(inv.funded)} USDC
+
Deadline: ${deadline}
+ ${ + showRecipients && inv.recipients.length > 0 + ? ` + + + + + ${recipientRows} +
RecipientAmount
` + : "" + } +
`; + }) + .join(""); + + const html = ` + + + + Invoice Print — ${invoices.length} invoice${invoices.length !== 1 ? "s" : ""} + + + +

StellarSplit — Bulk Invoice Print

+

Printed ${new Date().toLocaleString()} · ${invoices.length} invoice${invoices.length !== 1 ? "s" : ""}

+ + ${invoiceRows} + +`; + + const printWindow = window.open("", "_blank", "width=800,height=600"); + if (!printWindow) return; + printWindow.document.write(html); + printWindow.document.close(); +} + +/** + * Archive a list of invoices in bulk. + * Skips invoices that are already archived. + */ +export function archiveInvoicesBulk( + invoices: Invoice[], + isArchivedFn: (id: string) => boolean, +): BulkArchiveResult { + const archivedIds: string[] = []; + const skippedIds: string[] = []; + + invoices.forEach((inv) => { + if (isArchivedFn(inv.id)) { + skippedIds.push(inv.id); + } else { + archivedIds.push(inv.id); + } + }); + + if (archivedIds.length > 0) { + archiveInvoices(archivedIds); + } + + return { archivedIds, skippedIds }; +} + +/** + * Unarchive a list of invoices in bulk. + */ +export function unarchiveInvoicesBulk(invoiceIds: string[]): void { + unarchiveInvoices(invoiceIds); +} diff --git a/src/lib/creatorMessages.ts b/src/lib/creatorMessages.ts new file mode 100644 index 00000000..b5538555 --- /dev/null +++ b/src/lib/creatorMessages.ts @@ -0,0 +1,209 @@ +/** + * creatorMessages.ts — Creator ↔ Payer messaging library. + * + * Messages are stored in localStorage under a per-invoice key. + * Each message includes the sender address, a role tag (creator/payer), + * the text, a local timestamp, and an optional read receipt. + * + * Issue #812: Add creator messaging system for payer communication. + */ + +export type MessageRole = "creator" | "payer" | "system"; + +export interface CreatorMessage { + id: string; + invoiceId: string; + sender: string; + role: MessageRole; + text: string; + timestamp: number; + readBy: string[]; // addresses that have "read" the message +} + +export interface MessageThread { + invoiceId: string; + messages: CreatorMessage[]; + unreadCount: number; +} + +// ── Storage keys ────────────────────────────────────────────────────────────── + +const STORAGE_PREFIX = "creator-messages-"; +const UNREAD_KEY_PREFIX = "creator-messages-unread-"; + +function storageKey(invoiceId: string): string { + return `${STORAGE_PREFIX}${invoiceId}`; +} + +function unreadKey(invoiceId: string, address: string): string { + return `${UNREAD_KEY_PREFIX}${invoiceId}-${address}`; +} + +// ── ID generation ───────────────────────────────────────────────────────────── + +function generateId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +// ── Parse / validate ────────────────────────────────────────────────────────── + +function isValidMessage(value: unknown): value is CreatorMessage { + if (typeof value !== "object" || value === null) return false; + const m = value as Record; + return ( + typeof m.id === "string" && + typeof m.invoiceId === "string" && + typeof m.sender === "string" && + typeof m.role === "string" && + typeof m.text === "string" && + typeof m.timestamp === "number" && + Array.isArray(m.readBy) + ); +} + +function parseMessages(raw: string | null): CreatorMessage[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter(isValidMessage).sort((a, b) => a.timestamp - b.timestamp); + } catch { + return []; + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Load all messages for a given invoice thread. + */ +export function loadMessages(invoiceId: string): CreatorMessage[] { + if (typeof window === "undefined") return []; + return parseMessages(localStorage.getItem(storageKey(invoiceId))); +} + +/** + * Post a new message to an invoice thread. + */ +export function postMessage( + invoiceId: string, + sender: string, + role: MessageRole, + text: string, +): CreatorMessage { + const trimmed = text.trim(); + if (!trimmed) throw new Error("Message text must not be empty."); + + const message: CreatorMessage = { + id: generateId(), + invoiceId, + sender, + role, + text: trimmed, + timestamp: Date.now(), + readBy: [sender], // sender has implicitly read their own message + }; + + const existing = loadMessages(invoiceId); + localStorage.setItem( + storageKey(invoiceId), + JSON.stringify([...existing, message]), + ); + + return message; +} + +/** + * Mark all messages in a thread as read by the given address. + */ +export function markThreadRead(invoiceId: string, address: string): void { + if (typeof window === "undefined") return; + + const messages = loadMessages(invoiceId); + const updated = messages.map((m) => + m.readBy.includes(address) ? m : { ...m, readBy: [...m.readBy, address] }, + ); + + localStorage.setItem(storageKey(invoiceId), JSON.stringify(updated)); + + // Clear unread counter + localStorage.removeItem(unreadKey(invoiceId, address)); +} + +/** + * Count unread messages for a given address in a thread. + */ +export function countUnread(invoiceId: string, address: string): number { + const messages = loadMessages(invoiceId); + return messages.filter( + (m) => m.sender !== address && !m.readBy.includes(address), + ).length; +} + +/** + * Get a full message thread including unread count for the viewer. + */ +export function getThread(invoiceId: string, viewerAddress: string): MessageThread { + const messages = loadMessages(invoiceId); + const unreadCount = messages.filter( + (m) => m.sender !== viewerAddress && !m.readBy.includes(viewerAddress), + ).length; + + return { invoiceId, messages, unreadCount }; +} + +/** + * Delete a single message by ID (only the original sender may delete). + */ +export function deleteMessage( + invoiceId: string, + messageId: string, + callerAddress: string, +): boolean { + const messages = loadMessages(invoiceId); + const target = messages.find((m) => m.id === messageId); + if (!target || target.sender !== callerAddress) return false; + + const updated = messages.filter((m) => m.id !== messageId); + localStorage.setItem(storageKey(invoiceId), JSON.stringify(updated)); + return true; +} + +/** + * Clear all messages in a thread (for moderation / testing). + */ +export function clearThread(invoiceId: string): void { + if (typeof window !== "undefined") { + localStorage.removeItem(storageKey(invoiceId)); + } +} + +/** + * Determine the role of an address in an invoice. + */ +export function resolveRole( + address: string, + creatorAddress: string, + recipientAddresses: string[], +): MessageRole { + if (address === creatorAddress) return "creator"; + if (recipientAddresses.includes(address)) return "payer"; + return "system"; +} + +/** + * Format a timestamp for display in the message panel. + */ +export function formatMessageTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + + if (diff < 60_000) return "just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(timestamp)); +} diff --git a/src/lib/dashboardFilters.ts b/src/lib/dashboardFilters.ts index cdf16b26..16a2a5f8 100644 --- a/src/lib/dashboardFilters.ts +++ b/src/lib/dashboardFilters.ts @@ -4,35 +4,115 @@ import type { Invoice } from "@stellar-split/sdk"; export type DashboardInvoice = Invoice; export type DashboardPresetId = "all" | "active" | "funded" | "refunded" | "expired" | "draft" | "overdue"; -export type DashboardSortId = "newest" | "oldest" | "amount-desc" | "amount-asc" | "deadline"; - -export const SORT_OPTIONS: { id: DashboardSortId; label: string }[] = [ - { id: "newest", label: "Most Recent" }, - { id: "oldest", label: "Oldest" }, - { id: "amount-desc", label: "Amount (high–low)" }, - { id: "amount-asc", label: "Amount (low–high)" }, - { id: "deadline", label: "Deadline (soonest)" }, +/** + * Dashboard sort options. + * + * Issue #811: extended with advanced sort options — funded percentage, + * recipient count, deadline-farthest, and alphabetical-by-creator. + */ +export type DashboardSortId = + | "newest" + | "oldest" + | "amount-desc" + | "amount-asc" + | "deadline" + | "deadline-farthest" + | "funded-pct-desc" + | "funded-pct-asc" + | "recipients-desc" + | "recipients-asc" + | "creator-asc" + | "creator-desc"; + +export const SORT_OPTIONS: { id: DashboardSortId; label: string; group?: string }[] = [ + // ── Recency ────────────────────────────────────────────────────────────── + { id: "newest", label: "Most Recent", group: "Date" }, + { id: "oldest", label: "Oldest First", group: "Date" }, + // ── Deadline ───────────────────────────────────────────────────────────── + { id: "deadline", label: "Deadline (soonest first)", group: "Deadline" }, + { id: "deadline-farthest", label: "Deadline (farthest first)", group: "Deadline" }, + // ── Amount ─────────────────────────────────────────────────────────────── + { id: "amount-desc", label: "Amount (high → low)", group: "Amount" }, + { id: "amount-asc", label: "Amount (low → high)", group: "Amount" }, + // ── Funding progress ───────────────────────────────────────────────────── + { id: "funded-pct-desc", label: "Funded % (most funded)", group: "Funding" }, + { id: "funded-pct-asc", label: "Funded % (least funded)", group: "Funding" }, + // ── Recipients ─────────────────────────────────────────────────────────── + { id: "recipients-desc", label: "Recipients (most)", group: "Recipients" }, + { id: "recipients-asc", label: "Recipients (fewest)", group: "Recipients" }, + // ── Creator ────────────────────────────────────────────────────────────── + { id: "creator-asc", label: "Creator (A → Z)", group: "Creator" }, + { id: "creator-desc", label: "Creator (Z → A)", group: "Creator" }, ]; +/** Unique sort groups in declaration order, for grouped UI rendering. */ +export const SORT_GROUPS: string[] = Array.from( + new Set(SORT_OPTIONS.map((o) => o.group ?? "").filter(Boolean)), +); + +/** Returns funded percentage in [0, 1] for a given invoice. */ +function fundedPercent(inv: Invoice): number { + const total = inv.recipients.reduce((s, r) => s + r.amount, 0n); + if (total === 0n) return 0; + // Convert bigint to number for percentage comparison (precision sufficient for sorting) + return Number((inv.funded * 10_000n) / total) / 10_000; +} + export function sortInvoices(invoices: Invoice[], sort: DashboardSortId): Invoice[] { const list = [...invoices]; switch (sort) { case "oldest": return list.sort((a, b) => Number(a.id) - Number(b.id)); + case "amount-desc": return list.sort((a, b) => { const ta = a.recipients.reduce((s, r) => s + r.amount, 0n); const tb = b.recipients.reduce((s, r) => s + r.amount, 0n); return tb > ta ? 1 : tb < ta ? -1 : 0; }); + case "amount-asc": return list.sort((a, b) => { const ta = a.recipients.reduce((s, r) => s + r.amount, 0n); const tb = b.recipients.reduce((s, r) => s + r.amount, 0n); return ta > tb ? 1 : ta < tb ? -1 : 0; }); + case "deadline": - return list.sort((a, b) => a.deadline - b.deadline); + // Put invoices with no deadline (0) at the end + return list.sort((a, b) => { + if (!a.deadline && !b.deadline) return 0; + if (!a.deadline) return 1; + if (!b.deadline) return -1; + return a.deadline - b.deadline; + }); + + case "deadline-farthest": + return list.sort((a, b) => { + if (!a.deadline && !b.deadline) return 0; + if (!a.deadline) return 1; + if (!b.deadline) return -1; + return b.deadline - a.deadline; + }); + + case "funded-pct-desc": + return list.sort((a, b) => fundedPercent(b) - fundedPercent(a)); + + case "funded-pct-asc": + return list.sort((a, b) => fundedPercent(a) - fundedPercent(b)); + + case "recipients-desc": + return list.sort((a, b) => b.recipients.length - a.recipients.length); + + case "recipients-asc": + return list.sort((a, b) => a.recipients.length - b.recipients.length); + + case "creator-asc": + return list.sort((a, b) => a.creator.localeCompare(b.creator)); + + case "creator-desc": + return list.sort((a, b) => b.creator.localeCompare(a.creator)); + case "newest": default: return list.sort((a, b) => Number(b.id) - Number(a.id)); diff --git a/src/lib/invoiceDuplicates.ts b/src/lib/invoiceDuplicates.ts new file mode 100644 index 00000000..a4c4729a --- /dev/null +++ b/src/lib/invoiceDuplicates.ts @@ -0,0 +1,302 @@ +/** + * invoiceDuplicates.ts — Invoice duplicate detection and merge utilities. + * + * Detection strategy: + * Two invoices are considered duplicates when they share the same set of + * (recipient address, amount) pairs AND have the same creator. An optional + * deadline-proximity window can tighten or loosen the match. + * + * Merge strategy: + * The "primary" invoice is kept; duplicate invoices are flagged/hidden in + * localStorage. Because we don't control on-chain state from the client, + * merging is a UI-level concept: the merged invoice's metadata (notes, tags) + * is optionally carried over to the primary record. + * + * Issue #813: Implement invoice duplicate detection and merge. + */ + +import type { Invoice } from "@stellar-split/sdk"; +import { formatAmount } from "@stellar-split/sdk"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface DuplicateGroup { + /** Stable key for the group derived from creator + recipient fingerprint. */ + key: string; + /** All invoices that share this fingerprint (≥2). */ + invoices: Invoice[]; + /** Confidence score in [0, 1]: 1.0 = exact match, lower = fuzzy. */ + confidence: number; + /** Human-readable reason why these are considered duplicates. */ + reason: string; +} + +export type MergeStrategy = "keep-newest" | "keep-oldest" | "keep-largest"; + +export interface MergeResult { + primaryId: string; + mergedIds: string[]; + /** Total amount consolidated (in USDC string). */ + totalAmount: string; +} + +// ── Fingerprinting ──────────────────────────────────────────────────────────── + +/** + * Stable fingerprint for an invoice based on creator + sorted recipients. + * Two invoices with the same fingerprint are strong-match duplicates. + */ +export function invoiceFingerprint(invoice: Invoice): string { + const recipientParts = invoice.recipients + .map((r) => `${r.address.toLowerCase()}:${r.amount.toString()}`) + .sort() + .join("|"); + return `${invoice.creator.toLowerCase()}@@${recipientParts}`; +} + +/** + * Looser fingerprint that ignores exact amounts — matches invoices with the + * same recipients regardless of amount (fuzzy duplicate detection). + */ +export function invoiceFingerprintFuzzy(invoice: Invoice): string { + const recipientParts = invoice.recipients + .map((r) => r.address.toLowerCase()) + .sort() + .join("|"); + return `${invoice.creator.toLowerCase()}@@${recipientParts}`; +} + +// ── Detection ───────────────────────────────────────────────────────────────── + +/** + * Find exact duplicates: same creator, same recipients, same amounts. + */ +export function findExactDuplicates(invoices: Invoice[]): DuplicateGroup[] { + const groups = new Map(); + + for (const inv of invoices) { + const key = invoiceFingerprint(inv); + const existing = groups.get(key); + if (existing) { + existing.push(inv); + } else { + groups.set(key, [inv]); + } + } + + const result: DuplicateGroup[] = []; + for (const [key, group] of groups) { + if (group.length >= 2) { + result.push({ + key, + invoices: group, + confidence: 1.0, + reason: "Identical creator, recipients, and amounts.", + }); + } + } + + return result; +} + +/** + * Find fuzzy duplicates: same creator + same recipients, but different amounts. + * These likely represent repriced invoices sent to the same parties. + */ +export function findFuzzyDuplicates(invoices: Invoice[]): DuplicateGroup[] { + const groups = new Map(); + + for (const inv of invoices) { + const key = invoiceFingerprintFuzzy(inv); + const existing = groups.get(key); + if (existing) { + existing.push(inv); + } else { + groups.set(key, [inv]); + } + } + + // Exclude groups that are already exact duplicates + const exactFingerprints = new Set( + invoices.map((inv) => invoiceFingerprint(inv)), + ); + + const result: DuplicateGroup[] = []; + for (const [key, group] of groups) { + if (group.length >= 2) { + // Only add as fuzzy if not all members share the exact same amounts + const exactKey = invoiceFingerprint(group[0]); + const allExact = group.every( + (inv) => invoiceFingerprint(inv) === exactKey, + ); + if (!allExact) { + result.push({ + key: `fuzzy:${key}`, + invoices: group, + confidence: 0.7, + reason: + "Same creator and recipients, but different amounts — possible reprice.", + }); + } + } + } + + return result; +} + +/** + * Find all duplicate groups (exact + fuzzy), deduplicating any invoice + * that appears in multiple groups by assigning it to the highest-confidence + * group first. + */ +export function findAllDuplicates(invoices: Invoice[]): DuplicateGroup[] { + const exact = findExactDuplicates(invoices); + const fuzzy = findFuzzyDuplicates(invoices); + + // Remove from fuzzy any invoice already covered by an exact group + const exactIds = new Set(exact.flatMap((g) => g.invoices.map((inv) => inv.id))); + + const filteredFuzzy = fuzzy + .map((g) => ({ + ...g, + invoices: g.invoices.filter((inv) => !exactIds.has(inv.id)), + })) + .filter((g) => g.invoices.length >= 2); + + return [...exact, ...filteredFuzzy].sort((a, b) => b.confidence - a.confidence); +} + +// ── Merge ───────────────────────────────────────────────────────────────────── + +const MERGED_KEY = "merged-invoice-ids"; + +/** + * Get the set of invoice IDs that have been marked as "merged" (hidden in UI). + */ +export function getMergedIds(): Set { + if (typeof window === "undefined") return new Set(); + try { + const raw = localStorage.getItem(MERGED_KEY); + return new Set(raw ? JSON.parse(raw) : []); + } catch { + return new Set(); + } +} + +/** + * Check whether an invoice has been merged (hidden) locally. + */ +export function isInvoiceMerged(invoiceId: string): boolean { + return getMergedIds().has(String(invoiceId)); +} + +/** + * Mark duplicate invoices as merged in localStorage. + * The primary invoice is kept; the rest are marked as merged (hidden). + * + * @param group - The duplicate group to merge. + * @param strategy - Which invoice to keep as the primary. + * @returns MergeResult describing what happened. + */ +export function mergeInvoiceGroup( + group: DuplicateGroup, + strategy: MergeStrategy = "keep-newest", +): MergeResult { + if (group.invoices.length < 2) { + throw new Error("A duplicate group must have at least 2 invoices to merge."); + } + + // Select primary based on strategy + let sorted: Invoice[]; + switch (strategy) { + case "keep-oldest": + sorted = [...group.invoices].sort((a, b) => Number(a.id) - Number(b.id)); + break; + case "keep-largest": { + sorted = [...group.invoices].sort((a, b) => { + const ta = a.recipients.reduce((s, r) => s + r.amount, 0n); + const tb = b.recipients.reduce((s, r) => s + r.amount, 0n); + return tb > ta ? 1 : tb < ta ? -1 : 0; + }); + break; + } + case "keep-newest": + default: + sorted = [...group.invoices].sort((a, b) => Number(b.id) - Number(a.id)); + break; + } + + const [primary, ...duplicates] = sorted; + const duplicateIds = duplicates.map((inv) => String(inv.id)); + + // Persist merged IDs + const existing = getMergedIds(); + duplicateIds.forEach((id) => existing.add(id)); + localStorage.setItem(MERGED_KEY, JSON.stringify([...existing])); + + // Compute total consolidated amount + const total = group.invoices + .flatMap((inv) => inv.recipients) + .reduce((sum, r) => sum + r.amount, 0n); + + return { + primaryId: String(primary.id), + mergedIds: duplicateIds, + totalAmount: formatAmount(total), + }; +} + +/** + * Undo a merge: restore the given invoice IDs to the active state. + */ +export function undoMerge(invoiceIds: string[]): void { + if (typeof window === "undefined") return; + const merged = getMergedIds(); + invoiceIds.forEach((id) => merged.delete(id)); + localStorage.setItem(MERGED_KEY, JSON.stringify([...merged])); +} + +/** + * Filter out invoices that have been merged. + */ +export function filterMerged(invoices: Invoice[]): Invoice[] { + const merged = getMergedIds(); + return invoices.filter((inv) => !merged.has(String(inv.id))); +} + +// ── Summary helpers ─────────────────────────────────────────────────────────── + +/** + * Summarise the differences between two invoices for display in the merge + * confirmation UI. + */ +export function describeDifferences(a: Invoice, b: Invoice): string[] { + const diffs: string[] = []; + + const totalA = a.recipients.reduce((s, r) => s + r.amount, 0n); + const totalB = b.recipients.reduce((s, r) => s + r.amount, 0n); + + if (totalA !== totalB) { + diffs.push( + `Different totals: ${formatAmount(totalA)} vs ${formatAmount(totalB)} USDC`, + ); + } + + if (a.deadline !== b.deadline) { + const fmt = (d: number) => + d > 0 ? new Date(d * 1000).toLocaleDateString() : "none"; + diffs.push(`Different deadlines: ${fmt(a.deadline)} vs ${fmt(b.deadline)}`); + } + + if (a.status !== b.status) { + diffs.push(`Different statuses: ${a.status} vs ${b.status}`); + } + + if (a.recipients.length !== b.recipients.length) { + diffs.push( + `Different recipient counts: ${a.recipients.length} vs ${b.recipients.length}`, + ); + } + + return diffs; +}