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
107 changes: 107 additions & 0 deletions docs/bulk-invoice-payments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Bulk invoice payments

Pay every accepted invoice in one confirmation, using the CoinPay wallet
browser extension.

**The problem:** the Accepted queue on `/dashboard/invoices?tab=received` needs
each invoice paid individually — create a payment request, copy the address,
send from a wallet, wait. At 62 accepted invoices that is about an hour.

**The fix:** prepare all payment requests server-side, hand the whole list to the
extension, approve once, and let it broadcast while the dashboard streams
progress.

## Flow

1. **`BulkPayAccepted`** (client component on the invoices page) detects
`window.coinpay`. Without the extension it shows an install link instead.
2. **`POST /api/invoices/bulk-payment-request`** — takes the accepted invoice
ids and mints (or reuses) a live CoinPay payment request for each. Returns
`payments` shaped for `payBatch`, plus a `skipped` list explaining every
invoice that could not be prepared.
3. The user reviews the summary, then **`window.coinpay.payBatch(payments)`**
opens the wallet's approval window — one approval for the whole list.
4. The extension signs and broadcasts each payment, streaming progress back.
5. **`POST /api/invoices/bulk-payment-record`** stores the resulting transaction
hashes (or errors) on each invoice.

## What marks an invoice paid

**Not this feature.** A broadcast transaction is a claim, not a settlement — it
can still be dropped or replaced. The record endpoint writes
`metadata.payer_tx_hash` and sets `coinpay_status: "broadcast"`, and nothing
more.

Invoices flip to `paid` only through the existing CoinPay webhook and
`syncGigInvoicePaymentStatus`, which watch the deposit address. Trusting the
payer's self-reported hash here would let anyone flip their own invoice to paid.

The practical benefit of recording anyway: a payment that broadcast but has not
confirmed becomes distinguishable from one that was never sent — the difference
between "wait" and "pay again".

## Eligibility

`bulk-payment-request` only prepares an invoice when all of these hold, and
reports a reason for every one it skips:

| Requirement | Skip reason |
|---|---|
| Caller is `poster_id` | `Not found, or you are not the payer` |
| Status is `sent` or `expired` | `Invoice is <status>` / `Already paid` |
| `metadata.accepted_at` is set | `Not accepted yet` |
| Worker has a CoinPay receiving wallet | `…missing the worker's CoinPay receiving wallet` |
| CoinPay quotes a crypto amount | `CoinPay did not quote a crypto amount` |

Every requested id lands in exactly one of `payments` or `skipped` — an invoice
missing from both would silently look paid.

Accepting stays a deliberate per-invoice decision; bulk pay is the *Accepted*
queue's action, not a way to skip review.

## Idempotency

`ensureInvoicePaymentRequest` reuses an existing request while its quote is
unexpired, and re-quotes otherwise. This matters on retry: without reuse, a
second run would mint a second deposit address for the same debt, leaving two
live addresses one worker could be paid at twice.

Shared with the single-invoice route (`/api/gigs/[id]/invoice/[invoiceId]/
payment-request`) so both paths produce byte-identical requests — two
implementations here would eventually disagree about what "paid" means.

## Partial success

Each payment is a separate on-chain transaction, so some can fail while others
succeed. That is normal, not an error state:

- `payBatch` resolves with per-item `status`; it is not all-or-nothing.
- Failures are listed by name with their reason and a **Retry the N that failed**
button, which re-prepares only those ids.
- Results are recorded even when the batch partly failed.

## Timing

Payment-request quotes hold ~15 minutes, and the wallet serializes payments per
account (see `docs/BULK_PAYMENTS.md` in the coinpayportal repo for why). Roughly
4s per payment on EVM/Solana, ~10s on Bitcoin.

62 USDC payments ≈ 4 minutes — comfortably inside the window. A large
single-chain **Bitcoin** run can approach it; prefer USDC/SOL rails for payables,
or split very large BTC runs.

