Skip to content

Commit 8ce63a3

Browse files
ralyodioclaude
andcommitted
feat: self-heal pending credit purchases via CoinPay poll
When the CoinPay webhook is delayed, dropped, or arrives with an unverifiable signature, the local DB row sits at 'pending' forever and the buy-credits modal stares at a spinner. The user paid; we just don't know it yet. Fix: when /api/credits/status finds a pending row, ask CoinPay's API directly (getPaymentStatus). If upstream reports the payment paid/completed/confirmed/succeeded, call the same credit_purchase_complete RPC + receipt-mail flow the webhook would have. If upstream reports failed/expired/cancelled, mark failed. Extracted to lib/credits-finalize.ts so the webhook can switch to the same helpers in a follow-up (current webhook still works as-is with its inline copy — no behavior change there yet). Both completePurchase and the RPC are idempotent, so a webhook arriving after the poll already finalized the row is a no-op. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a19522e commit 8ce63a3

2 files changed

Lines changed: 236 additions & 3 deletions

File tree

‎app/api/credits/status/route.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { NextResponse } from "next/server";
22
import { serviceClient } from "@/lib/supabase/service";
33
import { createClient } from "@/lib/supabase/server";
4+
import { reconcileFromCoinpay } from "@/lib/credits-finalize";
45

56
export const runtime = "nodejs";
67

78
// GET /api/credits/status?payment_id=...
8-
// Returns the local credit_purchases.status — bumped to 'complete' by the
9-
// CoinPay webhook (RPC credit_purchase_complete). Falls back to 'pending'
10-
// while we wait for the webhook. Only the owner of the purchase can poll.
9+
// Returns the local credit_purchases.status, bumped to 'complete' by the
10+
// CoinPay webhook. As a self-heal, if the local row is still 'pending' we
11+
// ask CoinPay directly — if the upstream payment is paid, we run the same
12+
// completion RPC the webhook would. Only the owner of the purchase can poll.
1113
export async function GET(req: Request) {
1214
const { searchParams } = new URL(req.url);
1315
const paymentId = searchParams.get("payment_id");
@@ -31,6 +33,29 @@ export async function GET(req: Request) {
3133
return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 });
3234
}
3335

