From 9a1808cadf2cf3a5d680198f6e0dc916b31b7680 Mon Sep 17 00:00:00 2001 From: Mentor Date: Sat, 23 May 2026 11:50:43 +0000 Subject: [PATCH 1/2] fix(bounties): keep payments in-app on current master --- .../[id]/submissions/[sid]/pay/route.test.ts | 64 ++++++++++++++++++- .../[id]/submissions/[sid]/pay/route.ts | 32 ++++++---- src/app/bounties/[id]/ReviewPanel.tsx | 31 ++++----- 3 files changed, 97 insertions(+), 30 deletions(-) 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..beae8d3b 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,71 @@ 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("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..aa24f7fd 100644 --- a/src/app/api/bounties/[id]/submissions/[sid]/pay/route.ts +++ b/src/app/api/bounties/[id]/submissions/[sid]/pay/route.ts @@ -50,20 +50,24 @@ export async function POST( return NextResponse.json({ error: "Only approved submissions can be paid" }, { status: 400 }); } - // Already invoiced — return existing details + // Already invoiced with in-app payment details — return existing details. + // Older hosted-checkout 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) { const metadata = (submission.metadata || {}) as Record; - return NextResponse.json({ - data: { - submission_id: sid, - coinpay_invoice_id: submission.coinpay_invoice_id, - pay_url: submission.pay_url, - payment_address: metadata.payment_address || null, - payment_currency: metadata.payment_currency || null, - amount_crypto: metadata.amount_crypto || null, - expires_at: metadata.expires_at || null, - }, - }); + if (metadata.payment_address) { + return NextResponse.json({ + data: { + submission_id: sid, + coinpay_invoice_id: submission.coinpay_invoice_id, + pay_url: null, + payment_address: metadata.payment_address || null, + payment_currency: metadata.payment_currency || null, + amount_crypto: metadata.amount_crypto || null, + expires_at: metadata.expires_at || null, + }, + }); + } } const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net"; @@ -117,7 +121,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 +144,7 @@ export async function POST( payment_currency: responseCurrency, amount_crypto: amountCrypto, expires_at: expiresAt, - pay_url: checkoutUrl, + pay_url: null, }, }); } catch (err) { diff --git a/src/app/bounties/[id]/ReviewPanel.tsx b/src/app/bounties/[id]/ReviewPanel.tsx index 28012023..45917623 100644 --- a/src/app/bounties/[id]/ReviewPanel.tsx +++ b/src/app/bounties/[id]/ReviewPanel.tsx @@ -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 && (
From cdb5831950e7b14419d7ca8e81fe2e9c09cfcf69 Mon Sep 17 00:00:00 2001 From: Mentor Date: Sun, 24 May 2026 23:44:02 +0000 Subject: [PATCH 2/2] fix(bounties): preserve completed payout state --- .../[id]/submissions/[sid]/pay/route.test.ts | 40 +++++++++++++++++ .../[id]/submissions/[sid]/pay/route.ts | 44 ++++++++++++------- src/app/bounties/[id]/ReviewPanel.tsx | 2 +- 3 files changed, 68 insertions(+), 18 deletions(-) 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 beae8d3b..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 @@ -218,6 +218,46 @@ describe("POST /api/bounties/[id]/submissions/[sid]/pay", () => { }); }); + 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 aa24f7fd..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,24 +50,33 @@ export async function POST( return NextResponse.json({ error: "Only approved submissions can be paid" }, { status: 400 }); } + 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 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) { - const metadata = (submission.metadata || {}) as Record; - if (metadata.payment_address) { - return NextResponse.json({ - data: { - submission_id: sid, - coinpay_invoice_id: submission.coinpay_invoice_id, - pay_url: null, - payment_address: metadata.payment_address || null, - payment_currency: metadata.payment_currency || null, - amount_crypto: metadata.amount_crypto || null, - expires_at: metadata.expires_at || null, - }, - }); - } + // 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: 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, + expires_at: metadata.expires_at || null, + }, + }); } const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || "https://ugig.net"; @@ -145,6 +154,7 @@ export async function POST( amount_crypto: amountCrypto, expires_at: expiresAt, 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 45917623..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, }, }));