The UI tells the user to keep the approval window open: closing it cancels the
payments that have not gone out yet.

## Files

| Path | Role |
|---|---|
| `src/lib/invoices/payment-request.ts` | Shared request creation + reuse |
| `src/app/api/invoices/bulk-payment-request/route.ts` | Prepare many at once |
| `src/app/api/invoices/bulk-payment-record/route.ts` | Record broadcast hashes |
| `src/app/dashboard/invoices/BulkPayAccepted.tsx` | Panel, confirm, live progress |
| `src/types/coinpay-extension.d.ts` | `window.coinpay` typings |

The wallet side lives in `profullstack/coinpayportal` under
`packages/extension` — see `docs/BULK_PAYMENTS.md` there.
170 changes: 12 additions & 158 deletions src/app/api/gigs/[id]/invoice/[invoiceId]/payment-request/route.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { getAuthContext } from "@/lib/auth/get-user";
import { createPayment, preferredCoinToPaymentCurrency } from "@/lib/coinpayportal";
import { createServiceClient } from "@/lib/supabase/service";
import {
PAYABLE_INVOICE_STATUSES,
ensureInvoicePaymentRequest,
metadataObject,
} from "@/lib/invoices/payment-request";

export const dynamic = "force-dynamic";

const PAYMENT_REQUEST_SECONDS = 15 * 60;
const PAYABLE_INVOICE_STATUSES = new Set(["sent", "expired"]);
type InvoiceContext =
| { response: NextResponse }
| {
gigId: string;
invoice: any;
};

function metadataObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}

function activeExpiresAt(metadata: Record<string, unknown>): Date | null {
const expiresAt = typeof metadata.expires_at === "string" ? metadata.expires_at : null;
if (!expiresAt) return null;
const date = new Date(expiresAt);
if (Number.isNaN(date.getTime()) || date <= new Date()) return null;
return date;
}

