Skip to content

Commit 0684fc8

Browse files
Guard escrow webhook state transitions
1 parent 465ce61 commit 0684fc8

2 files changed

Lines changed: 197 additions & 29 deletions

File tree

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

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,133 @@ describe("POST /api/payments/coinpayportal/webhook", () => {
458458
expect(notifChain.insert).not.toHaveBeenCalled();
459459
});
460460

461+
it("escrow.funded transitions a pending escrow and sends funded notifications", async () => {
462+
const payload = makeWebhookPayload("escrow.funded", {
463+
payment_id: "cp-escrow-pending",
464+
status: "funded",
465+
});
466+
467+
const escrowFetchChain = chainResult({
468+
data: {
469+
id: "escrow-pending",
470+
coinpay_escrow_id: "cp-escrow-pending",
471+
status: "pending_payment",
472+
application_id: "app-escrow",
473+
gig_id: "gig-escrow",
474+
worker_id: "worker-escrow",
475+
poster_id: "poster-escrow",
476+
amount_usd: 20,
477+
},
478+
error: null,
479+
});
480+
const escrowUpdateChain = chainResult({ data: { id: "escrow-pending" }, error: null });
481+
const appChain = chainResult({ data: null, error: null });
482+
const gigChain = chainResult({ data: { title: "Escrow bugfix" }, error: null });
483+
const notifChain = chainResult({ data: null, error: null });
484+
let escrowCalls = 0;
485+
486+
mockFrom.mockImplementation((table: string) => {
487+
if (table === "gig_escrows") {
488+
escrowCalls += 1;
489+
return escrowCalls === 1 ? escrowFetchChain : escrowUpdateChain;
490+
}
491+
if (table === "applications") return appChain;
492+
if (table === "gigs") return gigChain;
493+
if (table === "notifications") return notifChain;
494+
return chainResult({ data: null, error: null });
495+
});
496+
497+
const res = await POST(makeRequest(payload, WEBHOOK_SECRET));
498+
expect(res.status).toBe(200);
499+
expect(escrowUpdateChain.update).toHaveBeenCalledWith(
500+
expect.objectContaining({ status: "funded" })
501+
);
502+
expect(escrowUpdateChain.eq).toHaveBeenCalledWith("status", "pending_payment");
503+
expect(appChain.update).toHaveBeenCalledWith(
504+
expect.objectContaining({ status: "in_progress" })
505+
);
506+
expect(notifChain.insert).toHaveBeenCalledTimes(2);
507+
});
508+
509+
it("escrow.funded does not downgrade an already released escrow", async () => {
510+
const payload = makeWebhookPayload("escrow.funded", {
511+
payment_id: "cp-escrow-released",
512+
status: "funded",
513+
});
514+
515+
const escrowFetchChain = chainResult({
516+
data: {
517+
id: "escrow-released",
518+
coinpay_escrow_id: "cp-escrow-released",
519+
status: "released",
520+
application_id: "app-escrow",
521+
gig_id: "gig-escrow",
522+
worker_id: "worker-escrow",
523+
poster_id: "poster-escrow",
524+
amount_usd: 20,
525+
},
526+
error: null,
527+
});
528+
const escrowUpdateChain = chainResult({ data: null, error: null });
529+
const appChain = chainResult({ data: null, error: null });
530+
const notifChain = chainResult({ data: null, error: null });
531+
let escrowCalls = 0;
532+
533+
mockFrom.mockImplementation((table: string) => {
534+
if (table === "gig_escrows") {
535+
escrowCalls += 1;
536+
return escrowCalls === 1 ? escrowFetchChain : escrowUpdateChain;
537+
}
538+
if (table === "applications") return appChain;
539+
if (table === "notifications") return notifChain;
540+
return chainResult({ data: null, error: null });
541+
});
542+
543+
const res = await POST(makeRequest(payload, WEBHOOK_SECRET));
544+
expect(res.status).toBe(200);
545+
expect(escrowUpdateChain.update).not.toHaveBeenCalled();
546+
expect(appChain.update).not.toHaveBeenCalled();
547+
expect(notifChain.insert).not.toHaveBeenCalled();
548+
});
549+
550+
it("escrow.refunded does not override an already released escrow", async () => {
551+
const payload = makeWebhookPayload("escrow.refunded", {
552+
payment_id: "cp-escrow-released",
553+
status: "refunded",
554+
});
555+
556+
const escrowFetchChain = chainResult({
557+
data: {
558+
id: "escrow-released",
559+
coinpay_escrow_id: "cp-escrow-released",
560+
status: "released",
561+
application_id: "app-escrow",
562+
gig_id: "gig-escrow",
563+
worker_id: "worker-escrow",
564+
poster_id: "poster-escrow",
565+
amount_usd: 20,
566+
},
567+
error: null,
568+
});
569+
const escrowUpdateChain = chainResult({ data: null, error: null });
570+
const notifChain = chainResult({ data: null, error: null });
571+
let escrowCalls = 0;
572+
573+
mockFrom.mockImplementation((table: string) => {
574+
if (table === "gig_escrows") {
575+
escrowCalls += 1;
576+
return escrowCalls === 1 ? escrowFetchChain : escrowUpdateChain;
577+
}
578+
if (table === "notifications") return notifChain;
579+
return chainResult({ data: null, error: null });
580+
});
581+
582+
const res = await POST(makeRequest(payload, WEBHOOK_SECRET));
583+
expect(res.status).toBe(200);
584+
expect(escrowUpdateChain.update).not.toHaveBeenCalled();
585+
expect(notifChain.insert).not.toHaveBeenCalled();
586+
});
587+
461588
it("uses service client (not cookie-based auth)", async () => {
462589
// This test verifies the fix: service client is used for webhooks
463590
// The mock is set up for createServiceClient, not createClient

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

Lines changed: 70 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { parseGitHubIssueUrl } from "@/lib/github-links";
77
import { updateIssueComment } from "@/lib/github-app";
88

99
const PAYABLE_GIG_INVOICE_STATUSES = new Set(["sent", "expired"]);
10+
const ESCROW_FUNDABLE_STATUSES = ["pending_payment"] as const;
11+
const ESCROW_RELEASABLE_STATUSES = ["funded"] as const;
12+
const ESCROW_REFUNDABLE_STATUSES = ["pending_payment", "funded", "disputed"] as const;
1013

1114
// POST /api/payments/coinpayportal/webhook - Handle CoinPayPortal webhooks
1215
export async function POST(request: NextRequest) {
@@ -747,6 +750,31 @@ async function updateGigInvoicePaymentMetadata(
747750

748751
// ─── Escrow webhook handlers ───────────────────────────────────────────────
749752

753+
async function transitionEscrowStatus(
754+
supabase: ReturnType<typeof createServiceClient>,
755+
escrowId: string,
756+
allowedStatuses: readonly string[],
757+
update: Record<string, unknown>
758+
): Promise<boolean> {
759+
let query = (supabase as any)
760+
.from("gig_escrows")
761+
.update(update)
762+
.eq("id", escrowId);
763+
764+
query =
765+
allowedStatuses.length === 1
766+
? query.eq("status", allowedStatuses[0])
767+
: query.in("status", Array.from(allowedStatuses));
768+
769+
const { data, error } = await query.select("id").single();
770+
771+
if (error || !data) {
772+
return false;
773+
}
774+
775+
return true;
776+
}
777+
750778
async function handleEscrowFunded(
751779
supabase: ReturnType<typeof createServiceClient>,
752780
payload: CoinPayWebhookPayload
@@ -766,15 +794,19 @@ async function handleEscrowFunded(
766794
return;
767795
}
768796

769-
// Update escrow status
770-
await (supabase as any)
771-
.from("gig_escrows")
772-
.update({
797+
if (!ESCROW_FUNDABLE_STATUSES.includes(escrow.status as any)) return;
798+
799+
const transitioned = await transitionEscrowStatus(
800+
supabase,
801+
escrow.id,
802+
ESCROW_FUNDABLE_STATUSES,
803+
{
773804
status: "funded",
774805
funded_at: now,
775806
updated_at: now,
776-
})
777-
.eq("id", escrow.id);
807+
}
808+
);
809+
if (!transitioned) return;
778810

779811
// Update application status to in_progress
780812
await supabase
@@ -835,25 +867,29 @@ async function handleEscrowReleased(
835867
return;
836868
}
837869

838-
// Update if not already released (release route may have already updated)
839-
if (escrow.status !== "released") {
840-
await (supabase as any)
841-
.from("gig_escrows")
842-
.update({
843-
status: "released",
844-
released_at: now,
845-
updated_at: now,
846-
})
847-
.eq("id", escrow.id);
870+
// Release route may have already updated this record before the webhook arrives.
871+
if (escrow.status === "released") return;
872+
if (!ESCROW_RELEASABLE_STATUSES.includes(escrow.status as any)) return;
848873

849-
await supabase
850-
.from("applications")
851-
.update({
852-
status: "completed" as any,
853-
updated_at: now,
854-
})
855-
.eq("id", escrow.application_id);
856-
}
874+
const transitioned = await transitionEscrowStatus(
875+
supabase,
876+
escrow.id,
877+
ESCROW_RELEASABLE_STATUSES,
878+
{
879+
status: "released",
880+
released_at: now,
881+
updated_at: now,
882+
}
883+
);
884+
if (!transitioned) return;
885+
886+
await supabase
887+
.from("applications")
888+
.update({
889+
status: "completed" as any,
890+
updated_at: now,
891+
})
892+
.eq("id", escrow.application_id);
857893
}
858894

859895
async function handleEscrowRefunded(
@@ -874,13 +910,18 @@ async function handleEscrowRefunded(
874910
return;
875911
}
876912

877-
await (supabase as any)
878-
.from("gig_escrows")
879-
.update({
913+
if (!ESCROW_REFUNDABLE_STATUSES.includes(escrow.status as any)) return;
914+
915+
const transitioned = await transitionEscrowStatus(
916+
supabase,
917+
escrow.id,
918+
ESCROW_REFUNDABLE_STATUSES,
919+
{
880920
status: "refunded",
881921
updated_at: now,
882-
})
883-
.eq("id", escrow.id);
922+
}
923+
);
924+
if (!transitioned) return;
884925

885926
// Notify poster
886927
await supabase.from("notifications").insert({

0 commit comments

Comments
 (0)