Skip to content

feat(invoices): pay every accepted invoice in one confirmation - #504

Merged
ralyodio merged 1 commit into
masterfrom
feat/bulk-invoice-payments
Jul 24, 2026
Merged

feat(invoices): pay every accepted invoice in one confirmation#504
ralyodio merged 1 commit into
masterfrom
feat/bulk-invoice-payments

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Paying the Accepted queue one invoice at a time — create a payment request, copy the address, send, wait — is about an hour of clicking at 62 invoices. This prepares all the payment requests server-side, hands the list to the CoinPay wallet extension, and takes a single approval.

Flow

BulkPayAccepted panel (received tab)
  → POST /api/invoices/bulk-payment-request   mint/reuse payment requests
  → window.coinpay.payBatch(...)              one approval, N transactions
  → POST /api/invoices/bulk-payment-record    store the tx hashes

Recording a payment does NOT mark it paid

A payer-reported transaction hash is a claim, not a settlement — it can still be dropped or replaced, and trusting it here would let anyone flip their own invoice to paid. Confirmation stays with the CoinPay webhook and syncGigInvoicePaymentStatus, which watch the deposit address.

What the record buys us: distinguishing "broadcast, not yet confirmed" from "never sent" — the difference between waiting and paying twice.

Design notes

  • One implementation of request creation. ensureInvoicePaymentRequest is extracted from the single-invoice route so both paths produce identical requests; two implementations would eventually disagree about what "paid" means.
  • Reuse over re-mint. An unexpired quote is reused rather than minting a second deposit address for the same debt — this matters on retry, where two live addresses is how a worker gets paid twice.
  • Nothing is silently dropped. Every requested id lands in exactly one of payments or skipped, each skip carrying a reason. An invoice missing from both would look paid when it never was.
  • Partial success is normal, since each payment is its own on-chain transaction. Failures are listed by name and retried by id, so a retry cannot re-pay something that already succeeded.

Timing

CoinPay quotes hold ~15 minutes, and the wallet serializes per account (~4s/payment on EVM/Solana, ~10s on Bitcoin). 62 USDC payments land around 4 minutes — comfortably inside the window. Large single-chain Bitcoin runs can approach it, so prefer USDC/SOL rails for payables or split them. The UI tells the user to keep the approval window open, since closing it cancels whatever hasn't gone out.

Tests — 1765 pass (56 new)

Covering both endpoints (ownership, eligibility, skip accounting, provider failure isolation, reuse vs re-quote), the shared lib, and the component — including that the record call never sets paid, that a webhook-confirmed invoice is not downgraded, and that "retry the failed ones" re-prepares only the failed ids.

Full pre-commit suite (lint + type-check + tests + build) passed.

Requires the wallet extension

profullstack/coinpayportal#189 (feat/extension-bulk-payments). Without it installed, the panel shows an install link instead of the button, and every existing per-invoice flow is untouched.

I could not exercise the on-chain path in this environment, so it is covered by unit/component tests rather than a real transaction — worth a small run (2–3 invoices) before turning it loose on all 62.

Docs: docs/bulk-invoice-payments.md

🤖 Generated with Claude Code

Paying the Accepted queue one invoice at a time — create a payment
request, copy the address, send, wait — is about an hour of clicking at
62 invoices. This prepares all the payment requests server-side, hands
the list to the CoinPay wallet extension, and takes a single approval.

Flow:
  BulkPayAccepted panel (received tab)
    -> POST /api/invoices/bulk-payment-request   mint/reuse requests
    -> window.coinpay.payBatch(...)              one approval, N txs
    -> POST /api/invoices/bulk-payment-record    store tx hashes

Recording a payment deliberately does NOT mark it paid. A payer-reported
transaction hash is a claim, not a settlement — it can still be dropped
or replaced, and trusting it here would let anyone flip their own
invoice to paid. Confirmation stays with the CoinPay webhook and
syncGigInvoicePaymentStatus. What the record buys us is the ability to
tell "broadcast, not yet confirmed" from "never sent" — the difference
between waiting and paying twice.

`ensureInvoicePaymentRequest` is extracted from the single-invoice route
so both paths produce identical requests; two implementations would
eventually disagree about what "paid" means. It reuses an unexpired
quote rather than minting a second deposit address for the same debt,
which matters on retry.

Every requested id lands in exactly one of `payments` or `skipped`, each
skip carrying a reason — an invoice missing from both would look paid
when it never was.

Partial success is normal here, since each payment is its own on-chain
transaction: failures are listed by name and retried by id, so a retry
cannot re-pay something that already succeeded.

Timing note: CoinPay quotes hold ~15 minutes and the wallet serializes
per account (~4s/payment on EVM/Solana, ~10s on Bitcoin). 62 USDC
payments land around 4 minutes; large single-chain BTC runs can approach
the window, so prefer USDC/SOL rails for payables.

Tests: 1765 pass (56 new, covering both endpoints, the shared lib, and
the component including the retry-only-failures path).

Requires the CoinPay wallet extension — profullstack/coinpayportal
branch feat/extension-bulk-payments.

Docs: docs/bulk-invoice-payments.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio merged commit bbcd71b into master Jul 24, 2026
3 checks passed
@ralyodio
ralyodio deleted the feat/bulk-invoice-payments branch July 24, 2026 19:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant