diff --git a/src/app/api/bounties/[id]/submissions/[sid]/pay/route.test.ts b/src/app/api/bounties/[id]/submissions/[sid]/pay/route.test.ts index 783bce16..91d62fe7 100644 --- a/src/app/api/bounties/[id]/submissions/[sid]/pay/route.test.ts +++ b/src/app/api/bounties/[id]/submissions/[sid]/pay/route.test.ts @@ -97,7 +97,7 @@ describe("POST /api/bounties/[id]/submissions/[sid]/pay", () => { expect(updatePayload).toMatchObject({ payout_status: "invoiced", coinpay_invoice_id: "cp-pay-bounty-1", - pay_url: "https://coinpayportal.com/pay/cp-pay-bounty-1", + pay_url: null, metadata: expect.objectContaining({ payment_address: "So11111111111111111111111111111111111111112", amount_crypto: 0.5, @@ -153,9 +153,111 @@ describe("POST /api/bounties/[id]/submissions/[sid]/pay", () => { const body = await res.json(); expect(body.data.coinpay_invoice_id).toBe("cp-pay-bounty-1"); expect(body.data.payment_address).toBe("So11111111111111111111111111111111111111112"); + expect(body.data.pay_url).toBeNull(); expect(createPayment).not.toHaveBeenCalled(); }); + it("creates a fresh in-app payment when an old invoice has no address metadata", async () => { + const bountyChain = chain({ + data: { + id: BOUNTY_ID, + creator_id: CREATOR_ID, + title: "Test bounty", + payout_usd: 25, + payment_coin: "SOL", + }, + }); + const submissionChain = chain({ + data: { + id: SUBMISSION_ID, + submitter_id: SUBMITTER_ID, + status: "approved", + payout_status: "invoiced", + pay_url: "https://coinpayportal.com/pay/old-hosted-checkout", + coinpay_invoice_id: "old-cp-pay-bounty-1", + metadata: {}, + }, + }); + let updatePayload: Record | null = null; + submissionChain.update.mockImplementation((payload: Record) => { + updatePayload = payload; + return submissionChain; + }); + const supabase = { + from: vi.fn((table: string) => { + if (table === "bounties") return bountyChain; + if (table === "bounty_submissions") return submissionChain; + return chain({ data: null }); + }), + }; + + (getAuthContext as any).mockResolvedValue({ user: { id: CREATOR_ID }, supabase }); + (resolveSupportedPaymentCurrency as any).mockResolvedValue("sol"); + (createPayment as any).mockResolvedValue({ + success: true, + payment_id: "cp-pay-bounty-fresh", + address: "SoFresh1111111111111111111111111111111111111", + amount_crypto: 0.4, + currency: "sol", + expires_at: "2026-05-23T13:00:00Z", + checkout_url: "https://coinpayportal.com/pay/cp-pay-bounty-fresh", + }); + + const res = await POST(req(), params); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.coinpay_invoice_id).toBe("cp-pay-bounty-fresh"); + expect(body.data.payment_address).toBe("SoFresh1111111111111111111111111111111111111"); + expect(updatePayload).toMatchObject({ + coinpay_invoice_id: "cp-pay-bounty-fresh", + pay_url: null, + metadata: expect.objectContaining({ + payment_address: "SoFresh1111111111111111111111111111111111111", + }), + }); + }); + + it("does not recreate a payment for an already-paid old checkout submission", async () => { + const bountyChain = chain({ + data: { + id: BOUNTY_ID, + creator_id: CREATOR_ID, + title: "Test bounty", + payout_usd: 25, + payment_coin: "SOL", + }, + }); + const submissionChain = chain({ + data: { + id: SUBMISSION_ID, + submitter_id: SUBMITTER_ID, + status: "approved", + payout_status: "paid", + pay_url: "https://coinpayportal.com/pay/old-hosted-checkout", + coinpay_invoice_id: "old-cp-pay-bounty-paid", + metadata: {}, + }, + }); + const supabase = { + from: vi.fn((table: string) => { + if (table === "bounties") return bountyChain; + if (table === "bounty_submissions") return submissionChain; + return chain({ data: null }); + }), + }; + + (getAuthContext as any).mockResolvedValue({ user: { id: CREATOR_ID }, supabase }); + + const res = await POST(req(), params); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("Submission has already been paid"); + expect(createPayment).not.toHaveBeenCalled(); + expect(submissionChain.update).not.toHaveBeenCalled(); + }); + it("returns the database error when loading the submission fails", async () => { const bountyChain = chain({ data: { 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 8117de11..3f0c74cb 100644 --- a/src/app/api/bounties/[id]/submissions/[sid]/pay/route.ts +++ b/src/app/api/bounties/[id]/submissions/[sid]/pay/route.ts @@ -50,14 +50,27 @@ export async function POST( return NextResponse.json({ error: "Only approved submissions can be paid" }, { status: 400 }); } - // Already invoiced — return existing details - if (submission.coinpay_invoice_id) { - const metadata = (submission.metadata || {}) as Record; + 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" }, + { status: 400 } + ); + } + + // 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: { submission_id: sid, coinpay_invoice_id: submission.coinpay_invoice_id, - pay_url: submission.pay_url, + pay_url: null, + checkout_url: metadata.checkout_url || null, payment_address: metadata.payment_address || null, payment_currency: metadata.payment_currency || null, amount_crypto: metadata.amount_crypto || null, @@ -117,7 +130,7 @@ export async function POST( .update({ payout_status: "invoiced", coinpay_invoice_id: paymentId, - pay_url: checkoutUrl, + pay_url: null, metadata: { payment_address: paymentAddress, amount_crypto: amountCrypto, @@ -140,7 +153,8 @@ export async function POST( payment_currency: responseCurrency, amount_crypto: amountCrypto, expires_at: expiresAt, - pay_url: checkoutUrl, + pay_url: null, + checkout_url: checkoutUrl, }, }); } catch (err) { diff --git a/src/app/bounties/[id]/ReviewPanel.tsx b/src/app/bounties/[id]/ReviewPanel.tsx index 28012023..b16cc526 100644 --- a/src/app/bounties/[id]/ReviewPanel.tsx +++ b/src/app/bounties/[id]/ReviewPanel.tsx @@ -143,7 +143,7 @@ export function ReviewPanel({ bountyId, payoutUsd, questions, submissions }: Rev payment_address: json.data.payment_address, amount_crypto: json.data.amount_crypto, payment_currency: json.data.payment_currency, - checkout_url: json.data.pay_url, + checkout_url: json.data.checkout_url ?? json.data.pay_url ?? null, expires_at: json.data.expires_at, }, })); @@ -241,21 +241,22 @@ export function ReviewPanel({ bountyId, payoutUsd, questions, submissions }: Rev )} - {s.status === "approved" && payoutStatus === "unpaid" && ( - - )} + {s.status === "approved" && + (payoutStatus === "unpaid" || (payoutStatus === "invoiced" && !paymentAddress)) && ( + + )} {s.status === "approved" && payoutStatus === "invoiced" && paymentAddress && (