diff --git a/src/app/api/bounties/[id]/submissions/[sid]/pay/route.profile-wallet-fallback.test.ts b/src/app/api/bounties/[id]/submissions/[sid]/pay/route.profile-wallet-fallback.test.ts new file mode 100644 index 00000000..8fb2667b --- /dev/null +++ b/src/app/api/bounties/[id]/submissions/[sid]/pay/route.profile-wallet-fallback.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/coinpayportal", () => ({ + createPayment: vi.fn(), + getCoinpayGlobalWalletTokens: vi.fn(), + preferredCoinToPaymentCurrency: vi.fn(), +})); +vi.mock("@/lib/coinpay-oauth", () => ({ getConnectedCoinpayAccessToken: vi.fn() })); +vi.mock("@/lib/auth/get-user", () => ({ getAuthContext: vi.fn() })); + +import { POST } from "./route"; +import { getAuthContext } from "@/lib/auth/get-user"; +import { createPayment, getCoinpayGlobalWalletTokens, preferredCoinToPaymentCurrency } from "@/lib/coinpayportal"; +import { getConnectedCoinpayAccessToken } from "@/lib/coinpay-oauth"; + +const BOUNTY_ID = "8489a861-0999-4107-afca-2592021ac338"; +const SUBMISSION_ID = "d2317730-c56a-49e9-a6e4-dc469b7605f7"; +const CREATOR_ID = "4f16c625-c37a-4654-82db-e391067cbb13"; +const SUBMITTER_ID = "666cbaba-c6ea-4756-ad44-d6a5b4248f8f"; +const STORED_SOL = "Stored11111111111111111111111111111111111111"; + +function chain(result: { data: any; error?: any }) { + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: result.data, error: result.error ?? null }), + }; +} + +describe("bounty pay profile-wallet fallback", () => { + beforeEach(() => { + vi.clearAllMocks(); + (getConnectedCoinpayAccessToken as any).mockResolvedValue(null); + (getCoinpayGlobalWalletTokens as any).mockResolvedValue([]); + (preferredCoinToPaymentCurrency as any).mockReturnValue("sol"); + }); + + it("pays to the submitter's matching stored profile wallet when OAuth wallet lookup is unavailable", async () => { + const bounty = chain({ + data: { id: BOUNTY_ID, creator_id: CREATOR_ID, title: "Test bounty", payout_usd: 1, payment_coin: "SOL" }, + }); + const submission = chain({ + data: { + id: SUBMISSION_ID, + submitter_id: SUBMITTER_ID, + status: "approved", + payout_status: "unpaid", + pay_url: null, + coinpay_invoice_id: null, + metadata: {}, + }, + }); + const profile = chain({ + data: { wallet_addresses: [{ currency: "SOL", address: STORED_SOL, is_preferred: true }] }, + }); + const supabase = { + from: vi.fn((table: string) => { + if (table === "bounties") return bounty; + if (table === "bounty_submissions") return submission; + if (table === "profiles") return profile; + return chain({ data: null }); + }), + }; + (getAuthContext as any).mockResolvedValue({ user: { id: CREATOR_ID }, supabase }); + (createPayment as any).mockResolvedValue({ + payment_id: "cp-pay-fallback", + address: "Pay111111111111111111111111111111111111111", + currency: "sol", + amount_crypto: 0.01, + }); + + const res = await POST({} as any, { + params: Promise.resolve({ id: BOUNTY_ID, sid: SUBMISSION_ID }), + }); + + expect(res.status).toBe(200); + expect(getCoinpayGlobalWalletTokens).not.toHaveBeenCalled(); + expect(createPayment).toHaveBeenCalledWith( + expect.objectContaining({ + amount_usd: 1, + currency: "sol", + merchant_wallet_address: STORED_SOL, + }) + ); + }); +}); diff --git a/src/app/api/bounties/[id]/submissions/[sid]/pay/route.ts b/src/app/api/bounties/[id]/submissions/[sid]/pay/route.ts index 414ed742..73a23350 100644 --- a/src/app/api/bounties/[id]/submissions/[sid]/pay/route.ts +++ b/src/app/api/bounties/[id]/submissions/[sid]/pay/route.ts @@ -4,13 +4,12 @@ import { createPayment, getCoinpayGlobalWalletTokens, preferredCoinToPaymentCurrency, + type SupportedCurrency, } from "@/lib/coinpayportal"; import { getConnectedCoinpayAccessToken } from "@/lib/coinpay-oauth"; // POST /api/bounties/[id]/submissions/[sid]/pay -// Creator generates a CoinPay in-app payment for an approved submission, -// matching the post-#224 gig invoice flow (in-app payment address, not a -// redirect to a hosted invoice page). +// Creator generates a CoinPay in-app payment for an approved submission. export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string; sid: string }> } @@ -56,8 +55,6 @@ export async function POST( } const metadata = (submission.metadata || {}) as Record; - // Never reopen a completed payout. Old hosted-checkout rows can lack address metadata, - // but paid rows must stay terminal even if this endpoint is called directly. if (submission.payout_status === "paid") { return NextResponse.json( { error: "Submission has already been paid" }, @@ -65,10 +62,6 @@ export async function POST( ); } - // Already invoiced with in-app payment details — return existing details. - // Older hosted-checkout invoice rows may have a CoinPay invoice id/pay_url but no address - // metadata; let those fall through and create a fresh in-app payment request so creators - // are not stuck. if (submission.coinpay_invoice_id && metadata.payment_address) { return NextResponse.json({ data: { @@ -86,38 +79,75 @@ export async function POST( const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net"; const businessId = process.env.COINPAY_MERCHANT_ID; - - // Resolve the SUBMITTER's CoinPay receiving wallet so the payout forwards to - // the bounty winner — not the platform business wallet. Unlike invoices - // (where the worker creates the invoice and picks their wallet), the - // submitter isn't present here, so we look up their connected CoinPay - // wallets server-side and default to the one matching the bounty's payment - // coin, falling back to their first wallet. - const submitterToken = await getConnectedCoinpayAccessToken(submission.submitter_id); - if (!submitterToken) { + const preferredCurrency = preferredCoinToPaymentCurrency(bounty.payment_coin); + if (!preferredCurrency) { return NextResponse.json( - { - error: "The bounty winner must connect CoinPay before they can be paid", - setup_required: true, - }, - { status: 409 } + { error: `Unsupported bounty payment coin: ${String(bounty.payment_coin || "unknown")}` }, + { status: 400 } ); } - const submitterWallets = await getCoinpayGlobalWalletTokens({ access_token: submitterToken }); - if (submitterWallets.length === 0) { + + // Prefer the submitter's live CoinPay wallet when OAuth is healthy. If the + // provider OAuth client cannot grant wallet:read, fall back to the matching + // wallet address that the submitter has already stored on their own uGig + // profile. Profile wallet addresses are user-controlled payout coordinates + // and are already exposed by the profile wallet-address API specifically so + // payers can use them without an OAuth lookup. + type PayoutWallet = { currency: SupportedCurrency; address: string; label?: string | null }; + let payoutWallet: PayoutWallet | null = null; + + const submitterToken = await getConnectedCoinpayAccessToken(submission.submitter_id); + if (submitterToken) { + const submitterWallets = await getCoinpayGlobalWalletTokens({ access_token: submitterToken }); + payoutWallet = + submitterWallets.find((w) => w.currency === preferredCurrency) || submitterWallets[0] || null; + } + + if (!payoutWallet) { + const { data: submitterProfile } = await (supabase as any) + .from("profiles") + .select("wallet_addresses") + .eq("id", submission.submitter_id) + .single(); + + const storedWallets = Array.isArray(submitterProfile?.wallet_addresses) + ? submitterProfile.wallet_addresses + : []; + const normalizeCurrency = (value: unknown) => + String(value || "").toLowerCase().replace(/[^a-z0-9]/g, ""); + const normalizedPreferred = normalizeCurrency(preferredCurrency); + const storedWallet = storedWallets + .filter( + (w: any) => + w && + typeof w === "object" && + typeof w.address === "string" && + w.address.trim().length > 0 && + normalizeCurrency(w.currency) === normalizedPreferred + ) + .sort((a: any, b: any) => Number(Boolean(b.is_preferred)) - Number(Boolean(a.is_preferred)))[0]; + + if (storedWallet) { + payoutWallet = { + currency: preferredCurrency, + address: storedWallet.address.trim(), + label: storedWallet.label || `${storedWallet.currency || bounty.payment_coin} profile wallet`, + }; + } + } + + if (!payoutWallet) { return NextResponse.json( { - error: "The bounty winner must add a CoinPay receiving wallet before they can be paid", + error: + "The bounty winner must connect CoinPay or add a matching receiving wallet to their uGig profile before they can be paid", setup_required: true, }, { status: 409 } ); } - const preferredCurrency = preferredCoinToPaymentCurrency(bounty.payment_coin); - const payoutWallet = - submitterWallets.find((w) => w.currency === preferredCurrency) || submitterWallets[0]; - const paymentCurrency = payoutWallet.currency; + const paymentCurrency = payoutWallet.currency; const paymentResult = await createPayment({ amount_usd: Number(bounty.payout_usd), currency: paymentCurrency, @@ -133,7 +163,7 @@ export async function POST( submitter_id: submission.submitter_id, payment_currency: paymentCurrency, merchant_wallet_address: payoutWallet.address, - merchant_wallet_label: payoutWallet.label, + merchant_wallet_label: payoutWallet.label || null, platform: "ugig.net", }, }); @@ -177,7 +207,7 @@ export async function POST( amount_crypto: amountCrypto, payment_currency: responseCurrency, merchant_wallet_address: payoutWallet.address, - merchant_wallet_label: payoutWallet.label, + merchant_wallet_label: payoutWallet.label || null, checkout_url: checkoutUrl, expires_at: expiresAt, },