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
125 changes: 118 additions & 7 deletions src/app/dashboard/invoices/BulkPayAccepted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -508,10 +515,10 @@ describe("BulkPayAccepted", () => {
render(
<BulkPayAccepted
invoices={[
{ id: "a", label: "Worker A", amountUsd: 1, currency: "usdc_sol", amountCrypto: "0.0138508" },
{ id: "a", label: "Worker A", amountUsd: 1, payeeId: "w1", payeeName: "Ada", currency: "usdc_sol", amountCrypto: "0.0138508" },
// No quote minted yet — a currency with no amount would imply a live
// price we do not have, so the row stays fiat-only.
{ id: "b", label: "Worker B", amountUsd: 2, currency: "sol", amountCrypto: null },
{ id: "b", label: "Worker B", amountUsd: 2, payeeId: "w2", payeeName: "Grace", currency: "sol", amountCrypto: null },
]}
totalUsd={3}
/>
Expand Down Expand Up @@ -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(<BulkPayAccepted invoices={MIXED} totalUsd={60} />);

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(<BulkPayAccepted invoices={MIXED} totalUsd={60} />);

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(<BulkPayAccepted invoices={MIXED} totalUsd={60} />);

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(<BulkPayAccepted invoices={MIXED} totalUsd={60} />);

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(<BulkPayAccepted invoices={MIXED} totalUsd={60} />);

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(<BulkPayAccepted invoices={MIXED} totalUsd={60} />);

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(
<BulkPayAccepted
invoices={[MIXED[0]!]}
totalUsd={10}
/>
);

expect(screen.queryByLabelText(/Filter invoices by who gets paid/)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Filter invoices by coin/)).not.toBeInTheDocument();
});
});
157 changes: 150 additions & 7 deletions src/app/dashboard/invoices/BulkPayAccepted.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand All @@ -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
Expand Down Expand Up @@ -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<string>("");
const [coinFilter, setCoinFilter] = useState<string>("");
const [phase, setPhase] = useState<Phase>("idle");
const [payments, setPayments] = useState<PreparedPayment[]>([]);
const [skipped, setSkipped] = useState<SkippedInvoice[]>([]);
Expand Down Expand Up @@ -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<string, { id: string; name: string; count: number; total: number }>();
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<string, { code: string; count: number; total: number }>();
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.
Expand All @@ -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) => {
Expand All @@ -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
Expand Down Expand Up @@ -399,6 +488,60 @@ export function BulkPayAccepted({ invoices, totalUsd }: Props) {
of what they owe — or testing with a handful — needs the rows. */}
{phase === "idle" && (
<div className="mt-3 rounded-lg border border-border bg-background">
{(payeeOptions.length > 1 || coinOptions.length > 1) && (
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
{payeeOptions.length > 1 && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Pay
<select
value={payeeFilter}
onChange={(e) => applyFilters(e.target.value, coinFilter)}
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground"
aria-label="Filter invoices by who gets paid"
>
<option value="">
everyone ({acceptedCount} · ${totalUsd.toFixed(2)})
</option>
{payeeOptions.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.count} · ${p.total.toFixed(2)})
</option>
))}
</select>
</label>
)}

{coinOptions.length > 1 && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
in
<select
value={coinFilter}
onChange={(e) => applyFilters(payeeFilter, e.target.value)}
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground"
aria-label="Filter invoices by coin"
>
<option value="">any coin</option>
{coinOptions.map((c) => (
<option key={c.code} value={c.code}>
{coinLabel(c.code)} ({c.count} · ${c.total.toFixed(2)})
</option>
))}
</select>
</label>
)}

{(payeeFilter || coinFilter) && (
<button
type="button"
onClick={() => applyFilters("", "")}
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
Clear filters
</button>
)}
</div>
)}

<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium">
<input
Expand Down Expand Up @@ -434,14 +577,14 @@ export function BulkPayAccepted({ invoices, totalUsd }: Props) {
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
aria-expanded={showRows}
>
{showRows ? "Hide invoices" : `Show ${acceptedCount} invoices`}
{showRows ? "Hide invoices" : `Show ${visibleInvoices.length} invoices`}
</button>
</div>
</div>

{showRows && (
<ul className="max-h-64 divide-y divide-border overflow-y-auto">
{orderedInvoices.map((invoice) => (
{visibleInvoices.map((invoice) => (
<li key={invoice.id}>
<label className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm hover:bg-muted/50">
<input
Expand Down
2 changes: 2 additions & 0 deletions src/app/dashboard/invoices/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ export default async function InvoicesDashboardPage({
? `${counterpartyName(i.worker)} — ${i.gig.title}`
: counterpartyName(i.worker),
amountUsd: Number(i.amount_usd || 0),
payeeId: i.worker?.id ?? "unknown",
payeeName: counterpartyName(i.worker),
currency: i.metadata?.payment_currency ?? null,
amountCrypto: i.metadata?.amount_crypto ?? null,
}))}
Expand Down
Loading