@carrierllc/mcp 0.10.7 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * GET /api/orders/[orderId]
3
+ *
4
+ * The activate page's data source. Signed-in callers only, and only for an
5
+ * order they have already claimed — ownership is the same check the page
6
+ * itself makes, repeated here because a route handler is reachable directly.
7
+ *
8
+ * Upstream failures are reported, not smoothed over. A page that cannot read
9
+ * an order must say so; the one thing it must never do is draw a reassuring
10
+ * placeholder while the eSIM behind it does not exist.
11
+ */
12
+ import { auth } from "@clerk/nextjs/server";
13
+ import { NextResponse } from "next/server";
14
+ import { userOwnsOrder } from "@/lib/checkout-order-claim";
15
+ import { fetchOrder } from "@/vendor/carrier/client";
16
+
17
+ export const dynamic = "force-dynamic";
18
+
19
+ interface Context {
20
+ params: Promise<{ orderId: string }>;
21
+ }
22
+
23
+ export async function GET(_req: Request, ctx: Context): Promise<NextResponse> {
24
+ const { userId } = await auth();
25
+ if (!userId) {
26
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
27
+ }
28
+
29
+ const { orderId } = await ctx.params;
30
+ if (!orderId) {
31
+ return NextResponse.json({ error: "orderId is required" }, { status: 400 });
32
+ }
33
+
34
+ if (!(await userOwnsOrder(userId, orderId))) {
35
+ return NextResponse.json({ error: "Order not found" }, { status: 404 });
36
+ }
37
+
38
+ const lookup = await fetchOrder(orderId);
39
+ if (lookup.ok) {
40
+ return NextResponse.json({ order: lookup.order });
41
+ }
42
+
43
+ if (lookup.reason === "not-found") {
44
+ return NextResponse.json({ error: "Order not found", code: "not_found" }, { status: 404 });
45
+ }
46
+
47
+ if (lookup.reason === "unconfigured") {
48
+ console.error(
49
+ "[orders] NEXT_PUBLIC_CARRIER_API_URL is unset — the storefront has no fulfilment origin to ask, " +
50
+ "so no order can ever reach 'ready'",
51
+ );
52
+ return NextResponse.json(
53
+ {
54
+ error: "Activation is not available yet. Please contact support with your order number.",
55
+ code: "fulfilment_unconfigured",
56
+ },
57
+ { status: 503 },
58
+ );
59
+ }
60
+
61
+ console.error(`[orders] order lookup failed for ${orderId}: ${lookup.reason}`);
62
+ return NextResponse.json(
63
+ {
64
+ error: "We could not reach your eSIM details right now. Please try again in a moment.",
65
+ code: "fulfilment_unavailable",
66
+ },
67
+ { status: 502 },
68
+ );
69
+ }
@@ -12,11 +12,17 @@
12
12
  * After Stripe payment success, the user lands on /checkout/success where they
13
13
  * are prompted to create an account (email + password, no phone) or sign in.
14
14
  * Then they are redirected to /activate/[orderId] for optional WhatsApp/phone setup.
15
+ *
16
+ * Every string on this page is read by a customer. It names no environment
17
+ * variable and gives no configuration instructions — an operator's deployment
18
+ * problem is not something a shopper can act on, and printing it makes a store
19
+ * look broken rather than busy.
15
20
  */
16
21
  import { useEffect, useRef, useState } from "react";
17
22
  import { useAuth } from "@clerk/nextjs";
18
23
  import { useRouter } from "next/navigation";
19
24
  import { conversionEvents } from "@/lib/conversion-events";
25
+ import { normalizeChannel } from "@/vendor/carrier/catalog-guard";
20
26
  import type { SkuTemplate } from "@/vendor/carrier/types";
21
27
 
