Skip to content

Commit 465ce61

Browse files
Guard closed invoice payment events (#495)
1 parent a8f05c0 commit 465ce61

4 files changed

Lines changed: 130 additions & 3 deletions

File tree

src/app/api/payments/coinpayportal/webhook/route.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ describe("POST /api/payments/coinpayportal/webhook", () => {
187187
worker_id: "worker-1",
188188
poster_id: "poster-1",
189189
amount_usd: 3,
190+
status: "sent",
190191
metadata: { payment_address: "SolAddress" },
191192
},
192193
error: null,
@@ -418,6 +419,45 @@ describe("POST /api/payments/coinpayportal/webhook", () => {
418419
expect(notifChain.insert).not.toHaveBeenCalled();
419420
});
420421

422+
it("payment.forwarded does not revive a cancelled invoice into paid", async () => {
423+
const payload = makeWebhookPayload("payment.forwarded", {
424+
payment_id: "cp-inv-cancelled",
425+
tx_hash: "abc123",
426+
merchant_tx_hash: "def456",
427+
currency: "usdc_sol",
428+
amount_crypto: "0.5",
429+
});
430+
431+
const gigInvoiceChain = chainResult({
432+
data: {
433+
id: "inv-cancelled",
434+
status: "cancelled",
435+
application_id: "app-cancelled",
436+
gig_id: "gig-cancelled",
437+
worker_id: "worker-cancelled",
438+
poster_id: "poster-cancelled",
439+
amount_usd: 10,
440+
metadata: { cancelled_at: "2026-07-08T00:00:00Z" },
441+
},
442+
error: null,
443+
});
444+
const appChain = chainResult({ data: null, error: null });
445+
const notifChain = chainResult({ data: null, error: null });
446+
447+
mockFrom.mockImplementation((table: string) => {
448+
if (table === "gig_invoices") return gigInvoiceChain;
449+
if (table === "applications") return appChain;
450+
if (table === "notifications") return notifChain;
451+
return chainResult({ data: null, error: null });
452+
});
453+
454+
const res = await POST(makeRequest(payload, WEBHOOK_SECRET));
455+
expect(res.status).toBe(200);
456+
expect(gigInvoiceChain.update).not.toHaveBeenCalled();
457+
expect(appChain.update).not.toHaveBeenCalled();
458+
expect(notifChain.insert).not.toHaveBeenCalled();
459+
});
460+
421461
it("uses service client (not cookie-based auth)", async () => {
422462
// This test verifies the fix: service client is used for webhooks
423463
// The mock is set up for createServiceClient, not createClient

src/app/api/payments/coinpayportal/webhook/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { getUserDid, onPaymentReceived, onPaymentSent } from "@/lib/reputation-h
66
import { parseGitHubIssueUrl } from "@/lib/github-links";
77
import { updateIssueComment } from "@/lib/github-app";
88

9+
const PAYABLE_GIG_INVOICE_STATUSES = new Set(["sent", "expired"]);
10+
911
// POST /api/payments/coinpayportal/webhook - Handle CoinPayPortal webhooks
1012
export async function POST(request: NextRequest) {
1113
return processCoinPayWebhook(request, [
@@ -606,6 +608,10 @@ async function handleGigInvoicePaymentConfirmed(
606608
return true;
607609
}
608610

611+
if (!PAYABLE_GIG_INVOICE_STATUSES.has(existingInvoice.status)) {
612+
return true;
613+
}
614+
609615
const { data: invoice } = await (supabase as any)
610616
.from("gig_invoices")
611617
.update({
@@ -622,6 +628,7 @@ async function handleGigInvoicePaymentConfirmed(
622628
},
623629
})
624630
.eq("id", existingInvoice.id)
631+
.in("status", Array.from(PAYABLE_GIG_INVOICE_STATUSES))
625632
.select()
626633
.single();
627634

@@ -695,6 +702,10 @@ async function updateGigInvoicePaymentMetadata(
695702

696703
if (!existingInvoice) return false;
697704

705+
if (!PAYABLE_GIG_INVOICE_STATUSES.has(existingInvoice.status)) {
706+
return true;
707+
}
708+
698709
const metadata: Record<string, unknown> = {
699710
...((existingInvoice.metadata || {}) as Record<string, unknown>),
700711
tx_hash: paymentData.tx_hash,
@@ -712,6 +723,7 @@ async function updateGigInvoicePaymentMetadata(
712723
updated_at: new Date().toISOString(),
713724
})
714725
.eq("id", existingInvoice.id)
726+
.in("status", Array.from(PAYABLE_GIG_INVOICE_STATUSES))
715727
.select()
716728
.single();
717729

src/lib/coinpay-payment-sync.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
2-
import { syncBountyPaymentStatus } from "@/lib/coinpay-payment-sync";
2+
import { syncBountyPaymentStatus, syncGigInvoicePaymentStatus } from "@/lib/coinpay-payment-sync";
33
import { getPaymentStatus } from "@/lib/coinpayportal";
44
import { onPaymentReceived, onPaymentSent } from "@/lib/reputation-hooks";
55

@@ -43,6 +43,10 @@ function makeSupabase(initialTables: Record<string, Row[]>) {
4343
if (op === "is" && value === null) filters.push((row) => row[field] !== null);
4444
return chain;
4545
}),
46+
in: vi.fn((field: string, values: unknown[]) => {
47+
filters.push((row) => values.includes(row[field]));
48+
return chain;
49+
}),
4650
order: vi.fn(() => chain),
4751
limit: vi.fn((count: number) => {
4852
limitCount = count;
@@ -195,4 +199,52 @@ describe("coinpay payment sync", () => {
195199
});
196200
});
197201

202+
it("does not mark a rejected gig invoice paid when a stale CoinPay payment confirms", async () => {
203+
vi.mocked(getPaymentStatus).mockResolvedValue({
204+
success: true,
205+
payment: {
206+
id: "cp-invoice-stale",
207+
status: "confirmed",
208+
tx_hash: "tx-stale",
209+
crypto_amount: "0.5",
210+
blockchain: "SOL",
211+
},
212+
});
213+
214+
const { supabase, tables } = makeSupabase({
215+
gig_invoices: [
216+
{
217+
id: "invoice-1",
218+
application_id: "app-1",
219+
gig_id: "gig-1",
220+
worker_id: "worker-1",
221+
poster_id: "poster-1",
222+
status: "rejected",
223+
amount_usd: 10,
224+
coinpay_invoice_id: "cp-invoice-stale",
225+
metadata: { rejection_reason: "duplicate" },
226+
},
227+
],
228+
applications: [{ id: "app-1", status: "accepted" }],
229+
gigs: [{ id: "gig-1", title: "Fix it" }],
230+
notifications: [],
231+
});
232+
233+
const result = await syncGigInvoicePaymentStatus(supabase, "cp-invoice-stale");
234+
235+
expect(result).toMatchObject({
236+
changed: false,
237+
local_status: "rejected",
238+
upstream_status: "confirmed",
239+
});
240+
expect(tables.gig_invoices[0]).toMatchObject({
241+
status: "rejected",
242+
metadata: { rejection_reason: "duplicate" },
243+
});
244+
expect(tables.applications[0]).toMatchObject({ status: "accepted" });
245+
expect(tables.notifications).toHaveLength(0);
246+
expect(onPaymentSent).not.toHaveBeenCalled();
247+
expect(onPaymentReceived).not.toHaveBeenCalled();
248+
});
249+
198250
});

src/lib/coinpay-payment-sync.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ interface SyncResult {
3434
const PAYOUT_PENDING_STATUSES = new Set(["pending", "processing", "detected"]);
3535
const PAYOUT_PAID_STATUSES = new Set(["confirmed", "forwarded"]);
3636
const PAYOUT_FAILED_STATUSES = new Set(["expired", "failed"]);
37+
const PAYABLE_GIG_INVOICE_STATUSES = new Set(["sent", "expired"]);
3738

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

379380
if (PAYOUT_PAID_STATUSES.has(upstreamStatus)) {
381+
if (!PAYABLE_GIG_INVOICE_STATUSES.has(existingInvoice.status)) {
382+
return {
383+
id: existingInvoice.id,
384+
coinpay_payment_id: coinpayPaymentId,
385+
kind: "gig_invoice",
386+
local_status: existingInvoice.status,
387+
upstream_status: upstreamStatus,
388+
changed: false,
389+
};
390+
}
391+
380392
const metadata = {
381393
...existingMetadata,
382394
coinpay_status: upstreamStatus,
@@ -395,7 +407,7 @@ export async function syncGigInvoicePaymentStatus(
395407
updated_at: now,
396408
})
397409
.eq("id", existingInvoice.id)
398-
.neq("status", "paid")
410+
.in("status", Array.from(PAYABLE_GIG_INVOICE_STATUSES))
399411
.select()
400412
.maybeSingle();
401413

@@ -534,12 +546,23 @@ export async function syncGigInvoicePaymentStatus(
534546
id: existingInvoice.id,
535547
coinpay_payment_id: coinpayPaymentId,
536548
kind: "gig_invoice",
537-
local_status: "sent",
549+
local_status: existingInvoice.status,
538550
upstream_status: upstreamStatus,
539551
changed: Boolean(invoice),
540552
};
541553
}
542554

555+
if (!PAYABLE_GIG_INVOICE_STATUSES.has(existingInvoice.status)) {
556+
return {
557+
id: existingInvoice.id,
558+
coinpay_payment_id: coinpayPaymentId,
559+
kind: "gig_invoice",
560+
local_status: existingInvoice.status,
561+
upstream_status: upstreamStatus,
562+
changed: false,
563+
};
564+
}
565+
543566
if (PAYOUT_PENDING_STATUSES.has(upstreamStatus)) {
544567
await (supabase.from("gig_invoices") as any)
545568
.update({

0 commit comments

Comments
 (0)