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
40 changes: 40 additions & 0 deletions src/app/api/payments/coinpayportal/webhook/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ describe("POST /api/payments/coinpayportal/webhook", () => {
worker_id: "worker-1",
poster_id: "poster-1",
amount_usd: 3,
status: "sent",
metadata: { payment_address: "SolAddress" },
},
error: null,
Expand Down Expand Up @@ -418,6 +419,45 @@ describe("POST /api/payments/coinpayportal/webhook", () => {
expect(notifChain.insert).not.toHaveBeenCalled();
});

it("payment.forwarded does not revive a cancelled invoice into paid", async () => {
const payload = makeWebhookPayload("payment.forwarded", {
payment_id: "cp-inv-cancelled",
tx_hash: "abc123",
merchant_tx_hash: "def456",
currency: "usdc_sol",
amount_crypto: "0.5",
});

const gigInvoiceChain = chainResult({
data: {
id: "inv-cancelled",
status: "cancelled",
application_id: "app-cancelled",
gig_id: "gig-cancelled",
worker_id: "worker-cancelled",
poster_id: "poster-cancelled",
amount_usd: 10,
metadata: { cancelled_at: "2026-07-08T00:00:00Z" },
},
error: null,
});
const appChain = chainResult({ data: null, error: null });
const notifChain = chainResult({ data: null, error: null });

mockFrom.mockImplementation((table: string) => {
if (table === "gig_invoices") return gigInvoiceChain;
if (table === "applications") return appChain;
if (table === "notifications") return notifChain;
return chainResult({ data: null, error: null });
});

const res = await POST(makeRequest(payload, WEBHOOK_SECRET));
expect(res.status).toBe(200);
expect(gigInvoiceChain.update).not.toHaveBeenCalled();
expect(appChain.update).not.toHaveBeenCalled();
expect(notifChain.insert).not.toHaveBeenCalled();
});

it("uses service client (not cookie-based auth)", async () => {
// This test verifies the fix: service client is used for webhooks
// The mock is set up for createServiceClient, not createClient
Expand Down
12 changes: 12 additions & 0 deletions src/app/api/payments/coinpayportal/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { getUserDid, onPaymentReceived, onPaymentSent } from "@/lib/reputation-h
import { parseGitHubIssueUrl } from "@/lib/github-links";
import { updateIssueComment } from "@/lib/github-app";

const PAYABLE_GIG_INVOICE_STATUSES = new Set(["sent", "expired"]);

// POST /api/payments/coinpayportal/webhook - Handle CoinPayPortal webhooks
export async function POST(request: NextRequest) {
return processCoinPayWebhook(request, [
Expand Down Expand Up @@ -606,6 +608,10 @@ async function handleGigInvoicePaymentConfirmed(
return true;
}

if (!PAYABLE_GIG_INVOICE_STATUSES.has(existingInvoice.status)) {
return true;
}

const { data: invoice } = await (supabase as any)
.from("gig_invoices")
.update({
Expand All @@ -622,6 +628,7 @@ async function handleGigInvoicePaymentConfirmed(
},
})
.eq("id", existingInvoice.id)
.in("status", Array.from(PAYABLE_GIG_INVOICE_STATUSES))
.select()
.single();

Expand Down Expand Up @@ -695,6 +702,10 @@ async function updateGigInvoicePaymentMetadata(

if (!existingInvoice) return false;

if (!PAYABLE_GIG_INVOICE_STATUSES.has(existingInvoice.status)) {
return true;
}

const metadata: Record<string, unknown> = {
...((existingInvoice.metadata || {}) as Record<string, unknown>),
tx_hash: paymentData.tx_hash,
Expand All @@ -712,6 +723,7 @@ async function updateGigInvoicePaymentMetadata(
updated_at: new Date().toISOString(),
})
.eq("id", existingInvoice.id)
.in("status", Array.from(PAYABLE_GIG_INVOICE_STATUSES))
.select()
.single();

Expand Down
54 changes: 53 additions & 1 deletion src/lib/coinpay-payment-sync.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { syncBountyPaymentStatus } from "@/lib/coinpay-payment-sync";
import { syncBountyPaymentStatus, syncGigInvoicePaymentStatus } from "@/lib/coinpay-payment-sync";
import { getPaymentStatus } from "@/lib/coinpayportal";
import { onPaymentReceived, onPaymentSent } from "@/lib/reputation-hooks";

Expand Down Expand Up @@ -43,6 +43,10 @@ function makeSupabase(initialTables: Record<string, Row[]>) {
if (op === "is" && value === null) filters.push((row) => row[field] !== null);
return chain;
}),
in: vi.fn((field: string, values: unknown[]) => {
filters.push((row) => values.includes(row[field]));
return chain;
}),
order: vi.fn(() => chain),
limit: vi.fn((count: number) => {
limitCount = count;
Expand Down Expand Up @@ -195,4 +199,52 @@ describe("coinpay payment sync", () => {
});
});

it("does not mark a rejected gig invoice paid when a stale CoinPay payment confirms", async () => {
vi.mocked(getPaymentStatus).mockResolvedValue({
success: true,
payment: {
id: "cp-invoice-stale",
status: "confirmed",
tx_hash: "tx-stale",
crypto_amount: "0.5",
blockchain: "SOL",
},
});

const { supabase, tables } = makeSupabase({
gig_invoices: [
{
id: "invoice-1",
application_id: "app-1",
gig_id: "gig-1",
worker_id: "worker-1",
poster_id: "poster-1",
status: "rejected",
amount_usd: 10,
coinpay_invoice_id: "cp-invoice-stale",
metadata: { rejection_reason: "duplicate" },
},
],
applications: [{ id: "app-1", status: "accepted" }],
gigs: [{ id: "gig-1", title: "Fix it" }],
notifications: [],
});

const result = await syncGigInvoicePaymentStatus(supabase, "cp-invoice-stale");

expect(result).toMatchObject({
changed: false,
local_status: "rejected",
upstream_status: "confirmed",
});
expect(tables.gig_invoices[0]).toMatchObject({
status: "rejected",
metadata: { rejection_reason: "duplicate" },
});
expect(tables.applications[0]).toMatchObject({ status: "accepted" });
expect(tables.notifications).toHaveLength(0);
expect(onPaymentSent).not.toHaveBeenCalled();
expect(onPaymentReceived).not.toHaveBeenCalled();
});

});
27 changes: 25 additions & 2 deletions src/lib/coinpay-payment-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface SyncResult {
const PAYOUT_PENDING_STATUSES = new Set(["pending", "processing", "detected"]);
const PAYOUT_PAID_STATUSES = new Set(["confirmed", "forwarded"]);
const PAYOUT_FAILED_STATUSES = new Set(["expired", "failed"]);
const PAYABLE_GIG_INVOICE_STATUSES = new Set(["sent", "expired"]);

function normalizeStatus(status?: string | null): SyncStatus | string {
return (status || "pending").toLowerCase();
Expand Down Expand Up @@ -377,6 +378,17 @@ export async function syncGigInvoicePaymentStatus(
const now = new Date().toISOString();

if (PAYOUT_PAID_STATUSES.has(upstreamStatus)) {
if (!PAYABLE_GIG_INVOICE_STATUSES.has(existingInvoice.status)) {
return {
id: existingInvoice.id,
coinpay_payment_id: coinpayPaymentId,
kind: "gig_invoice",
local_status: existingInvoice.status,
upstream_status: upstreamStatus,
changed: false,
};
}

const metadata = {
...existingMetadata,
coinpay_status: upstreamStatus,
Expand All @@ -395,7 +407,7 @@ export async function syncGigInvoicePaymentStatus(
updated_at: now,
})
.eq("id", existingInvoice.id)
.neq("status", "paid")
.in("status", Array.from(PAYABLE_GIG_INVOICE_STATUSES))
.select()
.maybeSingle();

Expand Down Expand Up @@ -534,12 +546,23 @@ export async function syncGigInvoicePaymentStatus(
id: existingInvoice.id,
coinpay_payment_id: coinpayPaymentId,
kind: "gig_invoice",
local_status: "sent",
local_status: existingInvoice.status,
upstream_status: upstreamStatus,
changed: Boolean(invoice),
};
}

if (!PAYABLE_GIG_INVOICE_STATUSES.has(existingInvoice.status)) {
return {
id: existingInvoice.id,
coinpay_payment_id: coinpayPaymentId,
kind: "gig_invoice",
local_status: existingInvoice.status,
upstream_status: upstreamStatus,
changed: false,
};
}

if (PAYOUT_PENDING_STATUSES.has(upstreamStatus)) {
await (supabase.from("gig_invoices") as any)
.update({
Expand Down
Loading