diff --git a/docs/bulk-invoice-payments.md b/docs/bulk-invoice-payments.md new file mode 100644 index 00000000..aef113c0 --- /dev/null +++ b/docs/bulk-invoice-payments.md @@ -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 ` / `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. diff --git a/src/app/api/gigs/[id]/invoice/[invoiceId]/payment-request/route.ts b/src/app/api/gigs/[id]/invoice/[invoiceId]/payment-request/route.ts index 8781ee73..625e666e 100644 --- a/src/app/api/gigs/[id]/invoice/[invoiceId]/payment-request/route.ts +++ b/src/app/api/gigs/[id]/invoice/[invoiceId]/payment-request/route.ts @@ -1,12 +1,13 @@ 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 } | { @@ -14,18 +15,6 @@ type InvoiceContext = invoice: any; }; -function metadataObject(value: unknown): Record { - return value && typeof value === "object" ? (value as Record) : {}; -} - -function activeExpiresAt(metadata: Record): 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 }> } @@ -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( diff --git a/src/app/api/invoices/bulk-payment-record/route.test.ts b/src/app/api/invoices/bulk-payment-record/route.test.ts new file mode 100644 index 00000000..89681554 --- /dev/null +++ b/src/app/api/invoices/bulk-payment-record/route.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: vi.fn(), +})); + +vi.mock("@/lib/supabase/service", () => ({ + createServiceClient: vi.fn(), +})); + +import { POST } from "./route"; +import { getAuthContext } from "@/lib/auth/get-user"; +import { createServiceClient } from "@/lib/supabase/service"; + +const POSTER_ID = "4f16c625-c37a-4654-82db-e391067cbb13"; +const ID_A = "f53e4a56-3cf7-42f9-9a33-bc1cb770c4f6"; +const ID_B = "a1b2c3d4-1111-4222-8333-444455556666"; + +function request(body: unknown) { + return { json: async () => body } as any; +} + +function selectQuery(rows: any[]) { + const query: any = { + select: vi.fn(() => query), + in: vi.fn(() => query), + eq: vi.fn(() => Promise.resolve({ data: rows, error: null })), + }; + return query; +} + +/** Captures every metadata write so tests can assert on what was persisted. */ +function serviceClient(updates: Record) { + return { + from: vi.fn(() => ({ + update: vi.fn((row: any) => ({ + eq: vi.fn((_col: string, id: string) => { + updates[id] = row; + return Promise.resolve({ error: null }); + }), + })), + })), + }; +} + +function mockAuth(rows: any[], updates: Record = {}) { + (getAuthContext as any).mockResolvedValue({ + user: { id: POSTER_ID }, + supabase: { from: vi.fn(() => selectQuery(rows)) }, + }); + (createServiceClient as any).mockReturnValue(serviceClient(updates)); + return updates; +} + +function invoiceRow(id: string, metadata: Record = {}) { + return { id, status: "sent", metadata }; +} + +describe("POST /api/invoices/bulk-payment-record", () => { + beforeEach(() => vi.clearAllMocks()); + + it("records the broadcast hash for a sent payment", async () => { + const updates = mockAuth([invoiceRow(ID_A)]); + + const res = await POST( + request({ + results: [ + { + invoice_id: ID_A, + status: "sent", + tx_hash: "0xabc123", + explorer_url: "https://etherscan.io/tx/0xabc123", + }, + ], + }) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toMatchObject({ recorded: 1, sent: 1, failed: 0 }); + expect(updates[ID_A].metadata).toMatchObject({ + payer_tx_hash: "0xabc123", + payer_tx_explorer_url: "https://etherscan.io/tx/0xabc123", + coinpay_status: "broadcast", + }); + }); + + it("never marks an invoice paid — confirmation stays with the webhook", async () => { + const updates = mockAuth([invoiceRow(ID_A)]); + + await POST( + request({ results: [{ invoice_id: ID_A, status: "sent", tx_hash: "0xabc" }] }) + ); + + // A payer-supplied hash is a claim, not a settlement. + expect(updates[ID_A]).not.toHaveProperty("status"); + expect(updates[ID_A].metadata.coinpay_status).not.toBe("paid"); + }); + + it("does not downgrade an invoice the webhook already confirmed", async () => { + const updates = mockAuth([invoiceRow(ID_A, { coinpay_status: "paid" })]); + + await POST( + request({ results: [{ invoice_id: ID_A, status: "sent", tx_hash: "0xabc" }] }) + ); + + expect(updates[ID_A].metadata.coinpay_status).toBe("paid"); + }); + + it("records the reason a payment was not sent", async () => { + const updates = mockAuth([invoiceRow(ID_A)]); + + const res = await POST( + request({ + results: [{ invoice_id: ID_A, status: "failed", error: "Insufficient funds" }], + }) + ); + + const body = await res.json(); + expect(body.data).toMatchObject({ sent: 0, failed: 1 }); + expect(updates[ID_A].metadata).toMatchObject({ + last_bulk_payment_error: "Insufficient funds", + }); + expect(updates[ID_A].metadata).not.toHaveProperty("payer_tx_hash"); + }); + + it("appends to the attempt history rather than overwriting it", async () => { + const updates = mockAuth([ + invoiceRow(ID_A, { + bulk_payment_attempts: [{ status: "failed", error: "nonce too low" }], + }), + ]); + + await POST( + request({ results: [{ invoice_id: ID_A, status: "sent", tx_hash: "0xdef" }] }) + ); + + const attempts = updates[ID_A].metadata.bulk_payment_attempts; + // Earlier hashes are what explain a duplicate on-chain payment later. + expect(attempts).toHaveLength(2); + expect(attempts[0]).toMatchObject({ status: "failed" }); + expect(attempts[1]).toMatchObject({ status: "sent", tx_hash: "0xdef" }); + }); + + it("reports invoices the caller does not own as unknown, writing nothing", async () => { + const updates = mockAuth([invoiceRow(ID_A)]); + + const res = await POST( + request({ + results: [ + { invoice_id: ID_A, status: "sent", tx_hash: "0x1" }, + { invoice_id: ID_B, status: "sent", tx_hash: "0x2" }, + ], + }) + ); + + const body = await res.json(); + expect(body.data.recorded).toBe(1); + expect(body.data.unknown).toEqual([ID_B]); + expect(updates[ID_B]).toBeUndefined(); + }); + + it("requires authentication", async () => { + (getAuthContext as any).mockResolvedValue(null); + + const res = await POST( + request({ results: [{ invoice_id: ID_A, status: "sent", tx_hash: "0x1" }] }) + ); + + expect(res.status).toBe(401); + }); + + it("rejects malformed results", async () => { + mockAuth([]); + + expect((await POST(request({ results: [] }))).status).toBe(400); + expect( + (await POST(request({ results: [{ invoice_id: ID_A, status: "bogus" }] }))).status + ).toBe(400); + expect( + (await POST(request({ results: [{ invoice_id: "nope", status: "sent" }] }))).status + ).toBe(400); + }); +}); diff --git a/src/app/api/invoices/bulk-payment-record/route.ts b/src/app/api/invoices/bulk-payment-record/route.ts new file mode 100644 index 00000000..72c920b6 --- /dev/null +++ b/src/app/api/invoices/bulk-payment-record/route.ts @@ -0,0 +1,146 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getAuthContext } from "@/lib/auth/get-user"; +import { createServiceClient } from "@/lib/supabase/service"; +import { metadataObject } from "@/lib/invoices/payment-request"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/invoices/bulk-payment-record + * + * Record what the wallet extension actually broadcast for each invoice in a + * bulk run: the transaction hash, or the error if it never went out. + * + * This deliberately does NOT mark anything paid. A broadcast transaction is a + * claim, not a settlement — it can still be dropped or replaced. Confirmation + * stays with the CoinPay webhook and `syncGigInvoicePaymentStatus`, which watch + * the deposit address. Trusting the payer's own report here would let a + * self-reported hash flip an invoice to paid. + * + * What it buys us: an audit trail, so a payment that broadcast but hasn't + * confirmed is distinguishable from one that was never sent — the difference + * between "wait" and "pay again". + */ + +const MAX_RESULTS = 100; + +const resultSchema = z.object({ + invoice_id: z.string().uuid(), + status: z.enum(["sent", "failed", "skipped"]), + tx_hash: z.string().min(1).max(200).optional(), + explorer_url: z.string().url().max(500).optional(), + error: z.string().max(500).optional(), +}); + +const bulkRecordSchema = z.object({ + results: z.array(resultSchema).min(1).max(MAX_RESULTS), +}); + +export async function POST(request: NextRequest): Promise { + try { + const auth = await getAuthContext(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { user, supabase } = auth; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = bulkRecordSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]!.message }, { status: 400 }); + } + + const results = parsed.data.results; + const invoiceIds = [...new Set(results.map((r) => r.invoice_id))]; + + const { data: invoiceData, error } = await (supabase as any) + .from("gig_invoices") + .select("id, status, metadata") + .in("id", invoiceIds) + .eq("poster_id", user.id); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + const invoices = new Map((invoiceData || []).map((inv: any) => [inv.id, inv])); + const serviceSupabase = createServiceClient() as any; + + let recorded = 0; + const unknown: string[] = []; + const recordedAt = new Date().toISOString(); + + for (const result of results) { + const invoice = invoices.get(result.invoice_id) as any; + if (!invoice) { + unknown.push(result.invoice_id); + continue; + } + + const metadata = metadataObject(invoice.metadata); + // Keep a full history: a retried invoice has more than one broadcast, and + // the earlier hashes are what explain a duplicate on-chain payment. + const attempts = Array.isArray(metadata.bulk_payment_attempts) + ? metadata.bulk_payment_attempts + : []; + + const nextMetadata = { + ...metadata, + bulk_payment_attempts: [ + ...attempts, + { + status: result.status, + tx_hash: result.tx_hash ?? null, + explorer_url: result.explorer_url ?? null, + error: result.error ?? null, + recorded_at: recordedAt, + }, + ], + ...(result.status === "sent" + ? { + payer_tx_hash: result.tx_hash ?? null, + payer_tx_explorer_url: result.explorer_url ?? null, + payer_tx_broadcast_at: recordedAt, + // Surfaced in the UI as "sent, awaiting confirmation" — the + // webhook still owns the transition to `paid`. + coinpay_status: metadata.coinpay_status === "paid" ? "paid" : "broadcast", + } + : { + last_bulk_payment_error: result.error ?? "Payment was not sent", + }), + }; + + const { error: updateError } = await (serviceSupabase.from("gig_invoices") as any) + .update({ metadata: nextMetadata, updated_at: recordedAt }) + .eq("id", result.invoice_id); + + if (updateError) { + console.error("[bulk payment record] update failed:", result.invoice_id, updateError); + continue; + } + recorded++; + } + + return NextResponse.json({ + data: { + recorded, + unknown, + sent: results.filter((r) => r.status === "sent").length, + failed: results.filter((r) => r.status !== "sent").length, + }, + }); + } catch (err) { + console.error("[bulk payment record] failed:", err); + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to record payments" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/invoices/bulk-payment-request/route.test.ts b/src/app/api/invoices/bulk-payment-request/route.test.ts new file mode 100644 index 00000000..35292ffe --- /dev/null +++ b/src/app/api/invoices/bulk-payment-request/route.test.ts @@ -0,0 +1,330 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/coinpayportal", () => ({ + createPayment: vi.fn(), + preferredCoinToPaymentCurrency: vi.fn((value: string | null) => value?.toLowerCase() || null), +})); + +vi.mock("@/lib/auth/get-user", () => ({ + getAuthContext: vi.fn(), +})); + +vi.mock("@/lib/supabase/service", () => ({ + createServiceClient: vi.fn(), +})); + +import { POST } from "./route"; +import { createPayment } from "@/lib/coinpayportal"; +import { getAuthContext } from "@/lib/auth/get-user"; +import { createServiceClient } from "@/lib/supabase/service"; + +const POSTER_ID = "4f16c625-c37a-4654-82db-e391067cbb13"; +const WORKER_ID = "666cbaba-c6ea-4756-ad44-d6a5b4248f8f"; +const GIG_ID = "8489a861-0999-4107-afca-2592021ac338"; +const WALLET = "So11111111111111111111111111111111111111112"; + +const ID_A = "f53e4a56-3cf7-42f9-9a33-bc1cb770c4f6"; +const ID_B = "a1b2c3d4-1111-4222-8333-444455556666"; +const ID_C = "c0ffee00-2222-4333-8444-555566667777"; + +function request(body: unknown) { + return { json: async () => body } as any; +} + +/** Supabase select(...).in(...).eq(...) resolving to the given rows. */ +function selectQuery(rows: any[]) { + const query: any = { + select: vi.fn(() => query), + in: vi.fn(() => query), + eq: vi.fn(() => Promise.resolve({ data: rows, error: null })), + }; + return query; +} + +function serviceClient() { + return { + from: vi.fn(() => ({ + update: vi.fn(() => ({ + eq: vi.fn(() => ({ + select: vi.fn(() => ({ + // Echo back whatever id was written; the route only reads `id` and + // `metadata` off this row. + single: vi.fn(async () => ({ + data: { id: lastUpdatedId, metadata: lastUpdatedMetadata }, + error: null, + })), + })), + })), + })), + })), + }; +} + +let lastUpdatedId = ID_A; +let lastUpdatedMetadata: Record = {}; + +function invoice(id: string, overrides: Record = {}) { + return { + id, + gig_id: GIG_ID, + application_id: "d2317730-c56a-49e9-a6e4-dc469b7605f7", + worker_id: WORKER_ID, + poster_id: POSTER_ID, + amount_usd: 100, + currency: "USD", + status: "sent", + coinpay_invoice_id: null, + pay_url: null, + notes: null, + metadata: { + accepted_at: "2026-07-01T00:00:00Z", + receiver_payment_currency: "sol", + merchant_wallet_address: WALLET, + }, + gig: { id: GIG_ID, title: "Build thing", payment_coin: "SOL" }, + worker: { id: WORKER_ID, username: "ada", full_name: "Ada Lovelace" }, + ...overrides, + }; +} + +function mockAuth(rows: any[]) { + (getAuthContext as any).mockResolvedValue({ + user: { id: POSTER_ID }, + supabase: { from: vi.fn(() => selectQuery(rows)) }, + }); + (createServiceClient as any).mockReturnValue(serviceClient()); +} + +function mockPaymentCreation() { + let counter = 0; + (createPayment as any).mockImplementation(async (opts: any) => { + counter++; + lastUpdatedId = opts.metadata.invoice_id; + lastUpdatedMetadata = { + payment_address: `addr-${counter}`, + amount_crypto: "1.25", + payment_currency: "sol", + expires_at: "2030-01-01T00:00:00Z", + }; + return { + payment_id: `cp-${counter}`, + address: `addr-${counter}`, + amount_crypto: "1.25", + currency: "sol", + expires_at: "2030-01-01T00:00:00Z", + payment: { id: `cp-${counter}` }, + }; + }); +} + +describe("POST /api/invoices/bulk-payment-request", () => { + beforeEach(() => { + vi.clearAllMocks(); + lastUpdatedMetadata = {}; + }); + + it("prepares a payment for every accepted invoice", async () => { + mockAuth([invoice(ID_A), invoice(ID_B)]); + mockPaymentCreation(); + + const res = await POST(request({ invoice_ids: [ID_A, ID_B] })); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.payments).toHaveLength(2); + expect(body.data.skipped).toHaveLength(0); + expect(body.data.total_usd).toBe(200); + expect(createPayment).toHaveBeenCalledTimes(2); + }); + + it("shapes each payment for the wallet's payBatch call", async () => { + mockAuth([invoice(ID_A)]); + mockPaymentCreation(); + + const res = await POST(request({ invoice_ids: [ID_A] })); + const body = await res.json(); + + expect(body.data.payments[0]).toMatchObject({ + id: ID_A, // correlation id must be the invoice id + chain: "sol", + to: "addr-1", + amount: "1.25", + amountUsd: 100, + label: "Ada Lovelace — Build thing", + }); + }); + + it("skips invoices that were never accepted", async () => { + mockAuth([invoice(ID_A, { metadata: { receiver_payment_currency: "sol" } })]); + mockPaymentCreation(); + + const res = await POST(request({ invoice_ids: [ID_A] })); + const body = await res.json(); + + expect(body.data.payments).toHaveLength(0); + expect(body.data.skipped).toEqual([{ id: ID_A, reason: "Not accepted yet" }]); + expect(createPayment).not.toHaveBeenCalled(); + }); + + it("skips already-paid invoices instead of charging twice", async () => { + mockAuth([invoice(ID_A, { status: "paid" })]); + mockPaymentCreation(); + + const res = await POST(request({ invoice_ids: [ID_A] })); + const body = await res.json(); + + expect(body.data.payments).toHaveLength(0); + expect(body.data.skipped).toEqual([{ id: ID_A, reason: "Already paid" }]); + expect(createPayment).not.toHaveBeenCalled(); + }); + + it("skips invoices the caller does not own, and never leaks their existence", async () => { + // The query filters on poster_id, so someone else's invoice comes back empty. + mockAuth([invoice(ID_A)]); + mockPaymentCreation(); + + const res = await POST(request({ invoice_ids: [ID_A, ID_B] })); + const body = await res.json(); + + expect(body.data.payments.map((p: any) => p.id)).toEqual([ID_A]); + expect(body.data.skipped).toEqual([ + { id: ID_B, reason: "Not found, or you are not the payer" }, + ]); + }); + + it("accounts for every requested invoice in exactly one bucket", async () => { + mockAuth([invoice(ID_A), invoice(ID_B, { status: "paid" })]); + mockPaymentCreation(); + + const res = await POST(request({ invoice_ids: [ID_A, ID_B, ID_C] })); + const body = await res.json(); + + const seen = [ + ...body.data.payments.map((p: any) => p.id), + ...body.data.skipped.map((s: any) => s.id), + ]; + // An invoice missing from both lists would silently look paid. + expect(seen.sort()).toEqual([ID_A, ID_B, ID_C].sort()); + }); + + it("reports a provider failure as a skip rather than failing the whole batch", async () => { + mockAuth([invoice(ID_A), invoice(ID_B)]); + let calls = 0; + (createPayment as any).mockImplementation(async (opts: any) => { + calls++; + if (opts.metadata.invoice_id === ID_A) throw new Error("CoinPay is down"); + lastUpdatedId = opts.metadata.invoice_id; + lastUpdatedMetadata = {}; + return { + payment_id: `cp-${calls}`, + address: `addr-${calls}`, + amount_crypto: "1.25", + currency: "sol", + expires_at: "2030-01-01T00:00:00Z", + payment: { id: `cp-${calls}` }, + }; + }); + + const res = await POST(request({ invoice_ids: [ID_A, ID_B] })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.payments.map((p: any) => p.id)).toEqual([ID_B]); + expect(body.data.skipped[0]).toMatchObject({ id: ID_A, reason: "CoinPay is down" }); + }); + + it("skips an invoice with no quoted crypto amount", async () => { + mockAuth([invoice(ID_A)]); + (createPayment as any).mockResolvedValue({ + payment_id: "cp-1", + address: "addr-1", + amount_crypto: null, + currency: "sol", + expires_at: "2030-01-01T00:00:00Z", + payment: { id: "cp-1" }, + }); + + const res = await POST(request({ invoice_ids: [ID_A] })); + const body = await res.json(); + + expect(body.data.payments).toHaveLength(0); + expect(body.data.skipped[0].reason).toMatch(/did not quote a crypto amount/); + }); + + it("reuses an unexpired request instead of minting a second one", async () => { + mockAuth([ + invoice(ID_A, { + coinpay_invoice_id: "cp-existing", + metadata: { + accepted_at: "2026-07-01T00:00:00Z", + receiver_payment_currency: "sol", + merchant_wallet_address: WALLET, + payment_address: "addr-existing", + amount_crypto: "2.5", + payment_currency: "sol", + expires_at: "2030-01-01T00:00:00Z", + }, + }), + ]); + mockPaymentCreation(); + + const res = await POST(request({ invoice_ids: [ID_A] })); + const body = await res.json(); + + // Two live deposit addresses for one debt is how a worker gets paid twice. + expect(createPayment).not.toHaveBeenCalled(); + expect(body.data.payments[0]).toMatchObject({ to: "addr-existing", reused: true }); + }); + + it("re-quotes when the existing request has expired", async () => { + mockAuth([ + invoice(ID_A, { + coinpay_invoice_id: "cp-old", + metadata: { + accepted_at: "2026-07-01T00:00:00Z", + receiver_payment_currency: "sol", + merchant_wallet_address: WALLET, + payment_address: "addr-stale", + amount_crypto: "2.5", + payment_currency: "sol", + expires_at: "2020-01-01T00:00:00Z", + }, + }), + ]); + mockPaymentCreation(); + + const res = await POST(request({ invoice_ids: [ID_A] })); + const body = await res.json(); + + expect(createPayment).toHaveBeenCalledTimes(1); + expect(body.data.payments[0]).toMatchObject({ to: "addr-1", reused: false }); + }); + + it("requires authentication", async () => { + (getAuthContext as any).mockResolvedValue(null); + + const res = await POST(request({ invoice_ids: [ID_A] })); + + expect(res.status).toBe(401); + expect(createPayment).not.toHaveBeenCalled(); + }); + + it("rejects an empty or oversized batch", async () => { + mockAuth([]); + + expect((await POST(request({ invoice_ids: [] }))).status).toBe(400); + + const tooMany = Array.from({ length: 101 }, () => ID_A); + expect((await POST(request({ invoice_ids: tooMany }))).status).toBe(400); + expect(createPayment).not.toHaveBeenCalled(); + }); + + it("rejects a malformed body", async () => { + mockAuth([]); + + expect((await POST(request({ invoice_ids: ["not-a-uuid"] }))).status).toBe(400); + expect( + (await POST({ json: async () => { throw new Error("bad"); } } as any)).status + ).toBe(400); + }); +}); diff --git a/src/app/api/invoices/bulk-payment-request/route.ts b/src/app/api/invoices/bulk-payment-request/route.ts new file mode 100644 index 00000000..32763be4 --- /dev/null +++ b/src/app/api/invoices/bulk-payment-request/route.ts @@ -0,0 +1,222 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getAuthContext } from "@/lib/auth/get-user"; +import { + PAYABLE_INVOICE_STATUSES, + ensureInvoicePaymentRequest, + metadataObject, +} from "@/lib/invoices/payment-request"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/invoices/bulk-payment-request + * + * Mint (or reuse) a live CoinPay payment request for many accepted invoices at + * once, so the wallet extension can pay them all behind a single approval. + * + * Paying 62 invoices one at a time is roughly an hour of clicking; this is the + * server half of collapsing that into one confirmation. + * + * Returns a `payments` array shaped for `window.coinpay.payBatch()` plus a + * `skipped` array explaining every invoice that could NOT be prepared. Nothing + * is silently dropped — an invoice missing from both arrays would look paid + * when it never was. + */ + +// Each entry costs a CoinPay API round-trip, and every minted request starts a +// 15-minute expiry clock that must outlast the on-chain sending that follows. +const MAX_INVOICES = 100; + +// Enough concurrency to prepare 62 requests in seconds, low enough to stay +// friendly to the payment provider's rate limits. +const CONCURRENCY = 5; + +const bulkPaymentRequestSchema = z.object({ + invoice_ids: z.array(z.string().uuid()).min(1).max(MAX_INVOICES), +}); + +export interface BulkPaymentItem { + /** The invoice id — echoed back by the wallet as the result correlation id. */ + id: string; + gig_id: string; + /** CoinPay currency code (e.g. `usdc_pol`); the wallet maps it to a chain. */ + chain: string; + /** CoinPay-issued deposit address for this specific invoice. */ + to: string; + /** Crypto amount, quoted at the market rate when the request was created. */ + amount: string; + label: string; + amountUsd: number; + expires_at: string; + /** True when an unexpired request already existed and was reused. */ + reused: boolean; +} + +export interface BulkPaymentSkip { + id: string; + reason: string; +} + +/** Run tasks with a bounded number in flight, preserving input order. */ +async function mapWithConcurrency( + items: T[], + limit: number, + task: (item: T) => Promise +): Promise { + const results = new Array(items.length); + let cursor = 0; + + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (cursor < items.length) { + const index = cursor++; + results[index] = await task(items[index]!); + } + }); + + await Promise.all(workers); + return results; +} + +function counterpartyLabel(invoice: any): string { + const worker = Array.isArray(invoice.worker) ? invoice.worker[0] : invoice.worker; + const gig = Array.isArray(invoice.gig) ? invoice.gig[0] : invoice.gig; + const name = worker?.full_name || worker?.username || "Unknown worker"; + return gig?.title ? `${name} — ${gig.title}` : name; +} + +export async function POST(request: NextRequest): Promise { + try { + const auth = await getAuthContext(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { user, supabase } = auth; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = bulkPaymentRequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.issues[0]!.message }, { status: 400 }); + } + + const invoiceIds = [...new Set(parsed.data.invoice_ids)]; + + const { data: invoiceData, error } = await (supabase as any) + .from("gig_invoices") + .select( + ` + id, + gig_id, + application_id, + worker_id, + poster_id, + amount_usd, + currency, + status, + coinpay_invoice_id, + pay_url, + notes, + metadata, + gig:gigs(id, title, payment_coin), + worker:profiles!worker_id (id, username, full_name) + ` + ) + .in("id", invoiceIds) + // Only the payer may create payment requests, enforced in the query so a + // forged id can never reach the provider call below. + .eq("poster_id", user.id); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + const invoices = (invoiceData || []) as any[]; + const found = new Map(invoices.map((invoice) => [invoice.id, invoice])); + + const skipped: BulkPaymentSkip[] = []; + const payable: any[] = []; + + for (const id of invoiceIds) { + const invoice = found.get(id); + if (!invoice) { + skipped.push({ id, reason: "Not found, or you are not the payer" }); + continue; + } + if (invoice.status === "paid") { + skipped.push({ id, reason: "Already paid" }); + continue; + } + if (!PAYABLE_INVOICE_STATUSES.has(invoice.status)) { + skipped.push({ id, reason: `Invoice is ${invoice.status}` }); + continue; + } + if (!metadataObject(invoice.metadata).accepted_at) { + // Bulk pay is the "Accepted" queue's action; accepting stays a + // deliberate, per-invoice decision. + skipped.push({ id, reason: "Not accepted yet" }); + continue; + } + payable.push(invoice); + } + + const prepared = await mapWithConcurrency(payable, CONCURRENCY, async (invoice) => { + try { + const result = await ensureInvoicePaymentRequest(invoice); + if (!result.ok) return { invoice, error: result.error }; + return { invoice, data: result.data, reused: result.reused }; + } catch (err) { + return { + invoice, + error: err instanceof Error ? err.message : "Failed to create payment request", + }; + } + }); + + const payments: BulkPaymentItem[] = []; + for (const entry of prepared) { + if (!("data" in entry) || !entry.data) { + skipped.push({ id: entry.invoice.id, reason: entry.error || "Could not be prepared" }); + continue; + } + + const amount = entry.data.amount_crypto; + if (amount === null || amount === undefined || !(Number(amount) > 0)) { + // Without a quoted crypto amount there is nothing safe to send. + skipped.push({ id: entry.invoice.id, reason: "CoinPay did not quote a crypto amount" }); + continue; + } + + payments.push({ + id: entry.invoice.id, + gig_id: entry.invoice.gig_id, + chain: entry.data.payment_currency, + to: entry.data.payment_address, + amount: String(amount), + label: counterpartyLabel(entry.invoice), + amountUsd: Number(entry.invoice.amount_usd) || 0, + expires_at: entry.data.expires_at, + reused: entry.reused, + }); + } + + return NextResponse.json({ + data: { + payments, + skipped, + total_usd: payments.reduce((sum, p) => sum + p.amountUsd, 0), + }, + }); + } catch (err) { + console.error("[bulk payment request] failed:", err); + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to create payment requests" }, + { status: 500 } + ); + } +} diff --git a/src/app/dashboard/invoices/BulkPayAccepted.test.tsx b/src/app/dashboard/invoices/BulkPayAccepted.test.tsx new file mode 100644 index 00000000..111dbee9 --- /dev/null +++ b/src/app/dashboard/invoices/BulkPayAccepted.test.tsx @@ -0,0 +1,322 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { BulkPayAccepted } from "./BulkPayAccepted"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ refresh: vi.fn() }), +})); + +const INVOICE_IDS = ["inv-1", "inv-2"]; + +const PREPARED = { + data: { + payments: [ + { + id: "inv-1", + gig_id: "gig-1", + chain: "usdc_pol", + to: "0xabc", + amount: "25", + label: "Ada Lovelace — Fix login", + amountUsd: 25, + expires_at: "2030-01-01T00:00:00Z", + reused: false, + }, + { + id: "inv-2", + gig_id: "gig-2", + chain: "usdc_pol", + to: "0xdef", + amount: "75", + label: "Grace Hopper — Ship compiler", + amountUsd: 75, + expires_at: "2030-01-01T00:00:00Z", + reused: false, + }, + ], + skipped: [], + total_usd: 100, + }, +}; + +/** Install a fake `window.coinpay`, as the extension would. */ +function installWallet(overrides: Record = {}) { + const provider = { + isCoinPay: true, + version: "0.1.0", + getState: vi.fn(async () => ({ initialized: true, unlocked: true, connected: true })), + connect: vi.fn(async () => ({ accounts: [] })), + getAccounts: vi.fn(async () => ({ accounts: [] })), + payBatch: vi.fn(async (_payments: unknown[], _options?: unknown) => ({ + results: [ + { id: "inv-1", chain: "usdc_pol", to: "0xabc", amount: "25", status: "sent", txHash: "0x1" }, + { id: "inv-2", chain: "usdc_pol", to: "0xdef", amount: "75", status: "sent", txHash: "0x2" }, + ], + })), + onProgress: vi.fn(() => () => {}), + ...overrides, + }; + (window as any).coinpay = provider; + return provider; +} + +/** Route fetches by URL so tests only specify what they care about. */ +function mockFetch(handlers: Record = {}) { + const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => { + if (url.includes("bulk-payment-request")) { + return { + ok: true, + json: async () => handlers.prepare ?? PREPARED, + }; + } + return { ok: true, json: async () => ({ data: { recorded: 2 } }) }; + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +async function openConfirmation() { + fireEvent.click(screen.getByRole("button", { name: /Pay all 2/ })); + await screen.findByRole("button", { name: /Approve in wallet/ }); +} + +describe("BulkPayAccepted", () => { + beforeEach(() => { + mockFetch(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + delete (window as any).coinpay; + }); + + it("renders nothing when there is nothing accepted to pay", () => { + installWallet(); + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("prompts to install the wallet when the extension is absent", () => { + render(); + + expect(screen.getByText(/Install the CoinPay wallet/)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Pay all/ })).not.toBeInTheDocument(); + }); + + it("offers the bulk action once the wallet is detected", () => { + installWallet(); + render(); + + expect(screen.getByRole("button", { name: /Pay all 2/ })).toBeInTheDocument(); + expect(screen.getByText(/2 invoices · \$100\.00/)).toBeInTheDocument(); + }); + + it("detects a wallet that finishes injecting after mount", async () => { + render(); + expect(screen.queryByRole("button", { name: /Pay all/ })).not.toBeInTheDocument(); + + installWallet(); + act(() => { + window.dispatchEvent(new Event("coinpay#initialized")); + }); + + expect(await screen.findByRole("button", { name: /Pay all 2/ })).toBeInTheDocument(); + }); + + it("prepares payment requests for the accepted invoices", async () => { + installWallet(); + const fetchMock = mockFetch(); + render(); + + await openConfirmation(); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/invoices/bulk-payment-request", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ invoice_ids: INVOICE_IDS }), + }) + ); + expect(screen.getByText(/2 payments/)).toBeInTheDocument(); + }); + + it("does not touch the wallet before the user confirms", async () => { + const wallet = installWallet(); + render(); + + await openConfirmation(); + + expect(wallet.payBatch).not.toHaveBeenCalled(); + }); + + it("surfaces skipped invoices in the confirmation", async () => { + installWallet(); + mockFetch({ + prepare: { + data: { + payments: PREPARED.data.payments.slice(0, 1), + skipped: [{ id: "inv-2", reason: "Not accepted yet" }], + total_usd: 25, + }, + }, + }); + render(); + + await openConfirmation(); + + // An invoice silently missing from the run would look paid when it isn't. + expect(screen.getByText(/1 invoice will be skipped/)).toBeInTheDocument(); + expect(screen.getByText(/Not accepted yet/)).toBeInTheDocument(); + }); + + it("hands the prepared payments to the wallet on approval", async () => { + const wallet = installWallet(); + render(); + await openConfirmation(); + + fireEvent.click(screen.getByRole("button", { name: /Approve in wallet/ })); + + await waitFor(() => expect(wallet.payBatch).toHaveBeenCalled()); + expect(wallet.connect).toHaveBeenCalled(); + const [payments] = wallet.payBatch.mock.calls[0]; + expect(payments).toEqual([ + { id: "inv-1", chain: "usdc_pol", to: "0xabc", amount: "25", label: "Ada Lovelace — Fix login", amountUsd: 25 }, + { id: "inv-2", chain: "usdc_pol", to: "0xdef", amount: "75", label: "Grace Hopper — Ship compiler", amountUsd: 75 }, + ]); + }); + + it("records the broadcast results afterwards", async () => { + installWallet(); + const fetchMock = mockFetch(); + render(); + await openConfirmation(); + + fireEvent.click(screen.getByRole("button", { name: /Approve in wallet/ })); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "/api/invoices/bulk-payment-record", + expect.objectContaining({ method: "POST" }) + ) + ); + const call = fetchMock.mock.calls.find((c: any[]) => + c[0].includes("bulk-payment-record") + )!; + expect(JSON.parse((call[1] as any).body).results).toEqual([ + { invoice_id: "inv-1", status: "sent", tx_hash: "0x1", explorer_url: undefined, error: undefined }, + { invoice_id: "inv-2", status: "sent", tx_hash: "0x2", explorer_url: undefined, error: undefined }, + ]); + }); + + it("summarizes a fully successful run", async () => { + installWallet(); + render(); + await openConfirmation(); + + fireEvent.click(screen.getByRole("button", { name: /Approve in wallet/ })); + + expect(await screen.findByText(/2 payments broadcast/)).toBeInTheDocument(); + // Broadcast is not settlement — the copy must not claim "paid". + expect(screen.getByText(/once each transaction confirms on-chain/)).toBeInTheDocument(); + }); + + it("names the failures and offers to retry only those", async () => { + const wallet = installWallet({ + payBatch: vi.fn(async (_payments: unknown[], _options?: unknown) => ({ + results: [ + { id: "inv-1", chain: "usdc_pol", to: "0xabc", amount: "25", status: "sent", txHash: "0x1" }, + { + id: "inv-2", + chain: "usdc_pol", + to: "0xdef", + amount: "75", + status: "failed", + error: "Insufficient funds", + }, + ], + })), + }); + const fetchMock = mockFetch(); + render(); + await openConfirmation(); + fireEvent.click(screen.getByRole("button", { name: /Approve in wallet/ })); + + expect(await screen.findByText(/1 payment broadcast, 1 not sent/)).toBeInTheDocument(); + expect(screen.getByText(/Grace Hopper — Ship compiler — Insufficient funds/)).toBeInTheDocument(); + + fetchMock.mockClear(); + fireEvent.click(screen.getByRole("button", { name: /Retry the 1 that failed/ })); + + // Retrying the successful one would pay it a second time. + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "/api/invoices/bulk-payment-request", + expect.objectContaining({ body: JSON.stringify({ invoice_ids: ["inv-2"] }) }) + ) + ); + expect(wallet.payBatch).toHaveBeenCalledTimes(1); + }); + + it("reports a wallet rejection and stays on the confirmation step", async () => { + installWallet({ + payBatch: vi.fn(async (_payments: unknown[], _options?: unknown) => { + throw new Error("Payment request rejected"); + }), + }); + render(); + await openConfirmation(); + + fireEvent.click(screen.getByRole("button", { name: /Approve in wallet/ })); + + expect(await screen.findByText("Payment request rejected")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Approve in wallet/ })).toBeInTheDocument(); + }); + + it("surfaces a preparation failure without opening the wallet", async () => { + const wallet = installWallet(); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, json: async () => ({ error: "CoinPay is down" }) })) + ); + render(); + + fireEvent.click(screen.getByRole("button", { name: /Pay all 2/ })); + + expect(await screen.findByText("CoinPay is down")).toBeInTheDocument(); + expect(wallet.payBatch).not.toHaveBeenCalled(); + }); + + it("still reports success when only the bookkeeping call fails", async () => { + installWallet(); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, _init?: RequestInit) => { + if (url.includes("bulk-payment-request")) { + return { ok: true, json: async () => PREPARED }; + } + throw new Error("network down"); + }) + ); + render(); + await openConfirmation(); + + fireEvent.click(screen.getByRole("button", { name: /Approve in wallet/ })); + + // The payments are already on-chain; a failed record call must not read as + // a failed payment. + expect(await screen.findByText(/2 payments broadcast/)).toBeInTheDocument(); + }); + + it("lets the user back out of the confirmation", async () => { + const wallet = installWallet(); + render(); + await openConfirmation(); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(await screen.findByRole("button", { name: /Pay all 2/ })).toBeInTheDocument(); + expect(wallet.payBatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/dashboard/invoices/BulkPayAccepted.tsx b/src/app/dashboard/invoices/BulkPayAccepted.tsx new file mode 100644 index 00000000..f2afcd39 --- /dev/null +++ b/src/app/dashboard/invoices/BulkPayAccepted.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { + AlertTriangle, + CheckCircle2, + ExternalLink, + Loader2, + Wallet, + XCircle, + Zap, +} from "lucide-react"; +import type { + CoinPayBatchResult, + CoinPayProgress, +} from "@/types/coinpay-extension"; + +/** + * Pay every accepted invoice in one go, via the CoinPay wallet extension. + * + * Paying 62 accepted invoices by hand is about an hour of clicking. This + * collapses it into: prepare all payment requests server-side → hand the whole + * list to the extension → the user approves ONCE → transactions go out while + * this panel streams progress. + * + * Each payment is still its own on-chain transaction, so partial success is + * normal and expected. The UI is built around that: results are reported per + * invoice, failures are named and retryable, and nothing is reported as paid + * until it actually confirms. + */ + +const EXTENSION_URL = "https://coinpayportal.com/extension"; + +interface Props { + /** Ids of the accepted-and-unpaid invoices the signed-in user owes. */ + invoiceIds: string[]; + totalUsd: number; +} + +interface PreparedPayment { + id: string; + chain: string; + to: string; + amount: string; + label: string; + amountUsd: number; + expires_at: string; +} + +interface SkippedInvoice { + id: string; + reason: string; +} + +type Phase = "idle" | "preparing" | "confirming" | "paying" | "done"; + +export function BulkPayAccepted({ invoiceIds, totalUsd }: Props) { + const acceptedCount = invoiceIds.length; + const router = useRouter(); + const [hasWallet, setHasWallet] = useState(false); + const [phase, setPhase] = useState("idle"); + const [payments, setPayments] = useState([]); + const [skipped, setSkipped] = useState([]); + const [progress, setProgress] = useState>({}); + const [results, setResults] = useState(null); + const [error, setError] = useState(null); + + // The extension injects `window.coinpay` at document_start, but this + // component can still mount first on a fast navigation — so listen for the + // ready event as well as checking directly. + useEffect(() => { + const detect = () => setHasWallet(Boolean(window.coinpay?.isCoinPay)); + detect(); + window.addEventListener("coinpay#initialized", detect); + return () => window.removeEventListener("coinpay#initialized", detect); + }, []); + + /** + * 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. + */ + const prepare = useCallback(async (ids: string[] = invoiceIds) => { + if (ids.length === 0) { + setError("No accepted invoices are ready to pay."); + return; + } + + setPhase("preparing"); + setError(null); + setResults(null); + setProgress({}); + + try { + const res = await fetch("/api/invoices/bulk-payment-request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ invoice_ids: ids }), + }); + const json = await res.json(); + if (!res.ok) { + setError(json.error || "Could not prepare the payments"); + setPhase("idle"); + return; + } + + setPayments(json.data?.payments || []); + setSkipped(json.data?.skipped || []); + setPhase("confirming"); + } catch { + setError("Network error. Try again."); + setPhase("idle"); + } + }, [invoiceIds]); + + const pay = useCallback(async () => { + const provider = window.coinpay; + if (!provider) { + setError("The CoinPay wallet extension is no longer available."); + return; + } + + setPhase("paying"); + setError(null); + + try { + // Connecting is idempotent — it returns immediately if already connected, + // and otherwise prompts before we ask for a 62-payment approval. + await provider.connect(); + + const { results: batchResults } = await provider.payBatch( + payments.map((p) => ({ + id: p.id, + chain: p.chain, + to: p.to, + amount: p.amount, + label: p.label, + amountUsd: p.amountUsd, + })), + { + onProgress: (update) => + setProgress((current) => ({ ...current, [update.id]: update })), + } + ); + + setResults(batchResults); + setPhase("done"); + + // Record what was broadcast even if some failed — the audit trail is what + // distinguishes "sent, not yet confirmed" from "never sent". + await fetch("/api/invoices/bulk-payment-record", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + results: batchResults.map((r) => ({ + invoice_id: r.id, + status: r.status, + tx_hash: r.txHash, + explorer_url: r.explorerUrl, + error: r.error, + })), + }), + }).catch(() => { + // The payments are already on-chain; a failed bookkeeping call must not + // read as a failed payment. Confirmation still arrives via webhook. + }); + + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "The wallet rejected the request"); + setPhase(results ? "done" : "confirming"); + } + }, [payments, results, router]); + + if (acceptedCount === 0) return null; + + const sentCount = results?.filter((r) => r.status === "sent").length ?? 0; + const failedResults = results?.filter((r) => r.status !== "sent") ?? []; + const labelFor = (id: string) => payments.find((p) => p.id === id)?.label || id; + + return ( +
+
+
+

+ + Pay all accepted invoices +

+

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

+
+ + {phase === "idle" && ( +
+ {hasWallet ? ( + + ) : ( + + + Install the CoinPay wallet to pay in bulk + + )} +
+ )} + + {phase === "preparing" && ( + + + Preparing payment requests… + + )} +
+ + {error && ( +

+ + {error} +

+ )} + + {/* Confirmation summary — the last stop before the wallet's own approval. */} + {phase === "confirming" && ( +
+

+ Ready to send{" "} + + {payments.length} payment{payments.length === 1 ? "" : "s"} + {" "} + worth{" "} + + ${payments.reduce((sum, p) => sum + p.amountUsd, 0).toFixed(2)} + + . Your wallet will show the full list and ask you to approve once. +

+ +

+ Each payment is quoted at the current market rate and the quotes hold + for 15 minutes, so keep the approval window open until it finishes. +

+ + {skipped.length > 0 && ( +
+ + {skipped.length} invoice{skipped.length === 1 ? "" : "s"} will be skipped + +
    + {skipped.map((s) => ( +
  • + {s.id.slice(0, 8)}… — {s.reason} +
  • + ))} +
+
+ )} + +
+ + +
+
+ )} + + {/* Live progress. The wallet's approval window shows the same run; this + mirrors it so the dashboard reflects reality without a refresh. */} + {phase === "paying" && ( +
+

+ + Sending {Object.values(progress).filter((p) => p.stage === "sent").length} of{" "} + {payments.length}… Approve and keep the wallet window open. +

+
    + {payments.map((payment) => { + const stage = progress[payment.id]?.stage; + return ( +
  • + {payment.label} + + {stage === "sent" ? ( + sent + ) : stage === "failed" || stage === "skipped" ? ( + {stage} + ) : ( + {stage ?? "waiting"} + )} + +
  • + ); + })} +
+
+ )} + + {phase === "done" && results && ( +
+

+ + {sentCount} payment{sentCount === 1 ? "" : "s"} broadcast + {failedResults.length > 0 && `, ${failedResults.length} not sent`} +

+

+ Invoices flip to paid once each transaction confirms on-chain — that + usually takes a few minutes and happens automatically. +

+ + {failedResults.length > 0 && ( +
+

+ + These were not sent — you can run the payment again for them: +

+
    + {failedResults.map((r) => ( +
  • + {labelFor(r.id)} — {r.error || "not sent"} +
  • + ))} +
+
+ )} + +
+ {failedResults.length > 0 && ( + + )} + +
+
+ )} +
+ ); +} diff --git a/src/app/dashboard/invoices/page.tsx b/src/app/dashboard/invoices/page.tsx index e715144d..4f5a377b 100644 --- a/src/app/dashboard/invoices/page.tsx +++ b/src/app/dashboard/invoices/page.tsx @@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge"; import { InvoicePaymentActions } from "./InvoicePaymentActions"; import { InvoiceCharges } from "./InvoiceCharges"; import { SentInvoiceActions } from "./SentInvoiceActions"; +import { BulkPayAccepted } from "./BulkPayAccepted"; import { ArrowLeft, ExternalLink, @@ -285,6 +286,14 @@ export default async function InvoicesDashboardPage({ })} + {/* Bulk pay — only on the received side, where you're the payer. */} + {tab === "received" && ( + i.id)} + totalUsd={totalAccepted} + /> + )} + {/* Invoice list */}

diff --git a/src/lib/invoices/payment-request.test.ts b/src/lib/invoices/payment-request.test.ts new file mode 100644 index 00000000..3987292a --- /dev/null +++ b/src/lib/invoices/payment-request.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/coinpayportal", () => ({ + createPayment: vi.fn(), + preferredCoinToPaymentCurrency: vi.fn((value: string | null) => value?.toLowerCase() || null), +})); + +vi.mock("@/lib/supabase/service", () => ({ + createServiceClient: vi.fn(), +})); + +import { + ensureInvoicePaymentRequest, + activeExpiresAt, + metadataObject, + PAYABLE_INVOICE_STATUSES, + PAYMENT_REQUEST_SECONDS, +} from "./payment-request"; +import { createPayment } from "@/lib/coinpayportal"; +import { createServiceClient } from "@/lib/supabase/service"; + +const INVOICE_ID = "f53e4a56-3cf7-42f9-9a33-bc1cb770c4f6"; +const GIG_ID = "8489a861-0999-4107-afca-2592021ac338"; +const WALLET = "So11111111111111111111111111111111111111112"; + +/** Captures the row written back to gig_invoices. */ +function serviceClient() { + const captured: { row?: any } = {}; + (createServiceClient as any).mockReturnValue({ + from: vi.fn(() => ({ + update: vi.fn((row: any) => { + captured.row = row; + return { + eq: vi.fn(() => ({ + select: vi.fn(() => ({ + single: vi.fn(async () => ({ + data: { id: INVOICE_ID, metadata: row.metadata }, + error: null, + })), + })), + })), + }; + }), + })), + }); + return captured; +} + +function invoice(metadata: Record = {}, overrides: Record = {}) { + return { + id: INVOICE_ID, + gig_id: GIG_ID, + application_id: "d2317730-c56a-49e9-a6e4-dc469b7605f7", + worker_id: "666cbaba-c6ea-4756-ad44-d6a5b4248f8f", + poster_id: "4f16c625-c37a-4654-82db-e391067cbb13", + amount_usd: 125, + currency: "USD", + status: "sent", + coinpay_invoice_id: null, + pay_url: null, + notes: "Milestone 1", + metadata, + gig: { id: GIG_ID, title: "Build thing", payment_coin: "SOL" }, + ...overrides, + }; +} + +const WALLET_METADATA = { + receiver_payment_currency: "sol", + merchant_wallet_address: WALLET, + merchant_wallet_label: "Solana wallet", +}; + +function mockCoinPaySuccess(overrides: Record = {}) { + (createPayment as any).mockResolvedValue({ + payment_id: "cp-1", + address: "deposit-addr", + amount_crypto: "0.42", + currency: "sol", + expires_at: "2030-01-01T00:00:00Z", + payment: { id: "cp-1" }, + ...overrides, + }); +} + +describe("metadataObject", () => { + it("coerces non-objects to an empty object", () => { + expect(metadataObject(null)).toEqual({}); + expect(metadataObject("nope")).toEqual({}); + expect(metadataObject(undefined)).toEqual({}); + expect(metadataObject({ a: 1 })).toEqual({ a: 1 }); + }); +}); + +describe("activeExpiresAt", () => { + it("returns a date only while the quote still holds", () => { + expect(activeExpiresAt({ expires_at: "2030-01-01T00:00:00Z" })).toBeInstanceOf(Date); + }); + + it("treats past, missing, and malformed expiry as expired", () => { + // Sending a stale quote would transfer the wrong crypto amount. + expect(activeExpiresAt({ expires_at: "2020-01-01T00:00:00Z" })).toBeNull(); + expect(activeExpiresAt({})).toBeNull(); + expect(activeExpiresAt({ expires_at: "not a date" })).toBeNull(); + expect(activeExpiresAt({ expires_at: 12345 })).toBeNull(); + }); +}); + +describe("PAYABLE_INVOICE_STATUSES", () => { + it("covers exactly the states a payer may act on", () => { + expect([...PAYABLE_INVOICE_STATUSES].sort()).toEqual(["expired", "sent"]); + }); +}); + +describe("ensureInvoicePaymentRequest", () => { + beforeEach(() => vi.clearAllMocks()); + + it("creates a request from the worker's stored receiving wallet", async () => { + const captured = serviceClient(); + mockCoinPaySuccess(); + + const result = await ensureInvoicePaymentRequest(invoice(WALLET_METADATA)); + + expect(result).toMatchObject({ + ok: true, + reused: false, + data: { + coinpay_invoice_id: "cp-1", + payment_address: "deposit-addr", + amount_crypto: "0.42", + payment_currency: "sol", + }, + }); + expect(createPayment).toHaveBeenCalledWith( + expect.objectContaining({ + amount_usd: 125, + currency: "sol", + merchant_wallet_address: WALLET, + expires_in: PAYMENT_REQUEST_SECONDS, + }) + ); + expect(captured.row).toMatchObject({ status: "sent", coinpay_invoice_id: "cp-1" }); + }); + + it("reuses an unexpired request without calling CoinPay again", async () => { + serviceClient(); + + const result = await ensureInvoicePaymentRequest( + invoice( + { + ...WALLET_METADATA, + payment_address: "existing-addr", + amount_crypto: "0.9", + payment_currency: "sol", + expires_at: "2030-01-01T00:00:00Z", + }, + { coinpay_invoice_id: "cp-existing" } + ) + ); + + // Two live deposit addresses for one debt is how a worker gets paid twice. + expect(createPayment).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + ok: true, + reused: true, + data: { payment_address: "existing-addr", coinpay_invoice_id: "cp-existing" }, + }); + }); + + it("re-quotes once the previous request expired", async () => { + serviceClient(); + mockCoinPaySuccess(); + + const result = await ensureInvoicePaymentRequest( + invoice( + { + ...WALLET_METADATA, + payment_address: "stale-addr", + expires_at: "2020-01-01T00:00:00Z", + }, + { coinpay_invoice_id: "cp-old" } + ) + ); + + expect(createPayment).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ ok: true, reused: false }); + }); + + it("keeps the superseded payment id in history when re-quoting", async () => { + const captured = serviceClient(); + mockCoinPaySuccess(); + + await ensureInvoicePaymentRequest( + invoice({ ...WALLET_METADATA, expires_at: "2020-01-01T00:00:00Z" }, { + coinpay_invoice_id: "cp-old", + }) + ); + + // Funds sent late to the old address still need to be traceable. + expect(captured.row.metadata.previous_coinpay_invoice_ids).toEqual(["cp-old"]); + }); + + it("fails cleanly when the worker has no receiving wallet", async () => { + serviceClient(); + + const result = await ensureInvoicePaymentRequest(invoice({})); + + expect(result).toMatchObject({ ok: false, code: "NO_WALLET" }); + expect(createPayment).not.toHaveBeenCalled(); + }); + + it("fails when the wallet address is blank", async () => { + serviceClient(); + + const result = await ensureInvoicePaymentRequest( + invoice({ receiver_payment_currency: "sol", merchant_wallet_address: " " }) + ); + + expect(result).toMatchObject({ ok: false, code: "NO_WALLET" }); + }); + + it("reports a provider throw as a PROVIDER failure rather than escaping", async () => { + serviceClient(); + (createPayment as any).mockRejectedValue(new Error("CoinPay is down")); + + // One bad invoice must not abort a 62-invoice bulk run. + const result = await ensureInvoicePaymentRequest(invoice(WALLET_METADATA)); + + expect(result).toMatchObject({ ok: false, code: "PROVIDER", error: "CoinPay is down" }); + }); + + it("rejects a provider response with no payment id or address", async () => { + serviceClient(); + + mockCoinPaySuccess({ payment_id: null, payment: {} }); + expect(await ensureInvoicePaymentRequest(invoice(WALLET_METADATA))).toMatchObject({ + ok: false, + code: "PROVIDER", + }); + + vi.clearAllMocks(); + serviceClient(); + mockCoinPaySuccess({ address: null, payment: { id: "cp-1" } }); + expect(await ensureInvoicePaymentRequest(invoice(WALLET_METADATA))).toMatchObject({ + ok: false, + code: "PROVIDER", + }); + }); + + it("reports a persistence failure instead of claiming success", async () => { + (createServiceClient as any).mockReturnValue({ + from: vi.fn(() => ({ + update: vi.fn(() => ({ + eq: vi.fn(() => ({ + select: vi.fn(() => ({ + single: vi.fn(async () => ({ data: null, error: { message: "db down" } })), + })), + })), + })), + })), + }); + mockCoinPaySuccess(); + + // A live deposit address the invoice does not know about would strand funds. + const result = await ensureInvoicePaymentRequest(invoice(WALLET_METADATA)); + + expect(result).toMatchObject({ ok: false, code: "PERSIST" }); + }); + + it("defaults an absent provider expiry to the standard window", async () => { + const captured = serviceClient(); + mockCoinPaySuccess({ expires_at: null, payment: { id: "cp-1" } }); + + const before = Date.now(); + const result = await ensureInvoicePaymentRequest(invoice(WALLET_METADATA)); + + expect(result.ok).toBe(true); + const expiry = new Date(captured.row.metadata.expires_at).getTime(); + expect(expiry).toBeGreaterThanOrEqual(before + PAYMENT_REQUEST_SECONDS * 1000 - 1000); + }); + + it("carries the invoice identifiers into provider metadata for webhook matching", async () => { + serviceClient(); + mockCoinPaySuccess(); + + await ensureInvoicePaymentRequest(invoice(WALLET_METADATA)); + + expect(createPayment).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + type: "gig_invoice", + invoice_id: INVOICE_ID, + gig_id: GIG_ID, + platform: "ugig.net", + }), + }) + ); + }); +}); diff --git a/src/lib/invoices/payment-request.ts b/src/lib/invoices/payment-request.ts new file mode 100644 index 00000000..b0ff9da2 --- /dev/null +++ b/src/lib/invoices/payment-request.ts @@ -0,0 +1,229 @@ +import { createPayment, preferredCoinToPaymentCurrency } from "@/lib/coinpayportal"; +import { createServiceClient } from "@/lib/supabase/service"; + +/** + * Creating the CoinPay payment request for an invoice. + * + * Extracted from the single-invoice `payment-request` route so the bulk payer + * can produce byte-identical requests: one code path decides the currency, the + * receiving address, the crypto amount, and what gets written to metadata. + * Two implementations here would eventually disagree about what "paid" means. + */ + +export const PAYMENT_REQUEST_SECONDS = 15 * 60; + +export const PAYABLE_INVOICE_STATUSES = new Set(["sent", "expired"]); + +export interface InvoicePaymentRequestData { + invoice_id: string; + coinpay_invoice_id: string; + pay_url: string | null; + payment_address: string; + amount_crypto: string | number | null; + payment_currency: string; + expires_at: string; + metadata: Record; +} + +export type PaymentRequestResult = + | { ok: true; data: InvoicePaymentRequestData; reused: boolean } + | { ok: false; error: string; code: "NO_WALLET" | "PROVIDER" | "PERSIST" }; + +export function metadataObject(value: unknown): Record { + return value && typeof value === "object" ? (value as Record) : {}; +} + +/** + * An existing request is still usable only while its quote holds. CoinPay locks + * the crypto amount at the market rate when the request is made, so past + * `expires_at` we must re-quote rather than send a stale amount. + */ +export function activeExpiresAt(metadata: Record): 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; +} + +/** The worker's receiving wallet, as captured on the invoice. */ +function receivingWallet(metadata: Record): { + currency: string | null; + address: string; + label: string | null; +} { + const currency = preferredCoinToPaymentCurrency( + typeof metadata.receiver_payment_currency === "string" + ? metadata.receiver_payment_currency + : typeof metadata.payment_currency === "string" && !metadata.payment_address + ? metadata.payment_currency + : null + ); + const address = + typeof metadata.merchant_wallet_address === "string" + ? metadata.merchant_wallet_address.trim() + : ""; + const label = + typeof metadata.merchant_wallet_label === "string" ? metadata.merchant_wallet_label : null; + + return { currency, address, label }; +} + +/** + * Return a live payment request for this invoice, reusing an unexpired one when + * possible and otherwise creating a fresh quote. + * + * Reuse matters for bulk: a run that partially fails gets retried, and without + * reuse the retry would mint a second payment request per invoice — leaving + * two live addresses for one debt. + * + * The caller must already have verified that the invoice belongs to the payer + * and is in a payable state. + */ +export async function ensureInvoicePaymentRequest( + invoice: any, + options: { appUrl?: string; businessId?: string } = {} +): Promise { + const metadata = metadataObject(invoice.metadata); + + if (invoice.coinpay_invoice_id && metadata.payment_address && activeExpiresAt(metadata)) { + return { + ok: true, + reused: true, + data: { + invoice_id: invoice.id, + coinpay_invoice_id: invoice.coinpay_invoice_id, + pay_url: invoice.pay_url || null, + payment_address: String(metadata.payment_address), + amount_crypto: (metadata.amount_crypto as string | number | null) ?? null, + payment_currency: String(metadata.payment_currency || ""), + expires_at: String(metadata.expires_at || ""), + metadata, + }, + }; + } + + const gig = Array.isArray(invoice.gig) ? invoice.gig[0] : invoice.gig; + const appUrl = + options.appUrl || process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net"; + const businessId = options.businessId || process.env.COINPAY_MERCHANT_ID; + + const wallet = receivingWallet(metadata); + if (!wallet.currency || !wallet.address) { + return { + ok: false, + code: "NO_WALLET", + error: "This invoice is missing the worker's CoinPay receiving wallet", + }; + } + + let paymentResult: any; + try { + paymentResult = await createPayment({ + amount_usd: Number(invoice.amount_usd), + currency: wallet.currency as any, + description: invoice.notes || `Invoice for gig: ${gig?.title || invoice.gig_id}`, + business_id: businessId, + merchant_wallet_address: wallet.address, + redirect_url: `${appUrl}/dashboard/invoices?tab=received`, + expires_in: PAYMENT_REQUEST_SECONDS, + metadata: { + type: "gig_invoice", + gig_id: invoice.gig_id, + 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: wallet.currency, + merchant_wallet_address: wallet.address, + merchant_wallet_label: wallet.label, + platform: "ugig.net", + }, + }); + } catch (err) { + return { + ok: false, + code: "PROVIDER", + error: err instanceof Error ? err.message : "Failed to create payment request", + }; + } + + const cpPayment = paymentResult.payment || paymentResult; + const paymentId = paymentResult.payment_id || cpPayment.id; + const paymentAddress = cpPayment.payment_address || paymentResult.address || null; + const checkoutUrl = paymentResult.checkout_url || cpPayment.checkout_url || null; + const amountCrypto = + paymentResult.amount_crypto || cpPayment.amount_crypto || cpPayment.crypto_amount || null; + const expiresAt = + paymentResult.expires_at || + cpPayment.expires_at || + new Date(Date.now() + PAYMENT_REQUEST_SECONDS * 1000).toISOString(); + const responseCurrency = paymentResult.currency || cpPayment.currency || wallet.currency; + + if (!paymentId) { + return { ok: false, code: "PROVIDER", error: "CoinPay did not return a payment id" }; + } + if (!paymentAddress) { + return { + ok: false, + code: "PROVIDER", + error: "CoinPay did not return an in-app payment address", + }; + } + + 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: wallet.currency, + merchant_wallet_address: wallet.address, + merchant_wallet_label: wallet.label, + 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 { ok: false, code: "PERSIST", error: "Failed to save payment request" }; + } + + return { + ok: true, + reused: false, + 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, + }, + }; +} diff --git a/src/types/coinpay-extension.d.ts b/src/types/coinpay-extension.d.ts new file mode 100644 index 00000000..c2643453 --- /dev/null +++ b/src/types/coinpay-extension.d.ts @@ -0,0 +1,80 @@ +/** + * `window.coinpay` — injected by the CoinPay wallet browser extension. + * + * Optional at runtime: the property only exists when the extension is + * installed, so every use must feature-detect first. + * See packages/extension/src/inpage/provider.ts in the coinpayportal repo. + */ + +export interface CoinPayBatchPayment { + /** Correlation id echoed back on the matching result (we use the invoice id). */ + id: string; + /** Chain or CoinPay currency code, e.g. `usdc_pol`, `BTC`. */ + chain: string; + to: string; + /** Decimal string in the chain's display units. */ + amount: string; + label?: string; + amountUsd?: number; +} + +export type CoinPayStage = + | "queued" + | "preparing" + | "signing" + | "broadcasting" + | "sent" + | "failed" + | "skipped"; + +export interface CoinPayBatchResult { + id: string; + chain: string; + to: string; + amount: string; + status: "sent" | "failed" | "skipped"; + txHash?: string; + explorerUrl?: string; + error?: string; +} + +export interface CoinPayProgress { + id: string; + stage: CoinPayStage; + txHash?: string; + explorerUrl?: string; + error?: string; + completed: number; + total: number; +} + +export interface CoinPayAccount { + chain: string; + address: string; + tokens: string[]; +} + +export interface CoinPayProvider { + isCoinPay: true; + version: string; + getState(): Promise<{ initialized: boolean; unlocked: boolean; connected: boolean }>; + connect(): Promise<{ accounts: CoinPayAccount[] }>; + getAccounts(): Promise<{ accounts: CoinPayAccount[] }>; + /** + * Resolves once every payment reaches a terminal state — including when some + * fail. Check `status` per result; this is not all-or-nothing. + */ + payBatch( + payments: CoinPayBatchPayment[], + options?: { onProgress?: (progress: CoinPayProgress) => void } + ): Promise<{ results: CoinPayBatchResult[] }>; + onProgress(listener: (progress: CoinPayProgress) => void): () => void; +} + +declare global { + interface Window { + coinpay?: CoinPayProvider; + } +} + +export {};