22
28
  interface Props {
@@ -25,6 +31,21 @@ interface Props {
25
31
  session: { url: string } | null;
26
32
  }
27
33
 
34
+ const GENERIC_CHECKOUT_ERROR = "Checkout is unavailable right now. Please try again in a moment.";
35
+
36
+ /**
37
+ * Where this visit came from, read off the landing URL.
38
+ *
39
+ * `?channel=` is the explicit form; `utm_source` is what ad and email tools
40
+ * actually append, so both are honoured. The value is normalised here and
41
+ * again on the server — this copy only keeps the request tidy.
42
+ */
43
+ function readChannel(): string {
44
+ if (typeof window === "undefined") return "storefront";
45
+ const params = new URLSearchParams(window.location.search);
46
+ return normalizeChannel(params.get("channel") ?? params.get("utm_source"));
47
+ }
48
+
28
49
  export function CheckoutClient({ plan, session }: Props) {
29
50
  const { isSignedIn, isLoaded } = useAuth();
30
51
  const router = useRouter();
@@ -49,9 +70,7 @@ export function CheckoutClient({ plan, session }: Props) {
49
70
  router.refresh();
50
71
  return;
51
72
  }
52
- setCheckoutError(
53
- "Checkout is unavailable. Configure NEXT_PUBLIC_CARRIER_API_URL and Clerk keys, then try again.",
54
- );
73
+ setCheckoutError(GENERIC_CHECKOUT_ERROR);
55
74
  return;
56
75
  }
57
76
 
@@ -63,12 +82,15 @@ export function CheckoutClient({ plan, session }: Props) {
63
82
  fetch("/api/checkout/guest", {
64
83
  method: "POST",
65
84
  headers: { "Content-Type": "application/json" },
66
- body: JSON.stringify({ templateId: plan.id }),
85
+ body: JSON.stringify({ templateId: plan.id, channel: readChannel() }),
67
86
  })
68
87
  .then(async (res) => {
69
88
  if (!res.ok) {
70
- const err = (await res.json()) as { error?: string };
71
- throw new Error(err.error ?? "Failed to create checkout session");
89
+ // The route returns customer-safe copy for every refusal it knows
90
+ // about. Anything else (a proxy error page, say) gets the generic
91
+ // line rather than whatever text happened to come back.
92
+ const err = (await res.json().catch(() => ({}))) as { error?: string; code?: string };
93
+ throw new Error(err.code && err.error ? err.error : GENERIC_CHECKOUT_ERROR);
72
94
  }
73
95
  return res.json() as Promise<{ url: string }>;
74
96
  })
@@ -77,8 +99,7 @@ export function CheckoutClient({ plan, session }: Props) {
77
99
  })
78
100
  .catch((err: unknown) => {
79
101
  guestCheckoutPlanIdRef.current = null;
80
- const msg = err instanceof Error ? err.message : "Checkout unavailable. Please try again.";
81
- setCheckoutError(msg);
102
+ setCheckoutError(err instanceof Error ? err.message : GENERIC_CHECKOUT_ERROR);
82
103
  setLoading(false);
83
104
  });
84
105
  }, [isLoaded, isSignedIn, plan, session, router]);
@@ -1,29 +1,108 @@
1
1
  export const dynamic = "force-dynamic";
2
2
 
3
+ /**
4
+ * /dashboard — the customer's order history.
5
+ *
6
+ * This page used to render "No active eSIMs yet." unconditionally, which told
7
+ * a paying customer their purchase had not happened. It now reads the orders
8
+ * they have actually claimed and asks fulfilment about each one. Three
9
+ * outcomes, all of them stated rather than implied: no orders, orders we could
10
+ * read, and orders we could not read.
11
+ */
3
12
  import { auth } from "@clerk/nextjs/server";
4
13
  import { redirect } from "next/navigation";
14
+ import { listClaimedOrderIds } from "@/lib/checkout-order-claim";
15
+ import { fetchOrder } from "@/vendor/carrier/client";
16
+ import type { EsimOrder, OrderStatus } from "@/vendor/carrier/types";
5
17
 
6
18
  export const metadata = { title: "Dashboard" };
7
19
 
