Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 103 additions & 1 deletion src/app/api/bounties/[id]/submissions/[sid]/pay/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> | null = null;
submissionChain.update.mockImplementation((payload: Record<string, unknown>) => {
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: {
Expand Down
26 changes: 20 additions & 6 deletions src/app/api/bounties/[id]/submissions/[sid]/pay/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
const metadata = (submission.metadata || {}) as Record<string, unknown>;
// 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,
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
33 changes: 17 additions & 16 deletions src/app/bounties/[id]/ReviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}));
Expand Down Expand Up @@ -241,21 +241,22 @@ export function ReviewPanel({ bountyId, payoutUsd, questions, submissions }: Rev
</>
)}

{s.status === "approved" && payoutStatus === "unpaid" && (
<Button
size="sm"
disabled={busyId === s.id}
onClick={() => pay(s.id)}
className="gap-1"
>
{busyId === s.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<DollarSign className="h-3 w-3" />
)}
Pay ${payoutUsd}
</Button>
)}
{s.status === "approved" &&
(payoutStatus === "unpaid" || (payoutStatus === "invoiced" && !paymentAddress)) && (
<Button
size="sm"
disabled={busyId === s.id}
onClick={() => pay(s.id)}
className="gap-1"
>
{busyId === s.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<DollarSign className="h-3 w-3" />
)}
{payoutStatus === "invoiced" ? "Create new payment request" : `Pay $${payoutUsd}`}
</Button>
)}

{s.status === "approved" && payoutStatus === "invoiced" && paymentAddress && (
<div className="w-full pt-2">
Expand Down