async function loadInvoiceContext(
request: NextRequest,
{ params }: { params: Promise<{ id: string; invoiceId: string }> }
Expand Down Expand Up @@ -118,150 +107,15 @@ export async function POST(
const context = await loadInvoiceContext(request, { params });
if ("response" in context) return context.response;

const { gigId, invoice } = context;

const metadata = metadataObject(invoice.metadata);
if (invoice.coinpay_invoice_id && metadata.payment_address && activeExpiresAt(metadata)) {
return NextResponse.json({
data: {
invoice_id: invoice.id,
coinpay_invoice_id: invoice.coinpay_invoice_id,
pay_url: invoice.pay_url || null,
payment_address: metadata.payment_address || null,
amount_crypto: metadata.amount_crypto || null,
payment_currency: metadata.payment_currency || null,
expires_at: metadata.expires_at || null,
metadata,
},
});
}

const gig = Array.isArray(invoice.gig) ? invoice.gig[0] : invoice.gig;
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net";
const businessId = process.env.COINPAY_MERCHANT_ID;
const selectedCurrency = preferredCoinToPaymentCurrency(
typeof metadata.receiver_payment_currency === "string"
? metadata.receiver_payment_currency
: typeof metadata.payment_currency === "string" && !metadata.payment_address
? metadata.payment_currency
: null
);
const selectedAddress =
typeof metadata.merchant_wallet_address === "string"
? metadata.merchant_wallet_address.trim()
: "";

if (!selectedCurrency || !selectedAddress) {
return NextResponse.json(
{ error: "This invoice is missing the worker's CoinPay receiving wallet" },
{ status: 400 }
);
const result = await ensureInvoicePaymentRequest(context.invoice);
if (!result.ok) {
// A missing receiving wallet is the payer's cue to ask for a fresh
// invoice, so it stays a 400 rather than a provider-side 502.
const status = result.code === "NO_WALLET" ? 400 : result.code === "PERSIST" ? 500 : 502;
return NextResponse.json({ error: result.error }, { status });
}
const paymentCurrency = selectedCurrency;
const merchantWalletLabel =
typeof metadata.merchant_wallet_label === "string" ? metadata.merchant_wallet_label : null;

const paymentResult = await createPayment({
amount_usd: Number(invoice.amount_usd),
currency: paymentCurrency,
description: invoice.notes || `Invoice for gig: ${gig?.title || invoice.gig_id}`,
business_id: businessId,
merchant_wallet_address: selectedAddress,
redirect_url: `${appUrl}/dashboard/invoices?tab=received`,
expires_in: PAYMENT_REQUEST_SECONDS,
metadata: {
type: "gig_invoice",
gig_id: gigId,
invoice_id: invoice.id,
application_id: invoice.application_id,
worker_id: invoice.worker_id,
poster_id: invoice.poster_id,
invoice_currency: invoice.currency,
payment_currency: paymentCurrency,
merchant_wallet_address: selectedAddress,
merchant_wallet_label: merchantWalletLabel,
platform: "ugig.net",
},
});

const cpPayment = paymentResult.payment || paymentResult;
const paymentId = paymentResult.payment_id || (cpPayment as any).id;
const paymentAddress = (cpPayment as any).payment_address || paymentResult.address || null;
const checkoutUrl = paymentResult.checkout_url || (cpPayment as any).checkout_url || null;
const amountCrypto =
paymentResult.amount_crypto ||
(cpPayment as any).amount_crypto ||
(cpPayment as any).crypto_amount ||
null;
const expiresAt =
paymentResult.expires_at ||
(cpPayment as any).expires_at ||
new Date(Date.now() + PAYMENT_REQUEST_SECONDS * 1000).toISOString();
const responseCurrency =
paymentResult.currency || (cpPayment as any).currency || paymentCurrency;

if (!paymentId) {
return NextResponse.json({ error: "CoinPay did not return a payment id" }, { status: 502 });
}
if (!paymentAddress) {
return NextResponse.json(
{ error: "CoinPay did not return an in-app payment address" },
{ status: 502 }
);
}

const previousPaymentIds = Array.isArray(metadata.previous_coinpay_invoice_ids)
? metadata.previous_coinpay_invoice_ids
: [];
const nextMetadata = {
...metadata,
previous_coinpay_invoice_ids: invoice.coinpay_invoice_id
? [...previousPaymentIds, invoice.coinpay_invoice_id]
: previousPaymentIds,
payment_address: paymentAddress,
amount_crypto: amountCrypto,
payment_currency: responseCurrency,
receiver_payment_currency: paymentCurrency,
merchant_wallet_address: selectedAddress,
merchant_wallet_label: merchantWalletLabel,
checkout_url: checkoutUrl,
expires_at: expiresAt,
payment_request_created_at: new Date().toISOString(),
coinpay_status: "pending",
};

const serviceSupabase = createServiceClient();
const { data: updated, error: updateError } = await (
(serviceSupabase as any).from("gig_invoices") as any
)
.update({
status: "sent",
coinpay_invoice_id: paymentId,
pay_url: null,
metadata: nextMetadata,
updated_at: new Date().toISOString(),
})
.eq("id", invoice.id)
.select()
.single();

if (updateError) {
console.error("[invoice payment request] update failed:", updateError);
return NextResponse.json({ error: "Failed to save payment request" }, { status: 500 });
}

return NextResponse.json({
data: {
invoice_id: updated.id,
coinpay_invoice_id: paymentId,
pay_url: null,
payment_address: paymentAddress,
amount_crypto: amountCrypto,
payment_currency: responseCurrency,
expires_at: expiresAt,
metadata: updated.metadata,
},
});
return NextResponse.json({ data: result.data });
} catch (err) {
console.error("[invoice payment request] failed:", err);
return NextResponse.json(
Expand Down
Loading
Loading