diff --git a/src/app/api/numbers/orders/[id]/reopen/route.ts b/src/app/api/numbers/orders/[id]/reopen/route.ts new file mode 100644 index 00000000..f8868f7f --- /dev/null +++ b/src/app/api/numbers/orders/[id]/reopen/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { createServiceClient } from "@/lib/supabase/server"; +import { resolveUser } from "@/lib/request-user"; +import { OrderError, reopenOrder } from "@/lib/managed-numbers/service"; +import { getBrandSiteUrl } from "@/lib/brand-server"; +import { checkRateLimit } from "@/lib/rate-limit"; + +/** Finish an expired order: { chain? } -> a fresh checkout for the same number and months. */ +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const who = await resolveUser(request); + if ("error" in who) return NextResponse.json({ error: who.error }, { status: who.status }); + const rl = checkRateLimit(`number-order:${who.user.userId}`, { limit: 10, windowMs: 60 * 60 * 1000 }); + if (!rl.allowed) return NextResponse.json({ error: "Too many checkouts, try again later" }, { status: 429 }); + const { id } = await params; + const body = (await request.json().catch(() => ({}))) as Record; + try { + const order = await reopenOrder( + { db: createServiceClient() }, + { + userId: who.user.userId, + email: who.user.email, + orderId: id, + chain: body.chain, + siteUrl: await getBrandSiteUrl(request), + } + ); + return NextResponse.json({ order }, { status: 201 }); + } catch (e) { + if (e instanceof OrderError) return NextResponse.json({ error: e.message }, { status: e.status }); + console.error("reopen number order error:", e); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/inbox/page.tsx b/src/app/inbox/page.tsx index f39afe00..bbd6d071 100644 --- a/src/app/inbox/page.tsx +++ b/src/app/inbox/page.tsx @@ -4,6 +4,7 @@ import { InboxClient } from "@/components/inbox-client"; import { StationInbox } from "@/components/station/station-inbox"; import { getBrand } from "@/lib/brand-server"; import { priceUsdPerMonth } from "@/lib/plans"; +import { unfinishedOrder } from "@/lib/managed-numbers/recovery"; export default async function InboxPage() { const supabase = await createServerSupabaseClient(); @@ -36,6 +37,15 @@ export default async function InboxPage() { .eq("status", "active") .order("created_at", { ascending: true }); + // An order that expired unpaid gets a one-click "Finish your order" here. + const { data: orders } = await supabase + .from("number_orders") + .select("id, kind, status, area_code, months, amount_usd, chain, error, created_at") + .eq("user_id", user.id) + .order("created_at", { ascending: false }) + .limit(5); + const unfinished = unfinishedOrder(orders || []); + if ((await getBrand()).id === "numberstation") { return ; } @@ -46,6 +56,11 @@ export default async function InboxPage() { numbers={numbers || []} userId={user.id} rentPriceUsd={priceUsdPerMonth()} + unfinishedOrder={ + unfinished + ? { id: unfinished.id, amountUsd: Number(unfinished.amount_usd), months: unfinished.months, chain: unfinished.chain } + : null + } /> ); } diff --git a/src/app/numbers/page.tsx b/src/app/numbers/page.tsx index 56dea04b..d8f8a5d0 100644 --- a/src/app/numbers/page.tsx +++ b/src/app/numbers/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import { LineNameEditor } from "@/components/line-name-editor"; import { CHECKOUT_WINDOW_MINUTES, checkoutWindowOpen } from "@/lib/coinpay/window"; +import { safePayUrl, unfinishedOrder } from "@/lib/managed-numbers/recovery"; interface NumberRow { id: string; @@ -57,6 +58,13 @@ const CHAIN_LABELS: Record = { SOL: "SOL", ETH: "ETH", BTC: "BTC", + USDC_BASE: "USDC (Base)", + USDT_TRON: "USDT (TRON)", + BCH: "Bitcoin Cash (BCH)", + DOGE: "Dogecoin (DOGE)", + XRP: "XRP", + ADA: "Cardano (ADA)", + BNB: "BNB (BNB Smart Chain)", }; const STATUS_TEXT: Record = { @@ -85,18 +93,6 @@ function statusText(o: OrderRow, now: Date): string { return STATUS_TEXT[o.status]; } -/** Only ever send the browser to CoinPay's own payment page. */ -function safePayUrl(raw: unknown): string | null { - try { - const u = new URL(String(raw)); - return u.protocol === "https:" && u.hostname === "coinpayportal.com" && u.pathname.startsWith("/pay/") - ? u.toString() - : null; - } catch { - return null; - } -} - function fmtDate(iso: string | null) { return iso ? new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) : ""; } @@ -199,10 +195,12 @@ export default function NumbersPage() { const rented = data.numbers.filter((n) => n.managed); // A checkout still inside its window: send people back to it instead of opening another. const openCheckout = data.orders.find((o) => payable(o, now) && safePayUrl(o.pay_url)); - const retry = (o: OrderRow) => - o.kind === "renew" && o.phone_number_id - ? checkout(`/api/numbers/${o.phone_number_id}/renew`, { months: o.months, chain: o.chain }) - : checkout("/api/numbers/orders", { area_code: o.area_code ?? "", months: o.months, chain: o.chain }); + // One click: the same number and months, a fresh checkout window. + const retry = (o: OrderRow, method?: string) => + checkout(`/api/numbers/orders/${o.id}/reopen`, method ? { chain: method } : {}); + const unfinished = !openCheckout && data.ordering.open ? unfinishedOrder(data.orders, now) : null; + const coins = data.pricing.chains.filter((c) => c !== "PAYPAL"); + const payByPaypal = chain === "PAYPAL"; return (
@@ -238,6 +236,36 @@ export default function NumbersPage() {
)} + {unfinished && unfinished.id !== watched?.id && ( +
+
+ Your order for {unfinished.kind === "renew" ? "a renewal" : unfinished.area_code ? `a ${unfinished.area_code} number` : "a US number"}{" "} + (${Number(unfinished.amount_usd).toFixed(2)}, {unfinished.months} mo) was not paid before the checkout closed. + Nothing was charged. Pick it up where you left off: +
+
+ + {unfinished.chain !== "PAYPAL" && ( + + )} +
+
+ )} + {error && (
{error}
)} @@ -320,8 +348,8 @@ export default function NumbersPage() {

Rent a number

- A US number that receives texts and verification codes, ${price}/month, paid in crypto or with PayPal - through CoinPay. + A US number that receives texts and verification codes, ${price}/month, paid with PayPal or in + any of {coins.length} cryptocurrencies through CoinPay. It is set up as soon as the payment confirms. Rented numbers receive only; some services refuse virtual numbers.

@@ -341,8 +369,8 @@ export default function NumbersPage() { PayPal's page. The number is set up once PayPal confirms the payment.
  • - The payment page stays open for {CHECKOUT_WINDOW_MINUTES} minutes. If it closes before you pay, start - a new checkout. If you already sent the coins, do not pay again: a late payment is still credited, + The payment page stays open for {CHECKOUT_WINDOW_MINUTES} minutes. If it closes before you pay, press + Finish your order here and you get a fresh one in one click. If you already sent the coins, do not pay again: a late payment is still credited, it only takes longer.
  • @@ -350,6 +378,31 @@ export default function NumbersPage() { {!data.ordering.open ? (

    {data.ordering.reason}

    ) : ( +
    +
    + + +
    { e.preventDefault(); @@ -375,25 +428,33 @@ export default function NumbersPage() { ))} - + {payByPaypal ? ( +
    + PayPal +
    + ) : ( + + )}
    +
    )}

    Plan: {data.plan.plan === "pro" ? "Pro" : "Free"}. Renting a number unlocks Pro limits while it is active. @@ -429,7 +490,7 @@ export default function NumbersPage() { onClick={() => retry(o)} className="ml-2 underline disabled:opacity-50" > - Start a new checkout + Finish this order )} diff --git a/src/components/inbox-client.tsx b/src/components/inbox-client.tsx index bc60366d..e4c8e0f3 100644 --- a/src/components/inbox-client.tsx +++ b/src/components/inbox-client.tsx @@ -7,6 +7,64 @@ import { NewMessageModal } from "./new-message-modal"; import { ContactNameEditor } from "./contact-name-editor"; import { Logo } from "./logo"; import type { LineNumber } from "@/lib/inbox-lines"; +import { safePayUrl } from "@/lib/managed-numbers/recovery"; + +interface UnfinishedOrder { + id: string; + amountUsd: number; + months: number; + chain: string; +} + +/** One click back into an expired, unpaid number order. */ +function FinishOrderBanner({ order }: { order: UnfinishedOrder }) { + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const go = async (chain?: string) => { + setBusy(true); + setErr(null); + try { + const res = await fetch(`/api/numbers/orders/${order.id}/reopen`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(chain ? { chain } : {}), + }); + const j = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(j.error || "Could not reopen the checkout"); + const pay = safePayUrl(j.order?.pay_url); + window.location.href = pay || "/numbers"; + } catch (e) { + setErr(e instanceof Error ? e.message : "Could not reopen the checkout"); + setBusy(false); + } + }; + return ( +

    + + Your ${order.amountUsd.toFixed(2)} number order ({order.months} mo) was not paid before the checkout closed. + + + {order.chain !== "PAYPAL" && ( + + )} + {err && {err}} +
    + ); +} function MessageStatusIcon({ status, retryCount }: { status: string; retryCount?: number }) { if (status === "failed" && retryCount && retryCount > 0) { @@ -31,12 +89,15 @@ export function InboxClient({ numbers = [], userId: _userId, rentPriceUsd = 5, + unfinishedOrder = null, }: { conversations: Conversation[]; numbers?: LineNumber[]; userId: string; /** Monthly price of a rented number, shown to accounts with no number yet. */ rentPriceUsd?: number; + /** An order that expired unpaid, offered back in one click. */ + unfinishedOrder?: UnfinishedOrder | null; }) { const { filteredConversations, @@ -132,7 +193,9 @@ export function InboxClient({ }, [showNewMessage, searchQuery, filteredConversations]); return ( -
    +
    + {unfinishedOrder && } +
    {/* New Message Modal */} 📱

    Get a number for your verification codes

    - Rent your own US number for ${rentPriceUsd}/month, paid in crypto through CoinPay. It is ready once + Rent your own US number for ${rentPriceUsd}/month, paid with PayPal or crypto through CoinPay. It is ready once the payment confirms, and every text and code lands right here.

    )}
    +
    ); } diff --git a/src/lib/coinpay/__tests__/checkout.test.ts b/src/lib/coinpay/__tests__/checkout.test.ts index c98e72dd..5fea5e52 100644 --- a/src/lib/coinpay/__tests__/checkout.test.ts +++ b/src/lib/coinpay/__tests__/checkout.test.ts @@ -6,6 +6,11 @@ import { signWebhookBody, verifyWebhookSignature, CHECKOUT_CHAINS, + FALLBACK_CHAINS, + availableChains, + methodsFor, + paymentMetadata, + resetChainsCache, } from "../checkout"; describe("coinpay checkout", () => { @@ -27,10 +32,62 @@ describe("coinpay checkout", () => { ); }); - it("falls back to the default chain for anything we hold no wallet for", () => { + it("knows every coin CoinPay takes and falls back to the default for anything else", () => { expect(normalizeChain("usdc_sol")).toBe("USDC_SOL"); - expect(normalizeChain("USDC_BASE")).toBe("USDC_POL"); - expect(CHECKOUT_CHAINS).not.toContain("USDC_BASE"); + expect(normalizeChain("USDC_BASE")).toBe("USDC_BASE"); + expect(normalizeChain("doge")).toBe("DOGE"); + expect(normalizeChain("SHIB")).toBe("USDC_POL"); + for (const c of ["BTC", "BCH", "ETH", "POL", "SOL", "DOGE", "XRP", "ADA", "BNB", "USDT_TRON"]) { + expect(CHECKOUT_CHAINS).toContain(c); + } + expect(FALLBACK_CHAINS).not.toContain("USDT_TRON"); + }); + + it("offers what CoinPay reports active, PayPal second, and falls back when CoinPay cannot answer", async () => { + resetChainsCache(); + const cfg = coinpayConfig({ + COINPAY_API_KEY: "cp_live_" + "0".repeat(20), + COINPAY_BUSINESS_ID: "b", + COINPAY_WEBHOOK_SECRET: "s", + } as unknown as NodeJS.ProcessEnv)!; + let calls = 0; + const ok = (async (url: RequestInfo | URL) => { + calls++; + expect(String(url)).toBe("https://coinpayportal.com/api/supported-coins?active_only=true"); + return new Response( + JSON.stringify({ + coins: [ + { symbol: "BTC", is_active: true }, + { symbol: "USDC_POL", is_active: true }, + { symbol: "DOGE", is_active: true }, + { symbol: "XRP", is_active: false }, + { symbol: "SHIB", is_active: true }, + ], + }) + ); + }) as unknown as typeof fetch; + const chains = await availableChains(cfg, ok, 1000); + expect(chains).toEqual(["USDC_POL", "BTC", "DOGE"]); + expect(methodsFor(chains)).toEqual(["USDC_POL", "PAYPAL", "BTC", "DOGE"]); + await availableChains(cfg, ok, 2000); + expect(calls).toBe(1); // cached + + resetChainsCache(); + const down = (async () => new Response("nope", { status: 502 })) as unknown as typeof fetch; + expect(await availableChains(cfg, down)).toEqual([...FALLBACK_CHAINS]); + resetChainsCache(); + }); + + it("tags every payment with the property, user, order and email", () => { + expect(paymentMetadata({ orderId: "o1", userId: "u1", kind: "new", email: " a@b.co " })).toEqual({ + property: "smshub.dev", + order_id: "o1", + user_id: "u1", + kind: "new", + email: "a@b.co", + customer_email: "a@b.co", + }); + expect(paymentMetadata({ orderId: "o1", userId: "u1", kind: "renew", email: null })).not.toHaveProperty("email"); }); it("verifies t=..,v1=.. signatures over the raw body with a 5 minute window", () => { diff --git a/src/lib/coinpay/checkout.ts b/src/lib/coinpay/checkout.ts index 9c481bc9..57866185 100644 --- a/src/lib/coinpay/checkout.ts +++ b/src/lib/coinpay/checkout.ts @@ -9,20 +9,36 @@ import { createHmac, timingSafeEqual } from "crypto"; -/** Chains the smshub CoinPay business holds wallets for, cheapest first. */ +/** + * Every coin CoinPay can take a payment in, stablecoins first, cheapest to send + * first. What the buyer is offered is this list narrowed to what CoinPay reports + * as active for the business right now (see availableChains): its 15 own wallets + * plus USDC_BASE through the merchant's global wallet. A coin CoinPay switches on + * later (USDT_TRON once it has a wallet) appears without a deploy, and one + * switched off drops out. + */ export const CHECKOUT_CHAINS = [ "USDC_POL", "USDC_SOL", + "USDC_BASE", "USDT_POL", "USDT_SOL", + "USDT_TRON", "USDC_ETH", "USDT_ETH", "POL", "SOL", "ETH", "BTC", + "BCH", + "DOGE", + "XRP", + "ADA", + "BNB", ] as const; export type CheckoutChain = (typeof CHECKOUT_CHAINS)[number]; +/** Offered when CoinPay cannot be asked: the coins the business was last seen to accept. */ +export const FALLBACK_CHAINS: readonly CheckoutChain[] = CHECKOUT_CHAINS.filter((c) => c !== "USDT_TRON"); export const DEFAULT_CHAIN: CheckoutChain = "USDC_POL"; /** An unknown or misspelled chain falls back to the default rather than reaching CoinPay. */ @@ -40,9 +56,77 @@ export function normalizeChain(chain: unknown): CheckoutChain { export const PAYPAL_METHOD = "PAYPAL" as const; /** Everything the buyer can pick: the default coin, PayPal, then the other coins. */ -export const CHECKOUT_METHODS = [DEFAULT_CHAIN, PAYPAL_METHOD, ...CHECKOUT_CHAINS.filter((c) => c !== DEFAULT_CHAIN)] as const; +export const CHECKOUT_METHODS = [DEFAULT_CHAIN, PAYPAL_METHOD, ...FALLBACK_CHAINS.filter((c) => c !== DEFAULT_CHAIN)] as const; export type CheckoutMethod = CheckoutChain | typeof PAYPAL_METHOD; +/** + * The coins CoinPay reports active for the business (GET /api/supported-coins), + * kept to the ones we know, in our order. Any failure answers FALLBACK_CHAINS: + * a checkout on a coin that was just switched off fails at create time with a + * clear error, which beats offering nothing. + */ +let chainsCache: { at: number; chains: CheckoutChain[] } | null = null; +const CHAINS_TTL_MS = 10 * 60_000; + +export async function availableChains( + cfg: CoinPayConfig | null, + fetchImpl: typeof fetch = fetch, + nowMs: number = Date.now() +): Promise { + if (!cfg) return [...FALLBACK_CHAINS]; + if (chainsCache && nowMs - chainsCache.at < CHAINS_TTL_MS) return chainsCache.chains; + try { + const res = await fetchImpl(`${cfg.apiUrl}/supported-coins?active_only=true`, { + headers: { Authorization: `Bearer ${cfg.apiKey}` }, + signal: AbortSignal.timeout(4000), + }); + const body = (await res.json().catch(() => ({}))) as { coins?: { symbol?: string; is_active?: boolean }[] }; + const active = new Set( + (body.coins ?? []).filter((c) => c.is_active !== false).map((c) => String(c.symbol ?? "").toUpperCase()) + ); + const chains = CHECKOUT_CHAINS.filter((c) => active.has(c)); + if (!res.ok || chains.length === 0) return [...FALLBACK_CHAINS]; + chainsCache = { at: nowMs, chains }; + return chains; + } catch { + return [...FALLBACK_CHAINS]; + } +} + +/** Test hook. */ +export function resetChainsCache(): void { + chainsCache = null; +} + +/** Buyer-facing methods: the default coin, PayPal, then the other live coins. */ +export function methodsFor(chains: readonly CheckoutChain[]): CheckoutMethod[] { + const first = chains.includes(DEFAULT_CHAIN) ? [DEFAULT_CHAIN] : []; + return [...first, PAYPAL_METHOD, ...chains.filter((c) => c !== DEFAULT_CHAIN)]; +} + +/** Every payment carries this, so CoinPay rows map back to the site, user and order. */ +export const PAYMENT_PROPERTY = "smshub.dev"; + +export function paymentMetadata(input: { + orderId: string; + userId: string; + kind: string; + email?: string | null; +}): Record { + const meta: Record = { + property: PAYMENT_PROPERTY, + order_id: input.orderId, + user_id: input.userId, + kind: input.kind, + }; + const email = String(input.email ?? "").trim(); + if (email) { + meta.email = email; + meta.customer_email = email; + } + return meta; +} + /** Like normalizeChain, but PayPal is also a valid answer. */ export function normalizeMethod(method: unknown): CheckoutMethod { const m = String(method ?? "").trim().toUpperCase(); @@ -160,6 +244,8 @@ export async function createPaypalPayment( successUrl: string; cancelUrl: string; invoiceNumber: string; + /** CoinPay's PayPal rail stores this on the transaction (receipts, disputes). */ + customerEmail?: string | null; }, fetchImpl: typeof fetch = fetch ): Promise { @@ -175,6 +261,7 @@ export async function createPaypalPayment( success_url: opts.successUrl, cancel_url: opts.cancelUrl, invoiceNumber: opts.invoiceNumber, + customer_email: opts.customerEmail || undefined, }), }); const body = (await res.json().catch(() => ({}))) as { error?: string; transaction_id?: string }; @@ -186,6 +273,78 @@ export async function createPaypalPayment( return { id, payUrl: `${cfg.apiUrl.replace(/\/api$/, "")}/pay/paypal/${id}` }; } +/** + * CoinPay hosted checkout sessions (POST /api/checkout/sessions): one page where + * the buyer picks any coin CoinPay takes or PayPal. Preferred when CoinPay has + * it; until then (404/405/501 or an HTML answer) this returns null, remembers that + * for a while, and the caller opens the method-specific payment instead. Any + * other failure also returns null so a checkout never dies on the new path. + * The session id is not a payment id, so the webhook maps a session payment back + * by metadata (order_id + user_id), see settlePayment. + */ +let sessionsUnsupportedUntil = 0; +const SESSIONS_RECHECK_MS = 15 * 60_000; + +export function resetSessionsProbe(): void { + sessionsUnsupportedUntil = 0; +} + +export async function createCheckoutSession( + cfg: CoinPayConfig, + opts: { + amountUsd: number; + description: string; + customerEmail?: string | null; + accountId: string; + metadata: Record; + successUrl: string; + cancelUrl: string; + idempotencyKey: string; + }, + fetchImpl: typeof fetch = fetch, + nowMs: number = Date.now() +): Promise { + if (nowMs < sessionsUnsupportedUntil) return null; + try { + const res = await fetchImpl(`${cfg.apiUrl}/checkout/sessions`, { + method: "POST", + headers: { + Authorization: `Bearer ${cfg.apiKey}`, + "Content-Type": "application/json", + "Idempotency-Key": opts.idempotencyKey, + }, + body: JSON.stringify({ + business_id: cfg.businessId, + amount: opts.amountUsd, + currency: "USD", + description: opts.description, + customer_email: opts.customerEmail || undefined, + account_id: opts.accountId, + property: PAYMENT_PROPERTY, + metadata: opts.metadata, + success_url: opts.successUrl, + cancel_url: opts.cancelUrl, + expires_in_minutes: CHECKOUT_WINDOW_MINUTES, + }), + signal: AbortSignal.timeout(8000), + }); + const isJson = (res.headers.get("content-type") || "").includes("json"); + if ([404, 405, 501].includes(res.status) || !isJson) { + sessionsUnsupportedUntil = nowMs + SESSIONS_RECHECK_MS; + return null; + } + const body = (await res.json().catch(() => ({}))) as { id?: string; url?: string }; + if (!res.ok || !body.id || !body.url) return null; + // Only ever send buyers to CoinPay's own origin. + const origin = new URL(cfg.apiUrl).origin; + const u = new URL(body.url, origin); + if (u.origin !== origin || u.protocol !== "https:") return null; + return { id: String(body.id), payUrl: u.toString() }; + } catch { + return null; + } +} + /** Verify `X-CoinPay-Signature: t=,v1=`. */ export function verifyWebhookSignature( rawBody: string, diff --git a/src/lib/managed-numbers/__tests__/recovery.test.ts b/src/lib/managed-numbers/__tests__/recovery.test.ts new file mode 100644 index 00000000..28904ad9 --- /dev/null +++ b/src/lib/managed-numbers/__tests__/recovery.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { safePayUrl, unfinishedOrder } from "../recovery"; + +const base = { kind: "new" as const, area_code: null, months: 1, amount_usd: 5, chain: "USDC_POL" }; +const ago = (min: number) => new Date(Date.now() - min * 60_000).toISOString(); + +describe("unfinished order", () => { + it("offers the newest order when it expired unpaid this week", () => { + expect(unfinishedOrder([{ ...base, id: "a", status: "expired", created_at: ago(90) }])?.id).toBe("a"); + expect(unfinishedOrder([{ ...base, id: "a", status: "pending", created_at: ago(61) }])?.id).toBe("a"); + }); + it("offers nothing when a newer order is paid or still open, or it is old", () => { + expect( + unfinishedOrder([ + { ...base, id: "a", status: "expired", created_at: ago(90) }, + { ...base, id: "b", status: "active", created_at: ago(30) }, + ]) + ).toBeNull(); + expect(unfinishedOrder([{ ...base, id: "a", status: "pending", created_at: ago(5) }])).toBeNull(); + expect(unfinishedOrder([{ ...base, id: "a", status: "expired", created_at: ago(8 * 1440) }])).toBeNull(); + expect(unfinishedOrder([])).toBeNull(); + }); + it("only sends the browser to CoinPay pages", () => { + expect(safePayUrl("https://coinpayportal.com/pay/x")).toBeTruthy(); + expect(safePayUrl("https://coinpayportal.com/checkout/cs_1")).toBeTruthy(); + expect(safePayUrl("https://evil.example/pay/x")).toBeNull(); + expect(safePayUrl("javascript:alert(1)")).toBeNull(); + }); +}); diff --git a/src/lib/managed-numbers/__tests__/service.test.ts b/src/lib/managed-numbers/__tests__/service.test.ts index 3b3d29cb..ad6242fa 100644 --- a/src/lib/managed-numbers/__tests__/service.test.ts +++ b/src/lib/managed-numbers/__tests__/service.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import type { SupabaseClient } from "@supabase/supabase-js"; import { FakeDb } from "@/__tests__/fake-db"; -import { createOrder, settlePayment, sweep, orderingStatus, OrderError, GRACE_DAYS } from "../service"; -import { readWebhook } from "@/lib/coinpay/checkout"; +import { createOrder, settlePayment, sweep, orderingStatus, OrderError, GRACE_DAYS, reopenOrder } from "../service"; +import { readWebhook, resetSessionsProbe } from "@/lib/coinpay/checkout"; import { MANAGED_TAG } from "@/lib/telnyx/numbers"; const USER = "user-1"; @@ -71,6 +71,7 @@ describe("managed numbers", () => { let db: SupabaseClient; beforeEach(() => { + resetSessionsProbe(); fdb = new FakeDb(); db = fdb as unknown as SupabaseClient; }); @@ -255,4 +256,84 @@ describe("managed numbers", () => { fdb.rows("number_orders")[0].status = "expired"; expect(await settlePayment(deps, settledEvent("pay-1"))).toMatchObject({ status: "active" }); }); + + it("tags every crypto and PayPal payment with property, user, order and email", async () => { + const f = fakeFetch(); + const deps = { db, env: baseEnv("live"), fetchImpl: f.impl }; + const a = await createOrder(deps, { userId: USER, email: EMAIL, siteUrl: "https://smshub.dev" }); + const b = await createOrder(deps, { userId: USER, email: EMAIL, chain: "PAYPAL", siteUrl: "https://smshub.dev" }); + const crypto = f.calls.find((c) => c.url.endsWith("/api/payments/create"))!.body as Record; + expect(crypto.metadata).toEqual({ + property: "smshub.dev", order_id: a.id, user_id: USER, kind: "new", email: EMAIL, customer_email: EMAIL, + }); + expect(crypto.expires_in_minutes).toBe(60); + const pp = f.calls.find((c) => c.url.endsWith("/paypal/payments/create"))!.body as Record; + expect(pp.customer_email).toBe(EMAIL); + expect(pp.metadata).toMatchObject({ property: "smshub.dev", order_id: b.id, user_id: USER, email: EMAIL }); + }); + + it("prefers a CoinPay hosted checkout session when CoinPay has one, and settles it by metadata", async () => { + const f = fakeFetch(); + const inner = f.impl; + const sessionCalls: Record[] = []; + const impl = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).endsWith("/api/checkout/sessions")) { + sessionCalls.push(JSON.parse(String(init?.body))); + return new Response(JSON.stringify({ id: "cs_1", url: "https://coinpayportal.com/checkout/cs_1" }), { + status: 201, + headers: { "content-type": "application/json" }, + }); + } + return inner(input, init); + }) as unknown as typeof fetch; + const deps = { db, env: baseEnv("live"), fetchImpl: impl }; + const order = await createOrder(deps, { userId: USER, email: EMAIL, chain: "DOGE", siteUrl: "https://smshub.dev" }); + expect(order.pay_url).toBe("https://coinpayportal.com/checkout/cs_1"); + expect(order.coinpay_payment_id).toBe("cs_1"); + expect(sessionCalls[0]).toMatchObject({ + amount: 5, currency: "USD", customer_email: EMAIL, account_id: USER, property: "smshub.dev", + expires_in_minutes: 60, cancel_url: "https://smshub.dev/numbers", + }); + expect(f.calls.some((c) => c.url.endsWith("/api/payments/create"))).toBe(false); + + // The payment made inside the session has its own id: mapped back by metadata. + const ev = readWebhook({ + data: { payment_id: "pay-real", status: "confirmed", metadata: { order_id: order.id, user_id: USER } }, + }); + expect(await settlePayment(deps, ev)).toMatchObject({ settled: true, status: "active" }); + expect(fdb.rows("number_orders")[0].coinpay_payment_id).toBe("pay-real"); + // A forged user in otherwise valid metadata is refused. + const o2 = await createOrder(deps, { userId: USER, email: EMAIL, siteUrl: "x" }); + const bad = readWebhook({ data: { payment_id: "pay-x", status: "confirmed", metadata: { order_id: o2.id, user_id: "someone" } } }); + expect(await settlePayment(deps, bad)).toMatchObject({ settled: false }); + }); + + it("falls back to the direct checkout when CoinPay has no sessions endpoint, and stops asking", async () => { + const f = fakeFetch(); // answers the sessions URL with a non-JSON 500, like CoinPay's HTML 404 page + const deps = { db, env: baseEnv("live"), fetchImpl: f.impl }; + const order = await createOrder(deps, { userId: USER, email: EMAIL, siteUrl: "x" }); + expect(order.coinpay_payment_id).toBe("pay-1"); + await createOrder(deps, { userId: USER, email: EMAIL, siteUrl: "x" }); + expect(f.calls.filter((c) => c.url.endsWith("/checkout/sessions")).length).toBe(1); + }); + + it("reopens an expired order in one click with the same terms, optionally on PayPal", async () => { + const f = fakeFetch(); + const deps = { db, env: baseEnv("live"), fetchImpl: f.impl }; + const old = await createOrder(deps, { userId: USER, email: EMAIL, areaCode: "415", months: 3, chain: "BTC", siteUrl: "x" }); + const row = fdb.rows("number_orders")[0]; + row.created_at = new Date(Date.now() - 61 * 60_000).toISOString(); + // Still "pending" but its window closed: reopenable, and marked expired. + const fresh = await reopenOrder(deps, { userId: USER, email: EMAIL, orderId: old.id, chain: "PAYPAL", siteUrl: "x" }); + expect(fresh.id).not.toBe(old.id); + expect(fresh).toMatchObject({ area_code: "415", months: 3, chain: "PAYPAL", status: "pending" }); + expect(row.status).toBe("expired"); + // A second click while that checkout is open returns it instead of another. + const again = await reopenOrder(deps, { userId: USER, email: EMAIL, orderId: old.id, siteUrl: "x" }); + expect(again.id).toBe(fresh.id); + // Someone else's order, or a paid one, cannot be reopened. + await expect(reopenOrder(deps, { userId: "other", email: EMAIL, orderId: old.id, siteUrl: "x" })).rejects.toThrow(OrderError); + row.status = "active"; + await expect(reopenOrder(deps, { userId: USER, email: EMAIL, orderId: old.id, siteUrl: "x" })).rejects.toThrow(/cannot be reopened/); + }); }); diff --git a/src/lib/managed-numbers/api.ts b/src/lib/managed-numbers/api.ts index f4620abb..ee85eb39 100644 --- a/src/lib/managed-numbers/api.ts +++ b/src/lib/managed-numbers/api.ts @@ -4,7 +4,7 @@ */ import type { SupabaseClient } from "@supabase/supabase-js"; -import { CHECKOUT_METHODS, CHECKOUT_WINDOW_MINUTES, DEFAULT_CHAIN } from "@/lib/coinpay/checkout"; +import { availableChains, CHECKOUT_WINDOW_MINUTES, coinpayConfig, DEFAULT_CHAIN, methodsFor } from "@/lib/coinpay/checkout"; import { getPlanUsage, priceUsdPerMonth } from "@/lib/plans"; import { extractOtp } from "@/lib/otp"; import { GRACE_DAYS, orderingStatus } from "./service"; @@ -61,17 +61,20 @@ export async function getOrder(db: SupabaseClient, userId: string, id: string) { } export async function overview(db: SupabaseClient, userId: string, email: string | null) { - const [plan, numbers, orders] = await Promise.all([ + const [plan, numbers, orders, chains] = await Promise.all([ getPlanUsage(db, userId), listNumbers(db, userId), listOrders(db, userId), + availableChains(coinpayConfig()), ]); return { ordering: orderingStatus(email), pricing: { usd_per_month: priceUsdPerMonth(), - chains: CHECKOUT_METHODS, - default_chain: DEFAULT_CHAIN, + // Every coin CoinPay has live for the business, plus PayPal. + chains: methodsFor(chains), + default_chain: chains.includes(DEFAULT_CHAIN) ? DEFAULT_CHAIN : chains[0] ?? DEFAULT_CHAIN, + paypal: true, grace_days: GRACE_DAYS, // CoinPay's pay page expires this long after the order is created. checkout_window_minutes: CHECKOUT_WINDOW_MINUTES, diff --git a/src/lib/managed-numbers/recovery.ts b/src/lib/managed-numbers/recovery.ts new file mode 100644 index 00000000..575469ec --- /dev/null +++ b/src/lib/managed-numbers/recovery.ts @@ -0,0 +1,46 @@ +/** + * Client-safe: which unpaid order to offer to finish. The newest order wins; if + * it expired unpaid (or its window closed) within the last week, and nothing + * newer is paid, active or still payable, it is the one to reopen. + */ +import { checkoutWindowOpen } from "@/lib/coinpay/window"; + +export interface RecoverableOrder { + id: string; + kind: "new" | "renew"; + status: string; + area_code: string | null; + months: number; + amount_usd: number | string; + chain: string; + error?: string | null; + created_at: string; +} + +export const RECOVERY_DAYS = 7; + +export function unfinishedOrder(orders: T[], now: Date = new Date()): T | null { + const sorted = [...orders].sort((a, b) => (a.created_at < b.created_at ? 1 : -1)); + const latest = sorted[0]; + if (!latest) return null; + if (now.getTime() - new Date(latest.created_at).getTime() > RECOVERY_DAYS * 86400_000) return null; + const dead = + latest.status === "expired" || + (latest.status === "pending" && !checkoutWindowOpen(latest.created_at, now)) || + (latest.status === "failed" && String(latest.error ?? "").startsWith("checkout:")); + return dead ? latest : null; +} + +/** Only ever send the browser to CoinPay's own pages. */ +export function safePayUrl(raw: unknown): string | null { + try { + const u = new URL(String(raw)); + return u.protocol === "https:" && + u.hostname === "coinpayportal.com" && + (u.pathname.startsWith("/pay/") || u.pathname.startsWith("/checkout/")) + ? u.toString() + : null; + } catch { + return null; + } +} diff --git a/src/lib/managed-numbers/service.ts b/src/lib/managed-numbers/service.ts index 4f301570..8006e1ab 100644 --- a/src/lib/managed-numbers/service.ts +++ b/src/lib/managed-numbers/service.ts @@ -14,9 +14,12 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import { CHECKOUT_WINDOW_MINUTES, + checkoutWindowOpen, coinpayConfig, + createCheckoutSession, createPayment, createPaypalPayment, + paymentMetadata, normalizeMethod, PAYPAL_METHOD, type CoinPayConfig, @@ -176,10 +179,26 @@ export async function createOrder( try { const description = `smshub.dev: ${numberLabel}, ${months} month${months > 1 ? "s" : ""}`; - const metadata = { order_id: order.id, user_id: input.userId, kind }; + const metadata = paymentMetadata({ orderId: order.id, userId: input.userId, kind, email: input.email }); const backUrl = `${input.siteUrl}/numbers?order=${order.id}`; + const cancelUrl = `${input.siteUrl}/numbers`; + const session = await createCheckoutSession( + cp, + { + amountUsd: amount, + description, + customerEmail: input.email, + accountId: input.userId, + metadata: { ...metadata, preferred_method: chain }, + successUrl: backUrl, + cancelUrl, + idempotencyKey: `smshub-session-${order.id}`, + }, + deps.fetchImpl + ); const payment = - chain === PAYPAL_METHOD + session ?? + (chain === PAYPAL_METHOD ? await createPaypalPayment( cp, { @@ -187,8 +206,9 @@ export async function createOrder( description, metadata, successUrl: backUrl, - cancelUrl: `${input.siteUrl}/numbers`, + cancelUrl, invoiceNumber: `smshub-${order.id}`, + customerEmail: input.email, }, deps.fetchImpl ) @@ -200,11 +220,11 @@ export async function createOrder( description, metadata, redirectUrl: backUrl, - cancelUrl: `${input.siteUrl}/numbers`, + cancelUrl, idempotencyKey: `smshub-order-${order.id}`, }, deps.fetchImpl - ); + )); const { data: updated } = await db .from("number_orders") .update({ coinpay_payment_id: payment.id, pay_url: payment.payUrl, updated_at: new Date().toISOString() }) @@ -228,14 +248,36 @@ export async function settlePayment(deps: Deps, ev: WebhookEvent): Promise { } return report; } + +/** + * Abandoned-checkout recovery: one click on /numbers or the inbox reopens an + * expired (or window-closed) order as a fresh checkout with the same area code, + * months and kind. The buyer may switch method (PayPal, another coin). If a + * checkout is already open, that one comes back instead of a second. + */ +export async function reopenOrder( + deps: Deps, + input: { userId: string; email: string | null | undefined; orderId: string; chain?: unknown; siteUrl: string } +): Promise { + const { db } = deps; + const now = deps.now?.() ?? new Date(); + const { data: old } = await db + .from("number_orders") + .select("*") + .eq("id", input.orderId) + .eq("user_id", input.userId) + .maybeSingle(); + if (!old) throw new OrderError("Order not found", 404); + const o = old as NumberOrder; + const windowClosed = !checkoutWindowOpen(o.created_at, now); + const reopenable = + o.status === "expired" || + (o.status === "pending" && windowClosed) || + (o.status === "failed" && String(o.error ?? "").startsWith("checkout:")); + if (!reopenable) throw new OrderError("This order cannot be reopened", 409); + + const { data: open } = await db + .from("number_orders") + .select("*") + .eq("user_id", input.userId) + .eq("status", "pending") + .order("created_at", { ascending: false }) + .limit(5); + const live = ((open ?? []) as NumberOrder[]).find((x) => x.pay_url && checkoutWindowOpen(x.created_at, now)); + if (live) return live; + + if (o.status === "pending") { + await db.from("number_orders").update({ status: "expired", updated_at: now.toISOString() }).eq("id", o.id).eq("status", "pending"); + } + return createOrder(deps, { + userId: input.userId, + email: input.email, + kind: o.kind, + phoneNumberId: o.phone_number_id, + areaCode: o.area_code ?? "", + months: o.months, + chain: input.chain ?? o.chain, + siteUrl: input.siteUrl, + }); +} diff --git a/src/lib/mcp/server.ts b/src/lib/mcp/server.ts index 3a350d33..6aca8104 100644 --- a/src/lib/mcp/server.ts +++ b/src/lib/mcp/server.ts @@ -98,7 +98,7 @@ const TOOLS: Tool[] = [ inputSchema: obj({ area_code: { type: "string", description: "Preferred 3-digit US area code; nearest available if none." }, months: { type: "integer", minimum: 1, maximum: 12, default: 1 }, - chain: { type: "string", description: "Coin to pay with, e.g. USDC_POL (default), USDC_SOL, BTC, ETH." }, + chain: { type: "string", description: "How to pay: PAYPAL, or a coin such as USDC_POL (default), USDC_SOL, USDC_BASE, USDT_POL, BTC, ETH, SOL, DOGE, XRP, ADA, BNB, BCH. GET /api/v1/numbers lists what is live." }, }), run: (c, a) => createOrder({ db: c.db }, { userId: c.user.userId, email: c.user.email, kind: "new", areaCode: a.area_code, months: a.months, chain: a.chain, siteUrl: c.siteUrl }),