From e233c056d47c78fd52a0b6f62998e673ca82cc48 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 1 Aug 2026 14:28:08 +0000 Subject: [PATCH] feat(invoices): filter mass payments by agent and by coin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paying everyone at once is the common case and stays exactly one click. But settling up with a single agent — or clearing out one coin — meant unticking seventy-nine boxes to get there. Two selects sit above the invoice rows: who gets paid, and in which coin. Each option carries its own count and total ("Ada (12 · $340.00)"), so the payer can see what picking it commits to before picking it. They combine, and a Clear filters link puts everything back. Picking a filter re-points the selection at exactly what it shows, because choosing an agent is already a statement of intent — "pay this one" — and should not then require a second select-all. Nothing outside the new view stays selected, so the summary can never bill for a row that scrolled out of sight. Select-all is likewise scoped to the visible rows. Reaching across a filter would let one click commit money the payer cannot see, which is the same reasoning that already keeps newly-appeared invoices unticked. Coins keep their chain in the label (USDC · SOL), since USDC on two chains is not interchangeable and must not collapse into one filter entry. The filter bar hides itself when there is only one payee and one coin, so a short list gains no chrome. Co-Authored-By: Claude Opus 5 (1M context) --- .../invoices/BulkPayAccepted.test.tsx | 125 +++++++++++++- .../dashboard/invoices/BulkPayAccepted.tsx | 157 +++++++++++++++++- src/app/dashboard/invoices/page.tsx | 2 + 3 files changed, 270 insertions(+), 14 deletions(-) diff --git a/src/app/dashboard/invoices/BulkPayAccepted.test.tsx b/src/app/dashboard/invoices/BulkPayAccepted.test.tsx index 9ba9780c..afb4b067 100644 --- a/src/app/dashboard/invoices/BulkPayAccepted.test.tsx +++ b/src/app/dashboard/invoices/BulkPayAccepted.test.tsx @@ -39,9 +39,16 @@ const PREPARED = { }, }; -/** The component takes labelled rows now; tests still think in ids. */ +/** The component takes labelled rows now; tests still think in ids. Each id + * gets its own payee by default, so filtering can be exercised. */ function payable(ids: string[]) { - return ids.map((id, i) => ({ id, label: `Worker ${i} — Gig ${i}`, amountUsd: 50 })); + return ids.map((id, i) => ({ + id, + label: `Worker ${i} — Gig ${i}`, + amountUsd: 50, + payeeId: `worker-${i}`, + payeeName: `Worker ${i}`, + })); } /** Install a fake `window.coinpay`, as the extension would. */ @@ -450,9 +457,9 @@ describe("BulkPayAccepted", () => { // the prepare call — not just the list on screen. installWallet(); const invoices = [ - { id: "inv-big", label: "Big", amountUsd: 90 }, - { id: "inv-small", label: "Small", amountUsd: 1 }, - { id: "inv-mid", label: "Mid", amountUsd: 20 }, + { id: "inv-big", label: "Big", amountUsd: 90, payeeId: "w1", payeeName: "Ada" }, + { id: "inv-small", label: "Small", amountUsd: 1, payeeId: "w2", payeeName: "Grace" }, + { id: "inv-mid", label: "Mid", amountUsd: 20, payeeId: "w1", payeeName: "Ada" }, ]; const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { const ids = JSON.parse(String(init?.body ?? "{}")).invoice_ids as string[]; @@ -508,10 +515,10 @@ describe("BulkPayAccepted", () => { render( @@ -571,3 +578,107 @@ describe("BulkPayAccepted", () => { expect(wallet.payBatch).not.toHaveBeenCalled(); }); }); + +/** + * Paying everyone at once is the default, but settling up with a single agent + * — or in a single coin — should not mean unticking seventy-nine boxes. + */ +describe("BulkPayAccepted — filtering", () => { + const MIXED = [ + { id: "a1", label: "Ada — Fix login", amountUsd: 10, payeeId: "ada", payeeName: "Ada", currency: "usdc_sol", amountCrypto: null }, + { id: "a2", label: "Ada — Ship compiler", amountUsd: 20, payeeId: "ada", payeeName: "Ada", currency: "sol", amountCrypto: null }, + { id: "g1", label: "Grace — Debug", amountUsd: 30, payeeId: "grace", payeeName: "Grace", currency: "usdc_sol", amountCrypto: null }, + ]; + + beforeEach(() => { + mockFetch(); + installWallet(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + delete (window as any).coinpay; + }); + + it("selects everyone by default, so paying all of it stays one click", () => { + render(); + + expect(screen.getByRole("button", { name: /Pay 3/ })).toBeInTheDocument(); + expect(screen.getByText(/3 of 3 selected/)).toBeInTheDocument(); + }); + + it("narrows the payment to one agent when that agent is picked", () => { + render(); + + fireEvent.change(screen.getByLabelText(/Filter invoices by who gets paid/), { + target: { value: "ada" }, + }); + + // Ada has two invoices worth $30 — Grace's $30 must not be in the run. + expect(screen.getByRole("button", { name: /Pay 2/ })).toBeInTheDocument(); + expect(screen.getByText(/2 of 3 selected/)).toBeInTheDocument(); + }); + + it("narrows to a single coin", () => { + render(); + + fireEvent.change(screen.getByLabelText(/Filter invoices by coin/), { + target: { value: "usdc_sol" }, + }); + + expect(screen.getByRole("button", { name: /Pay 2/ })).toBeInTheDocument(); + }); + + it("combines the two filters", () => { + render(); + + fireEvent.change(screen.getByLabelText(/Filter invoices by who gets paid/), { + target: { value: "ada" }, + }); + fireEvent.change(screen.getByLabelText(/Filter invoices by coin/), { + target: { value: "sol" }, + }); + + // Only Ada's SOL invoice survives both. + expect(screen.getByRole("button", { name: /Pay 1/ })).toBeInTheDocument(); + }); + + it("restores everyone when the filters are cleared", () => { + render(); + + fireEvent.change(screen.getByLabelText(/Filter invoices by who gets paid/), { + target: { value: "ada" }, + }); + fireEvent.click(screen.getByRole("button", { name: /Clear filters/ })); + + expect(screen.getByRole("button", { name: /Pay 3/ })).toBeInTheDocument(); + }); + + it("keeps select-all scoped to the filtered rows, never paying a hidden one", () => { + render(); + + fireEvent.change(screen.getByLabelText(/Filter invoices by who gets paid/), { + target: { value: "ada" }, + }); + // Untick, then re-tick: the toggle must not reach across the filter and + // pull Grace's invoice into a run the payer is not looking at. + fireEvent.click(screen.getByLabelText(/Deselect all invoices/)); + expect(screen.getByText(/0 of 3 selected/)).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText(/Select all invoices/)); + expect(screen.getByRole("button", { name: /Pay 2/ })).toBeInTheDocument(); + }); + + it("hides the filter bar when there is only one payee and one coin", () => { + render( + + ); + + expect(screen.queryByLabelText(/Filter invoices by who gets paid/)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/Filter invoices by coin/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/dashboard/invoices/BulkPayAccepted.tsx b/src/app/dashboard/invoices/BulkPayAccepted.tsx index b4d7ed80..910f74c2 100644 --- a/src/app/dashboard/invoices/BulkPayAccepted.tsx +++ b/src/app/dashboard/invoices/BulkPayAccepted.tsx @@ -43,6 +43,9 @@ export interface PayableInvoice { /** Who is owed, and for what — enough to recognise a row without opening it. */ label: string; amountUsd: number; + /** Who gets paid. Drives the payee filter — paying one agent at a time. */ + payeeId: string; + payeeName: string; /** CoinPay currency the worker receives in, e.g. `usdc_sol`. */ currency?: string | null; /** @@ -53,6 +56,16 @@ export interface PayableInvoice { amountCrypto?: string | number | null; } +/** + * `usdc_sol` → `USDC · SOL`. Keeps the chain visible, since USDC on two chains + * is not interchangeable and must not collapse into one filter entry. + */ +function coinLabel(currency: string): string { + const [symbol, chain] = currency.split("_"); + const head = (symbol ?? currency).toUpperCase(); + return chain ? `${head} · ${chain.toUpperCase()}` : head; +} + /** * `0.013851 SOL` for a row whose request has been quoted. Returns null when no * quote exists yet — showing a currency with no amount, or an amount derived @@ -105,6 +118,11 @@ export function BulkPayAccepted({ invoices, totalUsd }: Props) { // settles the most invoices that way, and the big ones can wait for a // top-up. This drives the actual payment order, not just the display. const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + // Filters, empty meaning unfiltered. The default is everyone and every coin, + // because settling the whole list in one confirmation is the common case; + // narrowing to a single agent (or a single coin) is the exception. + const [payeeFilter, setPayeeFilter] = useState(""); + const [coinFilter, setCoinFilter] = useState(""); const [phase, setPhase] = useState("idle"); const [payments, setPayments] = useState([]); const [skipped, setSkipped] = useState([]); @@ -145,6 +163,46 @@ export function BulkPayAccepted({ invoices, totalUsd }: Props) { [invoices, sortDir] ); + const visibleInvoices = useMemo( + () => + orderedInvoices.filter( + (i) => + (!payeeFilter || i.payeeId === payeeFilter) && + (!coinFilter || i.currency === coinFilter) + ), + [orderedInvoices, payeeFilter, coinFilter] + ); + + // Filter options carry their own count and total, so the payer can see what + // picking one would commit to before picking it. + const payeeOptions = useMemo(() => { + const byId = new Map(); + for (const i of invoices) { + const row = byId.get(i.payeeId) ?? { + id: i.payeeId, + name: i.payeeName, + count: 0, + total: 0, + }; + row.count += 1; + row.total += i.amountUsd; + byId.set(i.payeeId, row); + } + return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name)); + }, [invoices]); + + const coinOptions = useMemo(() => { + const byCode = new Map(); + for (const i of invoices) { + if (!i.currency) continue; + const row = byCode.get(i.currency) ?? { code: i.currency, count: 0, total: 0 }; + row.count += 1; + row.total += i.amountUsd; + byCode.set(i.currency, row); + } + return [...byCode.values()].sort((a, b) => a.code.localeCompare(b.code)); + }, [invoices]); + // Derived from the sorted list, so the order the payer sees is the order the // wallet is handed — pay-cheapest-first only means anything if it reaches // `payBatch` that way. @@ -156,7 +214,11 @@ export function BulkPayAccepted({ invoices, totalUsd }: Props) { () => invoices.filter((i) => selected.has(i.id)).reduce((sum, i) => sum + i.amountUsd, 0), [invoices, selected] ); - const allSelected = selectedIds.length === invoiceIds.length && invoiceIds.length > 0; + // Select-all applies to what is on screen, not to invoices hidden behind a + // filter — ticking a box must never commit money the payer cannot see. + const visibleIds = useMemo(() => visibleInvoices.map((i) => i.id), [visibleInvoices]); + const allSelected = + visibleIds.length > 0 && visibleIds.every((id) => selected.has(id)); const toggleOne = useCallback((id: string) => { setSelected((current) => { @@ -168,10 +230,37 @@ export function BulkPayAccepted({ invoices, totalUsd }: Props) { }, []); const toggleAll = useCallback(() => { - setSelected((current) => - current.size === invoiceIds.length ? new Set() : new Set(invoiceIds) - ); - }, [invoiceIds]); + setSelected((current) => { + const next = new Set(current); + if (visibleIds.every((id) => next.has(id))) { + for (const id of visibleIds) next.delete(id); + } else { + for (const id of visibleIds) next.add(id); + } + return next; + }); + }, [visibleIds]); + + /** + * Changing a filter re-points the selection at exactly what the new filter + * shows. Picking an agent is a statement of intent — "pay this one" — so it + * should take one click, not a filter plus a select-all. Nothing outside the + * new view stays selected, so the summary can never bill for a hidden row. + */ + const applyFilters = useCallback( + (payee: string, coin: string) => { + setPayeeFilter(payee); + setCoinFilter(coin); + setSelected( + new Set( + invoices + .filter((i) => (!payee || i.payeeId === payee) && (!coin || i.currency === coin)) + .map((i) => i.id) + ) + ); + }, + [invoices] + ); /** * Mint payment requests for the given invoices (the current selection by @@ -399,6 +488,60 @@ export function BulkPayAccepted({ invoices, totalUsd }: Props) { of what they owe — or testing with a handful — needs the rows. */} {phase === "idle" && (
+ {(payeeOptions.length > 1 || coinOptions.length > 1) && ( +
+ {payeeOptions.length > 1 && ( + + )} + + {coinOptions.length > 1 && ( + + )} + + {(payeeFilter || coinFilter) && ( + + )} +
+ )} +
{showRows && (
    - {orderedInvoices.map((invoice) => ( + {visibleInvoices.map((invoice) => (