diff --git a/src/app/api/payments/coinpayportal/webhook/route.test.ts b/src/app/api/payments/coinpayportal/webhook/route.test.ts index 8e298869..d575b58c 100644 --- a/src/app/api/payments/coinpayportal/webhook/route.test.ts +++ b/src/app/api/payments/coinpayportal/webhook/route.test.ts @@ -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, @@ -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 diff --git a/src/app/api/payments/coinpayportal/webhook/route.ts b/src/app/api/payments/coinpayportal/webhook/route.ts index 566b0207..5d24922c 100644 --- a/src/app/api/payments/coinpayportal/webhook/route.ts +++ b/src/app/api/payments/coinpayportal/webhook/route.ts @@ -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, [ @@ -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({ @@ -622,6 +628,7 @@ async function handleGigInvoicePaymentConfirmed( }, }) .eq("id", existingInvoice.id) + .in("status", Array.from(PAYABLE_GIG_INVOICE_STATUSES)) .select() .single(); @@ -695,6 +702,10 @@ async function updateGigInvoicePaymentMetadata( if (!existingInvoice) return false; + if (!PAYABLE_GIG_INVOICE_STATUSES.has(existingInvoice.status)) { + return true; + } + const metadata: Record = { ...((existingInvoice.metadata || {}) as Record), tx_hash: paymentData.tx_hash, @@ -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(); diff --git a/src/lib/coinpay-payment-sync.test.ts b/src/lib/coinpay-payment-sync.test.ts index 9008be27..43029e55 100644 --- a/src/lib/coinpay-payment-sync.test.ts +++ b/src/lib/coinpay-payment-sync.test.ts @@ -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"; @@ -43,6 +43,10 @@ function makeSupabase(initialTables: Record) { 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; @@ -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(); + }); + }); diff --git a/src/lib/coinpay-payment-sync.ts b/src/lib/coinpay-payment-sync.ts index 3b3e7638..9af4200a 100644 --- a/src/lib/coinpay-payment-sync.ts +++ b/src/lib/coinpay-payment-sync.ts @@ -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(); @@ -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, @@ -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(); @@ -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({