/** * Carrier API client. * * Set NEXT_PUBLIC_CARRIER_API_URL to point at a live Carrier API. When it is * absent — or the call fails — the catalog falls back to sample plans so the * storefront still builds and renders without credentials. * * That fallback is deliberately *labelled*. `fetchTemplateCatalog()` reports * whether the plans are live or sample, because a sample plan is safe to draw * and unsafe to sell: its id, name and price are invented, so a payment taken * against one is money for a product that does not exist. Anything that leads * to a charge must read `source`, not just the array. `fetchTemplates()` keeps * the plain-array shape for the pages that only render. */ import type { EsimOrder, EsimUsage, SkuTemplate, TopUpOption } from "./types"; const MOCK_TEMPLATES: SkuTemplate[] = [ { id: "visit-calm", name: "Visit — Calm", description: "Perfect for short trips. Light browsing & maps.", dataGb: 3, validityDays: 7, price_cents: 499, currency: "EUR", regions: ["Europe"], features: ["3 GB data", "7 days validity", "40+ countries", "Throttled after limit"], }, { id: "visit-cruise", name: "Visit — Cruise", description: "Comfortable speed for a week away.", dataGb: 10, validityDays: 7, price_cents: 799, currency: "EUR", regions: ["Europe"], features: ["10 GB data", "7 days validity", "40+ countries", "1 Mbps after limit"], }, { id: "nomad-calm", name: "Nomad — Calm", description: "Long-term coverage for slow travellers.", dataGb: 30, validityDays: 30, price_cents: 1999, currency: "EUR", regions: ["Global"], features: ["30 GB data", "30 days validity", "120+ countries", "Throttled after limit"], }, { id: "nomad-cruise", name: "Nomad — Cruise", description: "30 days of comfortable global data.", dataGb: 50, validityDays: 30, price_cents: 2499, currency: "EUR", regions: ["Global"], features: ["50 GB data", "30 days validity", "120+ countries", "1 Mbps after limit"], }, { id: "schengen-cruise", name: "Schengen Loop — Cruise", description: "Schengen zone coverage for multi-country trips.", dataGb: 20, validityDays: 14, price_cents: 1499, currency: "EUR", regions: ["Schengen"], features: ["20 GB data", "14 days validity", "26 Schengen countries", "1 Mbps after limit"], }, ]; /** Why the catalog is not live. Absent when `source` is "live". */ export type CatalogDegradedReason = "unconfigured" | "upstream-error" | "network-error"; export interface TemplateCatalog { templates: SkuTemplate[]; source: "live" | "sample"; reason?: CatalogDegradedReason; } function apiBase(): string { return (process.env.NEXT_PUBLIC_CARRIER_API_URL ?? "").replace(/\/+$/, ""); } /** Server-side only. Never reached in a client bundle, so never inlined. */ function apiKey(): string { return process.env.CARRIER_API_KEY ?? ""; } function sampleCatalog(reason: CatalogDegradedReason): TemplateCatalog { return { templates: MOCK_TEMPLATES, source: "sample", reason }; } /** * The plan catalog, with its provenance attached. * * Callers that can move money MUST branch on `source`. See catalog-guard.ts. */ export async function fetchTemplateCatalog(): Promise { const base = apiBase(); if (!base) return sampleCatalog("unconfigured"); try { const res = await fetch(`${base}/api/templates`, { next: { revalidate: 3600 } }); if (!res.ok) return sampleCatalog("upstream-error"); const templates = (await res.json()) as SkuTemplate[]; if (!Array.isArray(templates) || templates.length === 0) { return sampleCatalog("upstream-error"); } return { templates, source: "live" }; } catch { return sampleCatalog("network-error"); } } /** Render-only convenience. Do not use on any path that creates a charge. */ export async function fetchTemplates(): Promise { return (await fetchTemplateCatalog()).templates; } export async function createCheckoutSession( templateId: string, userId: string, ): Promise<{ url: string } | null> { const base = apiBase(); if (!base) return null; try { const res = await fetch(`${base}/api/checkout`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${userId}` }, body: JSON.stringify({ templateId }), }); if (!res.ok) return null; return (await res.json()) as { url: string }; } catch { return null; } } /** * Why an order could not be read. Each maps to distinct customer copy, because * "we cannot reach fulfilment" and "this order does not exist" call for very * different things from the person reading the page. */ export type OrderLookupError = "unconfigured" | "not-found" | "upstream-error" | "network-error"; export type OrderLookup = | { ok: true; order: EsimOrder } | { ok: false; reason: OrderLookupError }; /** * Read one order's fulfilment state and activation artefacts. * * Contract, deliberately stated because nothing in this repository implements * it yet: the storefront expects the Carrier origin named by * NEXT_PUBLIC_CARRIER_API_URL to serve * * GET {origin}/api/orders/{orderId} Authorization: Bearer {CARRIER_API_KEY} * 200 -> EsimOrder (see types.ts) * 404 -> order unknown * * The order id is the Stripe Checkout session id. Until an origin serves that * route, every call returns `unconfigured` or `upstream-error` and the activate * page says so in plain words. It does not draw a QR code it does not have. */ export async function fetchOrder(orderId: string): Promise { const base = apiBase(); if (!base) return { ok: false, reason: "unconfigured" }; if (!/^[A-Za-z0-9_-]{1,256}$/.test(orderId)) return { ok: false, reason: "not-found" }; const headers: Record = { Accept: "application/json" }; const key = apiKey(); if (key) headers.Authorization = `Bearer ${key}`; try { const res = await fetch(`${base}/api/orders/${encodeURIComponent(orderId)}`, { headers, cache: "no-store", }); if (res.status === 404) return { ok: false, reason: "not-found" }; if (!res.ok) return { ok: false, reason: "upstream-error" }; const order = (await res.json()) as EsimOrder; if (!order || typeof order.orderId !== "string") { return { ok: false, reason: "upstream-error" }; } return { ok: true, order }; } catch { return { ok: false, reason: "network-error" }; } } /** * Call one order-scoped fulfilment route and classify the failure. * * Shared by usage, top-up options and cancel so that every one of them reports * the same four reasons in the same words. A page that says "we can't reach * fulfilment" for one action and nothing at all for another teaches the * customer that the second action silently does nothing. */ async function orderScopedCall( orderId: string, path: string, init?: RequestInit, ): Promise<{ ok: true; data: T } | { ok: false; reason: OrderLookupError }> { const base = apiBase(); if (!base) return { ok: false, reason: "unconfigured" }; if (!/^[A-Za-z0-9_-]{1,256}$/.test(orderId)) return { ok: false, reason: "not-found" }; const headers: Record = { Accept: "application/json", ...(init?.headers as Record) }; const key = apiKey(); if (key) headers.Authorization = `Bearer ${key}`; try { const res = await fetch(`${base}/api/orders/${encodeURIComponent(orderId)}${path}`, { ...init, headers, cache: "no-store", }); if (res.status === 404) return { ok: false, reason: "not-found" }; if (!res.ok) return { ok: false, reason: "upstream-error" }; return { ok: true, data: (await res.json()) as T }; } catch { return { ok: false, reason: "network-error" }; } } /** * How much of the plan is gone. * * Contract: GET {origin}/api/orders/{orderId}/usage -> EsimUsage. * Until an origin serves it the dashboard says the meter is unavailable. It * never shows a zero, because "no data used" and "we could not ask" look * identical on a meter and mean opposite things to someone about to travel. */ export async function fetchUsage( orderId: string, ): Promise<{ ok: true; usage: EsimUsage } | { ok: false; reason: OrderLookupError }> { const res = await orderScopedCall(orderId, "/usage"); if (!res.ok) return res; if (typeof res.data?.usedBytes !== "number") return { ok: false, reason: "upstream-error" }; return { ok: true, usage: res.data }; } /** * What this eSIM can be topped up with. * * Contract: GET {origin}/api/orders/{orderId}/top-up-options -> TopUpOption[]. * An empty array is a valid answer and means this plan cannot be topped up — * distinct from a failure, and rendered as such. */ export async function fetchTopUpOptions( orderId: string, ): Promise<{ ok: true; options: TopUpOption[] } | { ok: false; reason: OrderLookupError }> { const res = await orderScopedCall(orderId, "/top-up-options"); if (!res.ok) return res; if (!Array.isArray(res.data)) return { ok: false, reason: "upstream-error" }; return { ok: true, options: res.data }; } /** * Stop billing this eSIM. * * Contract: POST {origin}/api/orders/{orderId}/cancel -> { cancelledAt: string }. * The operator decides what cancellation means for a plan mid-cycle; the * storefront only reports back what it was told. */ export async function cancelOrder( orderId: string, ): Promise<{ ok: true; cancelledAt?: string } | { ok: false; reason: OrderLookupError }> { const res = await orderScopedCall<{ cancelledAt?: string }>(orderId, "/cancel", { method: "POST", }); if (!res.ok) return res; return { ok: true, cancelledAt: res.data?.cancelledAt }; }