Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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,
})
);
});
});
94 changes: 62 additions & 32 deletions src/app/api/bounties/[id]/submissions/[sid]/pay/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> }
Expand Down Expand Up @@ -56,19 +55,13 @@ export async function POST(
}

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: {
Expand All @@ -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,
Expand All @@ -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",
},
});
Expand Down Expand Up @@ -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,
},
Expand Down
Loading