|
| 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