From 10a36b7afc73d86af13184a20a8d7b4a3b308a19 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 1 Aug 2026 05:27:31 +0000 Subject: [PATCH] feat(invoices): choose which accepted invoices to pay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk pay was all-or-nothing: it took the full accepted list and the only button was "Pay all N". There was no way to settle part of what you owe, and no way to try a handful before committing to eighty. Each invoice now gets a checkbox, with a select-all/deselect-all toggle above them (indeterminate when the selection is partial). The rows are collapsed behind "Show N invoices" so paying everything stays one click, and the summary and button follow the selection — "3 of 80 selected · $4.12 of $78.61", "Pay 3". Everything starts selected, because paying all of it is the common case and the checkboxes exist to carve out exceptions rather than to make the normal path require eighty clicks. Invoices that stop being payable drop out of the selection; ones that appear later stay unticked until the payer says so, so a refresh can never quietly add something to a run they did not look at. Co-Authored-By: Claude Opus 5 (1M context) --- .../invoices/BulkPayAccepted.test.tsx | 59 ++++---- .../dashboard/invoices/BulkPayAccepted.tsx | 142 ++++++++++++++++-- src/app/dashboard/invoices/page.tsx | 8 +- 3 files changed, 165 insertions(+), 44 deletions(-) diff --git a/src/app/dashboard/invoices/BulkPayAccepted.test.tsx b/src/app/dashboard/invoices/BulkPayAccepted.test.tsx index 165b5dfb..5328744c 100644 --- a/src/app/dashboard/invoices/BulkPayAccepted.test.tsx +++ b/src/app/dashboard/invoices/BulkPayAccepted.test.tsx @@ -39,6 +39,11 @@ const PREPARED = { }, }; +/** The component takes labelled rows now; tests still think in ids. */ +function payable(ids: string[]) { + return ids.map((id, i) => ({ id, label: `Worker ${i} — Gig ${i}`, amountUsd: 50 })); +} + /** Install a fake `window.coinpay`, as the extension would. */ function installWallet(overrides: Record = {}) { const provider = { @@ -76,7 +81,7 @@ function mockFetch(handlers: Record = {}) { } async function openConfirmation() { - fireEvent.click(screen.getByRole("button", { name: /Pay all 2/ })); + fireEvent.click(screen.getByRole("button", { name: /Pay 2/ })); await screen.findByRole("button", { name: /Approve in wallet/ }); } @@ -93,42 +98,42 @@ describe("BulkPayAccepted", () => { it("renders nothing when there is nothing accepted to pay", () => { installWallet(); - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); it("prompts to install the wallet when the extension is absent", () => { - render(); + render(); expect(screen.getByText(/Install the CoinPay wallet/)).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /Pay all/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Pay \\d/ })).not.toBeInTheDocument(); }); it("offers the bulk action once the wallet is detected", () => { installWallet(); - render(); + render(); - expect(screen.getByRole("button", { name: /Pay all 2/ })).toBeInTheDocument(); - expect(screen.getByText(/2 invoices · \$100\.00/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Pay 2/ })).toBeInTheDocument(); + expect(screen.getByText(/2 of 2 selected/)).toBeInTheDocument(); }); it("detects a wallet that finishes injecting after mount", async () => { - render(); - expect(screen.queryByRole("button", { name: /Pay all/ })).not.toBeInTheDocument(); + render(); + expect(screen.queryByRole("button", { name: /^Pay \\d/ })).not.toBeInTheDocument(); installWallet(); act(() => { window.dispatchEvent(new Event("coinpay#initialized")); }); - expect(await screen.findByRole("button", { name: /Pay all 2/ })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Pay 2/ })).toBeInTheDocument(); }); it("prepares payment requests for the accepted invoices", async () => { installWallet(); const fetchMock = mockFetch(); - render(); + render(); await openConfirmation(); @@ -144,7 +149,7 @@ describe("BulkPayAccepted", () => { it("does not touch the wallet before the user confirms", async () => { const wallet = installWallet(); - render(); + render(); await openConfirmation(); @@ -162,7 +167,7 @@ describe("BulkPayAccepted", () => { }, }, }); - render(); + render(); await openConfirmation(); @@ -173,7 +178,7 @@ describe("BulkPayAccepted", () => { it("hands the prepared payments to the wallet on approval", async () => { const wallet = installWallet(); - render(); + render(); await openConfirmation(); fireEvent.click(screen.getByRole("button", { name: /Approve in wallet/ })); @@ -190,7 +195,7 @@ describe("BulkPayAccepted", () => { it("records the broadcast results afterwards", async () => { installWallet(); const fetchMock = mockFetch(); - render(); + render(); await openConfirmation(); fireEvent.click(screen.getByRole("button", { name: /Approve in wallet/ })); @@ -212,7 +217,7 @@ describe("BulkPayAccepted", () => { it("summarizes a fully successful run", async () => { installWallet(); - render(); + render(); await openConfirmation(); fireEvent.click(screen.getByRole("button", { name: /Approve in wallet/ })); @@ -239,7 +244,7 @@ describe("BulkPayAccepted", () => { })), }); const fetchMock = mockFetch(); - render(); + render(); await openConfirmation(); fireEvent.click(screen.getByRole("button", { name: /Approve in wallet/ })); @@ -265,7 +270,7 @@ describe("BulkPayAccepted", () => { throw new Error("Payment request rejected"); }), }); - render(); + render(); await openConfirmation(); fireEvent.click(screen.getByRole("button", { name: /Approve in wallet/ })); @@ -305,8 +310,8 @@ describe("BulkPayAccepted", () => { }); vi.stubGlobal("fetch", fetchMock); - render(); - fireEvent.click(screen.getByRole("button", { name: /Pay all 45/ })); + render(); + fireEvent.click(screen.getByRole("button", { name: /Pay 45/ })); await screen.findByRole("button", { name: /Approve in wallet/ }); @@ -356,8 +361,8 @@ describe("BulkPayAccepted", () => { }) ); - render(); - fireEvent.click(screen.getByRole("button", { name: /Pay all 40/ })); + render(); + fireEvent.click(screen.getByRole("button", { name: /Pay 40/ })); // Still reaches the confirmation step with the successful chunks intact, // rather than throwing the whole run away. @@ -371,9 +376,9 @@ describe("BulkPayAccepted", () => { "fetch", vi.fn(async () => ({ ok: false, json: async () => ({ error: "CoinPay is down" }) })) ); - render(); + render(); - fireEvent.click(screen.getByRole("button", { name: /Pay all 2/ })); + fireEvent.click(screen.getByRole("button", { name: /Pay 2/ })); expect(await screen.findByText("CoinPay is down")).toBeInTheDocument(); expect(wallet.payBatch).not.toHaveBeenCalled(); @@ -390,7 +395,7 @@ describe("BulkPayAccepted", () => { throw new Error("network down"); }) ); - render(); + render(); await openConfirmation(); fireEvent.click(screen.getByRole("button", { name: /Approve in wallet/ })); @@ -402,12 +407,12 @@ describe("BulkPayAccepted", () => { it("lets the user back out of the confirmation", async () => { const wallet = installWallet(); - render(); + render(); await openConfirmation(); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); - expect(await screen.findByRole("button", { name: /Pay all 2/ })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: /Pay 2/ })).toBeInTheDocument(); expect(wallet.payBatch).not.toHaveBeenCalled(); }); }); diff --git a/src/app/dashboard/invoices/BulkPayAccepted.tsx b/src/app/dashboard/invoices/BulkPayAccepted.tsx index 53e42149..f44b5424 100644 --- a/src/app/dashboard/invoices/BulkPayAccepted.tsx +++ b/src/app/dashboard/invoices/BulkPayAccepted.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { @@ -37,9 +37,16 @@ const EXTENSION_URL = "https://coinpayportal.com/extension"; // promptly even when the provider is pacing us, so the count keeps moving. const PREPARE_CHUNK = 20; +export interface PayableInvoice { + id: string; + /** Who is owed, and for what — enough to recognise a row without opening it. */ + label: string; + amountUsd: number; +} + interface Props { - /** Ids of the accepted-and-unpaid invoices the signed-in user owes. */ - invoiceIds: string[]; + /** The accepted-and-unpaid invoices the signed-in user owes. */ + invoices: PayableInvoice[]; totalUsd: number; } @@ -62,10 +69,16 @@ interface SkippedInvoice { type Phase = "idle" | "preparing" | "confirming" | "paying" | "done"; -export function BulkPayAccepted({ invoiceIds, totalUsd }: Props) { - const acceptedCount = invoiceIds.length; +export function BulkPayAccepted({ invoices, totalUsd }: Props) { + const acceptedCount = invoices.length; + const invoiceIds = useMemo(() => invoices.map((i) => i.id), [invoices]); const router = useRouter(); const [hasWallet, setHasWallet] = useState(false); + // Everything is selected by default — paying all of them is the common case, + // and the checkboxes exist to carve out exceptions rather than to make the + // normal path require 80 clicks. + const [selected, setSelected] = useState>(() => new Set(invoiceIds)); + const [showRows, setShowRows] = useState(false); const [phase, setPhase] = useState("idle"); const [payments, setPayments] = useState([]); const [skipped, setSkipped] = useState([]); @@ -84,14 +97,51 @@ export function BulkPayAccepted({ invoiceIds, totalUsd }: Props) { return () => window.removeEventListener("coinpay#initialized", detect); }, []); + // Forget ids that are no longer payable (paid elsewhere, revoked, refreshed + // away). Invoices that appear later stay unticked until the payer says so — + // never auto-select something they have not seen. Returning the same Set when + // nothing changed keeps this from re-rendering on every parent render. + useEffect(() => { + setSelected((current) => { + const known = new Set(invoiceIds); + const kept = [...current].filter((id) => known.has(id)); + return kept.length === current.size ? current : new Set(kept); + }); + }, [invoiceIds]); + + const selectedIds = useMemo( + () => invoiceIds.filter((id) => selected.has(id)), + [invoiceIds, selected] + ); + const selectedTotal = useMemo( + () => 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; + + const toggleOne = useCallback((id: string) => { + setSelected((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const toggleAll = useCallback(() => { + setSelected((current) => + current.size === invoiceIds.length ? new Set() : new Set(invoiceIds) + ); + }, [invoiceIds]); + /** - * Mint payment requests for the given invoices (all of them by default, or - * just the failures when retrying). The server re-checks ownership and - * payability, so a stale id here is skipped rather than trusted. + * Mint payment requests for the given invoices (the current selection by + * default, or just the failures when retrying). The server re-checks + * ownership and payability, so a stale id here is skipped rather than trusted. */ - const prepare = useCallback(async (ids: string[] = invoiceIds) => { + const prepare = useCallback(async (ids: string[] = selectedIds) => { if (ids.length === 0) { - setError("No accepted invoices are ready to pay."); + setError("Select at least one invoice to pay."); return; } @@ -147,7 +197,7 @@ export function BulkPayAccepted({ invoiceIds, totalUsd }: Props) { } setPhase("confirming"); - }, [invoiceIds]); + }, [selectedIds]); const pay = useCallback(async () => { const provider = window.coinpay; @@ -221,20 +271,26 @@ export function BulkPayAccepted({ invoiceIds, totalUsd }: Props) {

- Pay all accepted invoices + Pay accepted invoices

- {acceptedCount} invoice{acceptedCount === 1 ? "" : "s"} · $ - {totalUsd.toFixed(2)} — one confirmation in your CoinPay wallet. + {selectedIds.length} of {acceptedCount} selected · $ + {selectedTotal.toFixed(2)} of ${totalUsd.toFixed(2)} — one + confirmation in your CoinPay wallet.

{phase === "idle" && (
{hasWallet ? ( - ) : ( )} + {/* Which invoices go in the run. Collapsed by default so the common + "pay everything" case stays one click, but a payer settling only some + of what they owe — or testing with a handful — needs the rows. */} + {phase === "idle" && ( +
+
+ + +
+ + {showRows && ( +
    + {invoices.map((invoice) => ( +
  • + +
  • + ))} +
+ )} +
+ )} + {/* Confirmation summary — the last stop before the wallet's own approval. */} {phase === "confirming" && (
diff --git a/src/app/dashboard/invoices/page.tsx b/src/app/dashboard/invoices/page.tsx index cac5e6b7..bce1f08b 100644 --- a/src/app/dashboard/invoices/page.tsx +++ b/src/app/dashboard/invoices/page.tsx @@ -306,7 +306,13 @@ export default async function InvoicesDashboardPage({ {/* Bulk pay — only on the received side, where you're the payer. */} {tab === "received" && ( i.id)} + invoices={accepted.map((i) => ({ + id: i.id, + label: i.gig?.title + ? `${counterpartyName(i.worker)} — ${i.gig.title}` + : counterpartyName(i.worker), + amountUsd: Number(i.amount_usd || 0), + }))} totalUsd={totalAccepted} /> )}