20
+ const STATUS_LABEL: Record<OrderStatus, string> = {
21
+ pending: "Payment received",
22
+ provisioning: "Setting up",
23
+ ready: "Active",
24
+ failed: "Needs attention",
25
+ };
26
+
27
+ function EmptyState() {
28
+ return (
29
+ <div className="rounded-2xl border border-sand-200 bg-white p-12 text-center dark:border-sand-800 dark:bg-sand-900">
30
+ <p className="text-sand-500">No active eSIMs yet.</p>
31
+ <a
32
+ href="/shop"
33
+ className="mt-4 inline-block rounded-full px-6 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
34
+ style={{ background: "var(--brand-accent)" }}
35
+ >
36
+ Browse plans
37
+ </a>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ function OrderRow({ orderId, order }: { orderId: string; order: EsimOrder | null }) {
43
+ return (
44
+ <li className="rounded-2xl border border-sand-200 bg-white p-6 dark:border-sand-800 dark:bg-sand-900">
45
+ <div className="flex flex-wrap items-baseline justify-between gap-2">
46
+ <p className="font-medium text-sand-900 dark:text-sand-50">
47
+ {order?.planName ?? order?.templateId ?? "eSIM order"}
48
+ </p>
49
+ <span className="text-xs text-sand-500">
50
+ {order ? STATUS_LABEL[order.status] : "Status unavailable"}
51
+ </span>
52
+ </div>
53
+ <p className="mt-1 font-mono text-xs break-all text-sand-400">{orderId}</p>
54
+ {order?.iccid && <p className="mt-1 text-xs text-sand-500">ICCID {order.iccid}</p>}
55
+ {!order && (
56
+ <p className="mt-2 text-xs text-sand-500">
57
+ We couldn&apos;t reach this order just now. Your purchase is safe — try again shortly.
58
+ </p>
59
+ )}
60
+ <a
61
+ href={`/activate/${encodeURIComponent(orderId)}`}
62
+ className="mt-4 inline-block text-sm font-semibold text-[var(--brand-accent)] hover:underline"
63
+ >
64
+ {order?.status === "ready" ? "View QR code" : "View order"} →
65
+ </a>
66
+ </li>
67
+ );
68
+ }
69
+
8
70
  export default async function DashboardPage() {
9
71
  const { userId } = await auth();
10
72
  if (!userId) redirect("/sign-in?redirect_url=/dashboard");
11
73
 
74
+ const orderIds = await listClaimedOrderIds(userId);
75
+ const orders = await Promise.all(
76
+ orderIds.map(async (orderId) => {
77
+ const lookup = await fetchOrder(orderId);
78
+ return { orderId, order: lookup.ok ? lookup.order : null };
79
+ }),
80
+ );
81
+ const unreadable = orders.filter((entry) => entry.order === null).length;
82
+
12
83
  return (
13
84
  <div className="mx-auto max-w-4xl px-4 py-16">
14
85
  <h1 className="mb-8 text-3xl font-semibold tracking-tight text-sand-900 dark:text-sand-50">
15
86
  Your eSIMs
16
87
  </h1>
17
- <div className="rounded-2xl border border-sand-200 bg-white p-12 text-center dark:border-sand-800 dark:bg-sand-900">
18
- <p className="text-sand-500">No active eSIMs yet.</p>
19
- <a
20
- href="/shop"
21
- className="mt-4 inline-block rounded-full px-6 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
22
- style={{ background: "var(--brand-accent)" }}
23
- >
24
- Browse plans
25
- </a>
26
- </div>
88
+
89
+ {orders.length === 0 ? (
90
+ <EmptyState />
91
+ ) : (
92
+ <>
93
+ {unreadable > 0 && (
94
+ <p className="mb-6 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-sand-700 dark:text-sand-200">
95
+ We couldn&apos;t load {unreadable === orders.length ? "your orders" : "some of your orders"}{" "}
96
+ right now. Nothing is lost — refresh in a minute, or contact us with the order number.
97
+ </p>
98
+ )}
99
+ <ul className="space-y-4">
100
+ {orders.map((entry) => (
101
+ <OrderRow key={entry.orderId} orderId={entry.orderId} order={entry.order} />
102
+ ))}
103
+ </ul>
104
+ </>
105
+ )}
27
106
  </div>
28
107
  );
29
108
  }
@@ -139,3 +139,16 @@ export async function userOwnsOrder(userId: string, orderId: string): Promise<bo
139
139
  orderId,
140
140
  );
141
141
  }