36+
// If still pending locally, ask CoinPay directly. The webhook may have
37+
// been dropped, delayed, or arrived with an unverifiable signature — we
38+
// shouldn't leave the customer staring at a spinner forever.
39+
if (row.status === "pending") {
40+
try {
41+
await reconcileFromCoinpay({ paymentId });
42+
} catch (err) {
43+
console.error("[credits.status] reconcile failed", err);
44+
}
45+
// Re-read after reconciliation.
46+
const { data: fresh } = await svc
47+
.from("credit_purchases")
48+
.select("status, completed_at, credits_added")
49+
.eq("coinpay_payment_id", paymentId)
50+
.maybeSingle();
51+
return NextResponse.json({
52+
ok: true,
53+
status: fresh?.status ?? "pending",
54+
completed_at: fresh?.completed_at ?? null,
55+
credits_added: fresh?.credits_added ?? row.credits_added,
56+
});
57+
}
58+
3459
return NextResponse.json({
3560
ok: true,
3661
status: row.status,

‎lib/credits-finalize.ts‎

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { serviceClient } from "./supabase/service";
2+
import { getPaymentStatus } from "./coinpay";
3+
import { sendPurchaseReceiptEmail } from "./email";
4+
import { env } from "./env";
5+
import { findPack } from "./credits";
6+
7+
// CoinPay status / event vocabulary that maps to our local state machine.
8+
// Both the webhook and the polling endpoint reference these sets so a single
9+
// rename in the upstream API only needs editing here.
10+
export const COMPLETE_EVENTS = new Set([
11+
"payment.completed",
12+
"payment.confirmed",
13+
"payment.paid",
14+
"payment.succeeded",
15+
]);
16+
export const COMPLETE_STATUSES = new Set([
17+
"completed",
18+
"confirmed",
19+
"paid",
20+
"succeeded",
21+
]);
22+
export const FAIL_EVENTS = new Set([
23+
"payment.failed",
24+
"payment.expired",
25+
"payment.cancelled",
26+
]);
27+
export const FAIL_STATUSES = new Set([
28+
"failed",
29+
"expired",
30+
"cancelled",
31+
"canceled",
32+
]);
33+
34+
export function classifyCoinpayStatus(
35+
status: string | null | undefined,
36+
): "complete" | "failed" | "pending" {
37+
const s = (status ?? "").toLowerCase();
38+
if (COMPLETE_STATUSES.has(s)) return "complete";
39+
if (FAIL_STATUSES.has(s)) return "failed";
40+
return "pending";
41+
}
42+
43+
type SvcClient = ReturnType<typeof serviceClient>;
44+
45+
// Mark a CoinPay purchase complete and email a PDF receipt. Idempotent —
46+
// safe to call from both the webhook and the polling endpoint, and re-runs
47+
// won't double-credit (the SQL RPC short-circuits on already-complete rows)
48+
// or double-email (receipt_emailed_at gate).
49+
export async function completePurchase(input: {
50+
svc: SvcClient;
51+
paymentId: string;
52+
event: Record<string, unknown>;
53+
txHash?: string | null;
54+
}): Promise<void> {
55+
const { svc, paymentId, event, txHash } = input;
56+
57+
const { error } = await svc.rpc("credit_purchase_complete", {
58+
p_payment_id: paymentId,
59+
p_event: event,
60+
});
61+
if (error) {
62+
console.error("[coinpay] credit_purchase_complete failed", error);
63+
throw new Error(error.message);
64+
}
65+
66+
try {
67+
await mailReceipt(svc, paymentId, txHash ?? null);
68+
} catch (err) {
69+
// Receipt is best-effort. Credits are already granted; never fail
70+
// upstream callers (webhook retries / poll loops) on a mail glitch.
71+
console.error("[coinpay] receipt mail failed", err);
72+
}
73+
}
74+
75+
export async function failPurchase(input: {
76+
svc: SvcClient;
77+
paymentId: string;
78+
event: Record<string, unknown>;
79+
}): Promise<void> {
80+
const { svc, paymentId, event } = input;
81+
await svc
82+
.from("credit_purchases")
83+
.update({ status: "failed", coinpay_event: event })
84+
.eq("coinpay_payment_id", paymentId);
85+
}
86+
87+
// Polling-endpoint entrypoint: if local DB still says pending, ask CoinPay
88+
// directly and reconcile. Returns the final local status.
89+
export async function reconcileFromCoinpay(input: {
90+
paymentId: string;
91+
}): Promise<"complete" | "failed" | "pending" | "not_found"> {
92+
const svc = serviceClient();
93+
const { data: row } = await svc
94+
.from("credit_purchases")
95+
.select("status, coinpay_payment_id")
96+
.eq("coinpay_payment_id", input.paymentId)
97+
.maybeSingle();
98+
if (!row) return "not_found";
99+
if (row.status === "complete" || row.status === "failed") {
100+
return row.status as "complete" | "failed";
101+
}
102+
103+
const remote = await getPaymentStatus(input.paymentId);
104+
if (!remote) return "pending";
105+
106+
const classified = classifyCoinpayStatus(remote.status);
107+
if (classified === "complete") {
108+
await completePurchase({
109+
svc,
110+
paymentId: input.paymentId,
111+
event: { source: "poll_fallback", remote },
112+
txHash: remote.txHash,
113+
});
114+
return "complete";
115+
}
116+
if (classified === "failed") {
117+
await failPurchase({
118+
svc,
119+
paymentId: input.paymentId,
120+
event: { source: "poll_fallback", remote },
121+
});
122+
return "failed";
123+
}
124+
return "pending";
125+
}
126+
127+
async function mailReceipt(
128+
svc: SvcClient,
129+
paymentId: string,
130+
txHash: string | null,
131+
): Promise<void> {
132+
const { data: purchase, error } = await svc
133+
.from("credit_purchases")
134+
.select(
135+
"id, owner_id, pack_id, credits_added, amount_cents, currency, completed_at, receipt_emailed_at",
136+
)
137+
.eq("coinpay_payment_id", paymentId)
138+
.maybeSingle();
139+
if (error || !purchase) return;
140+
if (purchase.receipt_emailed_at) return;
141+
142+
const { data: profile } = await svc
143+
.from("profiles")
144+
.select("email")
145+
.eq("id", purchase.owner_id)
146+
.maybeSingle();
147+
let to = profile?.email as string | null | undefined;
148+
if (!to) {
149+
const { data: userRes } = await svc.auth.admin.getUserById(
150+
purchase.owner_id as string,
151+
);
152+
to = userRes?.user?.email ?? null;
153+
}
154+
if (!to) return;
155+
156+
if (!env.workerUrl || !env.workerSecret) return;
157+
158+
const pack = findPack(purchase.pack_id as string);
159+
const packLabel = pack?.label ?? purchase.pack_id;
160+
const completedAt = purchase.completed_at ?? new Date().toISOString();
161+
const amount = `$${(purchase.amount_cents / 100).toLocaleString(undefined, {
162+
minimumFractionDigits: purchase.amount_cents % 100 === 0 ? 0 : 2,
163+
})}`;
164+
const txLine = txHash ? `\n**Transaction:** \`${txHash}\`\n` : "";
165+
166+
const markdown = `# Receipt
167+
168+
Thanks for your purchase! Your credits are ready to use.
169+
170+
| | |
171+
|---|---|
172+
| **Order** | \`${paymentId}\` |
173+
| **Date** | ${new Date(completedAt).toUTCString()} |
174+
| **Pack** | ${packLabel} |
175+
| **Credits added** | ${purchase.credits_added} |
176+
| **Total paid** | ${amount} ${purchase.currency} |
177+
| **Payment method** | Crypto (CoinPay) |
178+
${txLine}
179+
180+
Sign in to your CrawlProof dashboard to start a scan. Questions? Just reply to this email.
181+
`;
182+
183+
const workerRes = await fetch(`${env.workerUrl}/pdf`, {
184+
method: "POST",
185+
headers: { "content-type": "application/json", "x-worker-secret": env.workerSecret },
186+
body: JSON.stringify({ markdown, title: "CrawlProof receipt" }),
187+
});
188+
if (!workerRes.ok) return;
189+
const pdf = Buffer.from(await workerRes.arrayBuffer());
190+
191+
const { sent } = await sendPurchaseReceiptEmail({
192+
to,
193+
paymentId,
194+
packLabel,
195+
creditsAdded: purchase.credits_added as number,
196+
amountCents: purchase.amount_cents as number,
197+
currency: purchase.currency as string,
198+
txHash,
199+
completedAt,
200+
pdf,
201+
});
202+
if (!sent) return;
203+
204+
await svc
205+
.from("credit_purchases")
206+
.update({ receipt_emailed_at: new Date().toISOString() })
207+
.eq("id", purchase.id);
208+
}

0 commit comments

Comments
 (0)