142
+
143
+ /**
144
+ * Every order this user has claimed, newest last.
145
+ *
146
+ * Clerk's private metadata is the storefront's only record of who bought what:
147
+ * there is no local database, and Stripe cannot be queried by user. The
148
+ * dashboard reads this and then asks fulfilment about each id.
149
+ */
150
+ export async function listClaimedOrderIds(userId: string): Promise<string[]> {
151
+ const client = await clerkClient();
152
+ const user = await client.users.getUser(userId);
153
+ return claimedOrderIds(user.privateMetadata as Record<string, unknown> | undefined);
154
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Pure decision logic that sits between the plan catalog and Stripe.
3
+ *
4
+ * It lives apart from the route handler on purpose: the route imports
5
+ * `next/server`, which cannot be loaded in a plain node test, and this is the
6
+ * part that has to be provable. Nothing here touches the network or the env.
7
+ */
8
+ import type { SkuTemplate } from "./types";
9
+ import type { TemplateCatalog } from "./client";
10
+
11
+ /** Codes the storefront maps to customer-facing copy. Never shown verbatim. */
12
+ export type CheckoutRefusalCode = "plan_not_found" | "catalog_degraded" | "plan_not_sellable";
13
+
14
+ export type CheckoutDecision =
15
+ | { ok: true; plan: SkuTemplate }
16
+ | { ok: false; status: number; code: CheckoutRefusalCode; error: string };
17
+
18
+ /**
19
+ * A price is only chargeable when it is a whole, positive number of minor
20
+ * units and the currency is a plain ISO-4217 code. The guest route builds the
21
+ * Stripe line item inline from these two fields (price_data, no price id), so
22
+ * Stripe validates nothing on our behalf — this is the only check there is.
23
+ */
24
+ function isSellable(plan: SkuTemplate): boolean {
25
+ return (
26
+ Number.isSafeInteger(plan.price_cents) &&
27
+ plan.price_cents > 0 &&
28
+ typeof plan.currency === "string" &&
29
+ /^[A-Za-z]{3}$/.test(plan.currency) &&
30
+ typeof plan.name === "string" &&
31
+ plan.name.trim().length > 0
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Decide whether a plan may be charged for.
37
+ *
38
+ * The rule that matters: when Stripe is live, a catalog that fell back to the
39
+ * built-in sample plans must never reach a charge. Those plans have invented
40
+ * ids, names and prices; a payment against one takes real money for a product
41
+ * that does not exist and cannot be fulfilled. Rendering them is fine — that is
42
+ * what lets the template build with no credentials — but selling them is not.
43
+ */
44
+ export function decideGuestCheckout(args: {
45
+ catalog: TemplateCatalog;
46
+ templateId: string;
47
+ stripeConfigured: boolean;
48
+ }): CheckoutDecision {
49
+ const { catalog, templateId, stripeConfigured } = args;
50
+
51
+ if (stripeConfigured && catalog.source !== "live") {
52
+ return {
53
+ ok: false,
54
+ status: 503,
55
+ code: "catalog_degraded",
56
+ error: "Plans are temporarily unavailable. Please try again in a few minutes.",
57
+ };
58
+ }
59
+
60
+ const plan = catalog.templates.find((p) => p.id === templateId);
61
+ if (!plan) {
62
+ return {
63
+ ok: false,
64
+ status: 404,
65
+ code: "plan_not_found",
66
+ error: "That plan is no longer available.",
67
+ };
68
+ }
69
+
70
+ if (stripeConfigured && !isSellable(plan)) {
71
+ return {
72
+ ok: false,
73
+ status: 503,
74
+ code: "plan_not_sellable",
75
+ error: "That plan cannot be purchased right now. Please try again in a few minutes.",
76
+ };
77
+ }
78
+
79
+ return { ok: true, plan };
80
+ }
81
+
82
+ /**
83
+ * Channel attribution carried into Stripe session metadata.
84
+ *
85
+ * Stripe metadata values are free text and this one is caller-supplied — it
86
+ * arrives in the request body — so it is clamped to a short slug rather than
87
+ * passed through. An unrecognisable value degrades to the default instead of
88
+ * failing the purchase: attribution is never worth losing a sale over.
89
+ */
90
+ export const DEFAULT_CHANNEL = "storefront";
91
+
92
+ export function normalizeChannel(raw: unknown): string {
93
+ if (typeof raw !== "string") return DEFAULT_CHANNEL;
94
+ const slug = raw
95
+ .trim()
96
+ .toLowerCase()
97
+ .replace(/[^a-z0-9_.-]/g, "-")
98
+ .replace(/-{2,}/g, "-");
99
+ // Bound the length BEFORE stripping, and strip with a loop rather than
100
+ // /^-+|-+$/g. That pattern backtracks polynomially on a long run of dashes
101
+ // (CodeQL js/polynomial-redos), and `raw` comes straight off the request —
102
+ // "-" repeated a few hundred thousand times is a free CPU burn otherwise.
103
+ // The loop is linear and stays correct even if the collapse above changes.
104
+ const bounded = slug.slice(0, 64);
105
+ let start = 0;
106
+ let end = bounded.length;
107
+ while (start < end && bounded[start] === "-") start += 1;
108
+ while (end > start && bounded[end - 1] === "-") end -= 1;
109
+ return bounded.slice(start, end) || DEFAULT_CHANNEL;
110
+ }
@@ -1,9 +1,18 @@
1
1
  /**
2
- * Carrier API client stub.
3
- * Set NEXT_PUBLIC_CARRIER_API_URL to point at a live Carrier API.
4
- * When absent, all methods return mock data so the storefront builds without credentials.
2
+ * Carrier API client.
3
+ *
4
+ * Set NEXT_PUBLIC_CARRIER_API_URL to point at a live Carrier API. When it is
5
+ * absent — or the call fails — the catalog falls back to sample plans so the
6
+ * storefront still builds and renders without credentials.
7
+ *
8
+ * That fallback is deliberately *labelled*. `fetchTemplateCatalog()` reports
9
+ * whether the plans are live or sample, because a sample plan is safe to draw
10
+ * and unsafe to sell: its id, name and price are invented, so a payment taken
11
+ * against one is money for a product that does not exist. Anything that leads
12
+ * to a charge must read `source`, not just the array. `fetchTemplates()` keeps
13
+ * the plain-array shape for the pages that only render.
5
14
  */
6
- import type { SkuTemplate } from "./types";
15
+ import type { EsimOrder, SkuTemplate } from "./types";
7
16
 
8
17
  const MOCK_TEMPLATES: SkuTemplate[] = [
9
18
  {
@@ -63,26 +72,62 @@ const MOCK_TEMPLATES: SkuTemplate[] = [
63
72
  },
64
73
  ];
65
74
 
66
- const API_BASE = process.env.NEXT_PUBLIC_CARRIER_API_URL ?? "";
75
+ /** Why the catalog is not live. Absent when `source` is "live". */
76
+ export type CatalogDegradedReason = "unconfigured" | "upstream-error" | "network-error";
67
77
 
68
- export async function fetchTemplates(): Promise<SkuTemplate[]> {
69
- if (!API_BASE) return MOCK_TEMPLATES;
78
+ export interface TemplateCatalog {
79
+ templates: SkuTemplate[];
80
+ source: "live" | "sample";
81
+ reason?: CatalogDegradedReason;
82
+ }
83
+
84
+ function apiBase(): string {
85
+ return (process.env.NEXT_PUBLIC_CARRIER_API_URL ?? "").replace(/\/+$/, "");
86
+ }
87
+
88
+ /** Server-side only. Never reached in a client bundle, so never inlined. */
89
+ function apiKey(): string {
90
+ return process.env.CARRIER_API_KEY ?? "";
91
+ }
92
+
93
+ function sampleCatalog(reason: CatalogDegradedReason): TemplateCatalog {
94
+ return { templates: MOCK_TEMPLATES, source: "sample", reason };
95
+ }
96
+
97
+ /**
98
+ * The plan catalog, with its provenance attached.
99
+ *
100
+ * Callers that can move money MUST branch on `source`. See catalog-guard.ts.
101
+ */
102
+ export async function fetchTemplateCatalog(): Promise<TemplateCatalog> {
103
+ const base = apiBase();
104
+ if (!base) return sampleCatalog("unconfigured");
70
105
  try {
71
- const res = await fetch(`${API_BASE}/api/templates`, { next: { revalidate: 3600 } });
72
- if (!res.ok) return MOCK_TEMPLATES;
73
- return (await res.json()) as SkuTemplate[];
106
+ const res = await fetch(`${base}/api/templates`, { next: { revalidate: 3600 } });
107
+ if (!res.ok) return sampleCatalog("upstream-error");
108
+ const templates = (await res.json()) as SkuTemplate[];
109
+ if (!Array.isArray(templates) || templates.length === 0) {
110
+ return sampleCatalog("upstream-error");
111
+ }
112
+ return { templates, source: "live" };
74
113
  } catch {
75
- return MOCK_TEMPLATES;
114
+ return sampleCatalog("network-error");
76
115
  }
77
116
  }
78
117
 
118
+ /** Render-only convenience. Do not use on any path that creates a charge. */
119
+ export async function fetchTemplates(): Promise<SkuTemplate[]> {
120
+ return (await fetchTemplateCatalog()).templates;
121
+ }
122
+
79
123
  export async function createCheckoutSession(
80
124
  templateId: string,
81
125
  userId: string,
82
126
  ): Promise<{ url: string } | null> {
83
- if (!API_BASE) return null;
127
+ const base = apiBase();
128
+ if (!base) return null;
84
129
  try {
85
- const res = await fetch(`${API_BASE}/api/checkout`, {
130
+ const res = await fetch(`${base}/api/checkout`, {
86
131
  method: "POST",
87
132
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${userId}` },
88
133
  body: JSON.stringify({ templateId }),
@@ -93,3 +138,55 @@ export async function createCheckoutSession(
93
138
  return null;
94
139
  }
95
140
  }
141
+
142
+ /**
143
+ * Why an order could not be read. Each maps to distinct customer copy, because
144
+ * "we cannot reach fulfilment" and "this order does not exist" call for very
145
+ * different things from the person reading the page.
146
+ */
147
+ export type OrderLookupError = "unconfigured" | "not-found" | "upstream-error" | "network-error";
148
+
149
+ export type OrderLookup =
150
+ | { ok: true; order: EsimOrder }
151
+ | { ok: false; reason: OrderLookupError };
152
+
153
+ /**
154
+ * Read one order's fulfilment state and activation artefacts.
155
+ *
156
+ * Contract, deliberately stated because nothing in this repository implements
157
+ * it yet: the storefront expects the Carrier origin named by
158
+ * NEXT_PUBLIC_CARRIER_API_URL to serve
159
+ *
160
+ * GET {origin}/api/orders/{orderId} Authorization: Bearer {CARRIER_API_KEY}
161
+ * 200 -> EsimOrder (see types.ts)
162
+ * 404 -> order unknown
163
+ *
164
+ * The order id is the Stripe Checkout session id. Until an origin serves that
165
+ * route, every call returns `unconfigured` or `upstream-error` and the activate
166
+ * page says so in plain words. It does not draw a QR code it does not have.
167
+ */
168
+ export async function fetchOrder(orderId: string): Promise<OrderLookup> {
169
+ const base = apiBase();
170
+ if (!base) return { ok: false, reason: "unconfigured" };
171
+ if (!/^[A-Za-z0-9_-]{1,256}$/.test(orderId)) return { ok: false, reason: "not-found" };
172
+
173
+ const headers: Record<string, string> = { Accept: "application/json" };
174
+ const key = apiKey();
175
+ if (key) headers.Authorization = `Bearer ${key}`;
176
+
177
+ try {
178
+ const res = await fetch(`${base}/api/orders/${encodeURIComponent(orderId)}`, {
179
+ headers,
180
+ cache: "no-store",
181
+ });
182
+ if (res.status === 404) return { ok: false, reason: "not-found" };
183
+ if (!res.ok) return { ok: false, reason: "upstream-error" };
184
+ const order = (await res.json()) as EsimOrder;
185
+ if (!order || typeof order.orderId !== "string") {
186
+ return { ok: false, reason: "upstream-error" };
187
+ }
188
+ return { ok: true, order };
189
+ } catch {
190
+ return { ok: false, reason: "network-error" };
191
+ }
192
+ }
@@ -19,3 +19,40 @@ export interface ProvisionEsimResult {
19
19
  activationUrl: string;
20
20
  expiresAt: string;
21
21
  }
22
+
23
+ /**
24
+ * Fulfilment state of one purchase.
25
+ *
26
+ * "pending" — paid, not yet handed to provisioning.
27
+ * "provisioning" — provisioning in flight; the page keeps polling.
28
+ * "ready" — an eSIM exists and the activation artefacts below are set.
29
+ * "failed" — provisioning gave up; the customer needs a human.
30
+ */
31
+ export type OrderStatus = "pending" | "provisioning" | "ready" | "failed";
32
+
33
+ /**
34
+ * One order as the storefront needs it.
35
+ *
36
+ * Everything below `status` is optional because it only exists once
37
+ * provisioning has succeeded. `qrCode` is an image the page can render
38
+ * directly (a data: URI or an https URL); `smdpAddress` and `matchingId` are
39
+ * the same credential in the form a phone accepts typed by hand, which is the
40
+ * fallback whenever the QR cannot be shown.
41
+ */
42
+ export interface EsimOrder {
43
+ orderId: string;
44
+ status: OrderStatus;
45
+ templateId?: string;
46
+ planName?: string;
47
+ iccid?: string;
48
+ /** Renderable QR image: data: URI or https URL. */
49
+ qrCode?: string;
50
+ /** Full LPA string, e.g. LPA:1$smdp.example.com$MATCHING-ID */
51
+ activationCode?: string;
52
+ smdpAddress?: string;
53
+ matchingId?: string;
54
+ createdAt?: string;
55
+ expiresAt?: string;
56
+ /** Operator-supplied, customer-safe explanation when status is "failed". */
57
+ failureMessage?: string;
58
+ }