@carrierllc/mcp 0.11.17 → 0.12.0

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.
Files changed (34) hide show
  1. package/dist/{chunk-EHGZRVSO.js → chunk-J52VP7E7.js} +14 -2
  2. package/dist/chunk-J52VP7E7.js.map +1 -0
  3. package/dist/cli.js +13 -5
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/package.json +1 -1
  7. package/plugin/carrier/agents/carrier-storefront-builder.md +3 -3
  8. package/plugin/carrier/skills/storefront/SKILL.md +2 -2
  9. package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +145 -14
  10. package/templates/storefront/src/app/activate/[orderId]/page.tsx +1 -1
  11. package/templates/storefront/src/app/api/checkout/guest/route.ts +72 -9
  12. package/templates/storefront/src/app/api/orders/[orderId]/cancel/route.ts +33 -0
  13. package/templates/storefront/src/app/api/orders/[orderId]/top-up-options/route.ts +28 -0
  14. package/templates/storefront/src/app/api/orders/[orderId]/usage/route.ts +25 -0
  15. package/templates/storefront/src/app/api/profile/phone/route.ts +1 -1
  16. package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +142 -39
  17. package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +1 -1
  18. package/templates/storefront/src/app/checkout/success/page.tsx +1 -1
  19. package/templates/storefront/src/app/dashboard/EsimControls.tsx +293 -0
  20. package/templates/storefront/src/app/dashboard/page.tsx +12 -1
  21. package/templates/storefront/src/app/shop/ShopClient.tsx +126 -5
  22. package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +1 -1
  23. package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +1 -1
  24. package/templates/storefront/src/brand.config.ts +19 -0
  25. package/templates/storefront/src/components/checkout/EmbeddedStripeCheckout.tsx +191 -0
  26. package/templates/storefront/src/components/landing/PlanCard.tsx +12 -4
  27. package/templates/storefront/src/lib/checkout-order-claim.ts +68 -4
  28. package/templates/storefront/src/lib/order-route-guard.ts +85 -0
  29. package/templates/storefront/src/middleware.ts +1 -1
  30. package/templates/storefront/src/vendor/carrier/client.ts +86 -1
  31. package/templates/storefront/src/vendor/carrier/types.ts +34 -0
  32. package/templates/storefront/src/vendor/geo/index.ts +45 -0
  33. package/templates/storefront/src/vendor/ui/brand.ts +8 -1
  34. package/dist/chunk-EHGZRVSO.js.map +0 -1
package/dist/index.js CHANGED
@@ -65,7 +65,7 @@ import {
65
65
  usageOverPeriodParams,
66
66
  verifyStorefront,
67
67
  withOcsListSummary
68
- } from "./chunk-EHGZRVSO.js";
68
+ } from "./chunk-J52VP7E7.js";
69
69
  import "./chunk-SHKKVIIA.js";
70
70
 
71
71
  // src/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carrierllc/mcp",
3
- "version": "0.11.17",
3
+ "version": "0.12.0",
4
4
  "description": "Carrier MCP \u2014 natural-language control of MVNO/eSIM fleets via eSIMVault OCS. Stdio mode for direct integration with Claude Desktop, Cursor, Windsurf, and MCP-compatible clients. Ships the `carrier` CLI (plugin install + white-label eSIM storefront scaffold).",
5
5
  "license": "MIT",
6
6
  "author": "Carrier (Lifecycle Innovations Limited)",
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  name: carrier-storefront-builder
3
- description: Scaffolds, white-labels, and deploys the customer-facing eSIM storefront (Mango template) via @carrierllc/cli, and keeps its plans in sync with the Carrier fleet. Use to launch or rebrand a storefront.
3
+ description: Scaffolds, white-labels, and deploys the customer-facing eSIM storefront via @carrierllc/cli, and keeps its plans in sync with the Carrier fleet. Use to launch or rebrand a storefront.
4
4
  ---
5
5
 
6
6
  You build and ship white-labeled eSIM storefronts on top of a Carrier fleet.
7
7
 
8
- The storefront is a Next.js app (Mango-derived template) scaffolded by `@carrierllc/cli`. Its single white-label surface is `src/brand.config.ts` (name, tagline, colors, domain, support, social).
8
+ The storefront is a Next.js app (bundled template) scaffolded by `@carrierllc/cli`. Its single white-label surface is `src/brand.config.ts` (name, tagline, colors, domain, support, social).
9
9
 
10
10
  Workflow:
11
11
  1. **Scaffold** — `npx @carrierllc/cli site create <dir>` with the operator's brand (name, domain, accent hex, support email).
12
- 2. **Brand** — verify `src/brand.config.ts` and `globals.css` reflect the brand; the default accent is Carrier Flame `#FF6B35` on dark `#080C16` — replace with the operator's palette. Check there are no leftover "Mango" strings.
12
+ 2. **Brand** — verify `src/brand.config.ts` and `globals.css` reflect the brand; the default accent is Carrier Flame `#FF6B35` on dark `#080C16` — replace with the operator's palette. The template ships brand-free — every name a page prints comes from `brand.config.ts`.
13
13
  3. **Wire** — set `NEXT_PUBLIC_CARRIER_API_URL` in `.env.local`; ensure the plans shown match `pricing_plans` / `list_package_templates` from the fleet.
14
14
  4. **Build & deploy** — `npx @carrierllc/cli site deploy <dir>` → Cloudflare Pages (`wrangler login` first).
15
15
  5. **Verify** — load the live URL, confirm it renders, plans are correct, and checkout reaches the control plane.
@@ -6,7 +6,7 @@ argument-hint: "[create|logo|deploy|targets|clerk|rebrand] [dir]"
6
6
 
7
7
  # Storefront
8
8
 
9
- Roll out the customer-facing eSIM storefront (Mango template, white-labeled) on top of your Carrier fleet.
9
+ Roll out the customer-facing eSIM storefront (white-labeled) on top of your Carrier fleet.
10
10
 
11
11
  Works for **any developer** with Carrier MCP (`https://mcp.carrier.llc/mcp`) or the `carrier` CLI. You do not need an image-API key on your laptop.
12
12
 
@@ -42,7 +42,7 @@ MCP (any authenticated Carrier user):
42
42
  generate_storefront_logo
43
43
  name: Bananas
44
44
  accent: #FF8A00
45
- tagline: Travel eSIM that just works.
45
+ tagline: Data that works wherever you land.
46
46
  ```
47
47
 
48
48
  Write the returned `svg` to `public/brand/logo.svg`. Nav and icons read that path.
@@ -2,14 +2,19 @@
2
2
  /**
3
3
  * ActivateClient
4
4
  *
5
- * MANGO-8/9: Shown after sign-up/sign-in on /activate/[orderId].
5
+ * Shown after sign-up/sign-in on /activate/[orderId].
6
6
  *
7
7
  * - Fetches the order and shows the real eSIM QR code once provisioning is done.
8
- * - Falls back to the SM-DP+ address and matching ID for manual entry whenever
9
- * the QR image is missing — every phone accepts those typed by hand.
8
+ * - Offers a one-tap install, then the SM-DP+ address and matching ID, then
9
+ * written steps. A QR code is useless on the one phone the customer is
10
+ * reading this on, so every layer below it has to stand on its own.
10
11
  * - Says plainly when it cannot read the order at all. The page used to print
11
12
  * a fixed reassurance that a QR code was on its way, and then never
12
13
  * ask anyone anything, which reads as progress and is not.
14
+ * - Never spins forever. A spinner is a promise that something is still
15
+ * happening; when the answer is "this order does not exist", "it is not
16
+ * yours", or "provisioning has not finished in the time it normally takes",
17
+ * the page stops and says which, with something the customer can do next.
13
18
  * - Optionally collects phone number / WhatsApp for eSIM delivery notifications.
14
19
  * Phone is NEVER required — user can skip.
15
20
  */
@@ -23,12 +28,32 @@ interface Props {
23
28
  type LoadState =
24
29
  | { phase: "loading" }
25
30
  | { phase: "ready"; order: EsimOrder }
26
- | { phase: "error"; message: string };
31
+ /**
32
+ * `terminal` means retrying cannot change the answer. A 404 or a 403 is a
33
+ * fact about this order, not a hiccup, so the page offers support instead of
34
+ * a "Try again" button that will fail identically.
35
+ */
36
+ | { phase: "error"; message: string; terminal: boolean }
37
+ /** Still provisioning after the polls ran out. Not an error, not progress. */
38
+ | { phase: "stalled"; order: EsimOrder };
27
39
 
28
40
  /** Provisioning is usually seconds. Poll a bounded number of times, then stop. */
29
41
  const POLL_INTERVAL_MS = 4000;
30
42
  const MAX_POLLS = 15;
31
43
 
44
+ /**
45
+ * Apple's universal eSIM install link — iOS 17.4 and later hand the activation
46
+ * string straight to Settings, so the customer never types anything.
47
+ *
48
+ * Nothing equivalent exists on Android: install there is Settings → Network →
49
+ * add eSIM → enter manually, which is what the written steps below are for.
50
+ */
51
+ function directInstallUrl(activationCode: string): string {
52
+ return `https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=${encodeURIComponent(
53
+ activationCode,
54
+ )}`;
55
+ }
56
+
32
57
  export function ActivateClient({ orderId }: Props) {
33
58
  const [phone, setPhone] = useState("");
34
59
  const [submitted, setSubmitted] = useState(false);
@@ -37,6 +62,11 @@ export function ActivateClient({ orderId }: Props) {
37
62
  const [state, setState] = useState<LoadState>({ phase: "loading" });
38
63
  const [copied, setCopied] = useState(false);
39
64
  const pollsRef = useRef(0);
65
+ // Bumped by "Check again". The polling effect depends on it, so a new value
66
+ // tears the old loop down and starts a fresh one. Resetting the counter and
67
+ // calling loadOrder once would fetch a single time and then sit on the
68
+ // spinner forever, because loadOrder is stable and the effect never re-runs.
69
+ const [pollCycle, setPollCycle] = useState(0);
40
70
 
41
71
  const loadOrder = useCallback(async (): Promise<EsimOrder | null> => {
42
72
  try {
@@ -46,11 +76,21 @@ export function ActivateClient({ orderId }: Props) {
46
76
  const payload = (await res.json().catch(() => ({}))) as {
47
77
  order?: EsimOrder;
48
78
  error?: string;
79
+ code?: string;
49
80
  };
50
81
  if (!res.ok || !payload.order) {
82
+ // 404 and 403 are settled answers: the order is unknown, or it is not
83
+ // this account's. Retrying returns the same status, and a spinner over
84
+ // the top of it is a lie about what the page is waiting for.
85
+ const terminal = res.status === 404 || res.status === 403 || payload.code === "not_found";
51
86
  setState({
52
87
  phase: "error",
53
- message: payload.error ?? "We could not load your eSIM details right now.",
88
+ terminal,
89
+ message:
90
+ payload.error ??
91
+ (terminal
92
+ ? "We can't find this order on your account."
93
+ : "We could not load your eSIM details right now."),
54
94
  });
55
95
  return null;
56
96
  }
@@ -59,6 +99,7 @@ export function ActivateClient({ orderId }: Props) {
59
99
  } catch {
60
100
  setState({
61
101
  phase: "error",
102
+ terminal: false,
62
103
  message: "We could not reach your eSIM details. Check your connection and try again.",
63
104
  });
64
105
  return null;
@@ -73,10 +114,16 @@ export function ActivateClient({ orderId }: Props) {
73
114
  const order = await loadOrder();
74
115
  if (cancelled) return;
75
116
  const stillWorking = order?.status === "pending" || order?.status === "provisioning";
76
- if (stillWorking && pollsRef.current < MAX_POLLS) {
117
+ if (!stillWorking) return;
118
+ if (pollsRef.current < MAX_POLLS) {
77
119
  pollsRef.current += 1;
78
120
  timer = setTimeout(tick, POLL_INTERVAL_MS);
121
+ return;
79
122
  }
123
+ // Out of polls and still not ready. Stop the spinner and say so — it has
124
+ // now taken materially longer than provisioning normally does, and
125
+ // "we're preparing your eSIM" under an animation is no longer true.
126
+ setState({ phase: "stalled", order });
80
127
  }
81
128
 
82
129
  void tick();
@@ -84,7 +131,7 @@ export function ActivateClient({ orderId }: Props) {
84
131
  cancelled = true;
85
132
  if (timer) clearTimeout(timer);
86
133
  };
87
- }, [loadOrder]);
134
+ }, [loadOrder, pollCycle]);
88
135
 
89
136
  async function handlePhoneSubmit(e: FormEvent<HTMLFormElement>) {
90
137
  e.preventDefault();
@@ -120,7 +167,8 @@ export function ActivateClient({ orderId }: Props) {
120
167
  }
121
168
  }
122
169
 
123
- const order = state.phase === "ready" ? state.order : null;
170
+ const order =
171
+ state.phase === "ready" || state.phase === "stalled" ? state.order : null;
124
172
  const ready = order?.status === "ready";
125
173
  const manualCode =
126
174
  order?.activationCode ??
@@ -150,27 +198,68 @@ export function ActivateClient({ orderId }: Props) {
150
198
  {state.phase === "error" && (
151
199
  <>
152
200
  <p className="text-sand-900 dark:text-sand-50 text-sm font-medium mb-2">
153
- We couldn&apos;t load your eSIM
201
+ {state.terminal ? "We can't show this eSIM" : "We couldn't load your eSIM"}
154
202
  </p>
155
203
  <p className="text-sand-600 dark:text-sand-400 text-sm mb-4">{state.message}</p>
156
204
  <p className="text-sand-400 text-xs mb-4">
157
205
  Your payment is safe and your order number is above. Quote it if you contact us.
158
206
  </p>
207
+ <div className="flex items-center justify-center gap-3">
208
+ {/* No "Try again" on a terminal answer: the same request would
209
+ return the same status, and a button that cannot work is
210
+ worse than no button. */}
211
+ {!state.terminal && (
212
+ <button
213
+ type="button"
214
+ onClick={() => {
215
+ pollsRef.current = 0;
216
+ setState({ phase: "loading" });
217
+ void loadOrder();
218
+ }}
219
+ className="rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
220
+ >
221
+ Try again
222
+ </button>
223
+ )}
224
+ <a
225
+ href="/contact"
226
+ className={
227
+ state.terminal
228
+ ? "rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
229
+ : "rounded-full border border-sand-300 dark:border-sand-700 px-5 py-2 text-sm text-sand-900 dark:text-sand-50 hover:bg-white/10 transition-colors"
230
+ }
231
+ >
232
+ Contact support
233
+ </a>
234
+ </div>
235
+ </>
236
+ )}
237
+
238
+ {state.phase === "stalled" && (
239
+ <>
240
+ <p className="text-sand-900 dark:text-sand-50 text-sm font-medium mb-2">
241
+ This is taking longer than it should
242
+ </p>
243
+ <p className="text-sand-600 dark:text-sand-400 text-sm mb-4">
244
+ Your payment went through and your order is queued, but the eSIM isn&apos;t ready
245
+ yet. We&apos;ll email your QR code as soon as it is — you don&apos;t have to wait
246
+ here.
247
+ </p>
159
248
  <div className="flex items-center justify-center gap-3">
160
249
  <button
161
250
  type="button"
162
251
  onClick={() => {
163
252
  pollsRef.current = 0;
164
253
  setState({ phase: "loading" });
165
- void loadOrder();
254
+ setPollCycle((n) => n + 1);
166
255
  }}
167
- className="rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
256
+ className="rounded-full border border-sand-300 dark:border-sand-700 px-5 py-2 text-sm text-sand-900 dark:text-sand-50 hover:bg-white/10 transition-colors"
168
257
  >
169
- Try again
258
+ Check again
170
259
  </button>
171
260
  <a
172
261
  href="/contact"
173
- className="rounded-full border border-sand-300 dark:border-sand-700 px-5 py-2 text-sm text-sand-900 dark:text-sand-50 hover:bg-white/10 transition-colors"
262
+ className="rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
174
263
  >
175
264
  Contact support
176
265
  </a>
@@ -178,7 +267,7 @@ export function ActivateClient({ orderId }: Props) {
178
267
  </>
179
268
  )}
180
269
 
181
- {order && !ready && order.status !== "failed" && (
270
+ {state.phase === "ready" && order && !ready && order.status !== "failed" && (
182
271
  <>
183
272
  <div className="h-8 w-8 rounded-full border-2 border-[var(--brand-accent)] border-t-transparent animate-spin mx-auto mb-4" />
184
273
  <p className="text-sand-600 dark:text-sand-400 text-sm mb-2">
@@ -234,6 +323,18 @@ export function ActivateClient({ orderId }: Props) {
234
323
  )}
235
324
  </div>
236
325
 
326
+ {/* One tap, no typing — iOS 17.4+ only, and harmless everywhere else:
327
+ a phone that cannot handle the link opens a page that explains the
328
+ manual route, which is the next card down. */}
329
+ {ready && manualCode && (
330
+ <a
331
+ href={directInstallUrl(manualCode)}
332
+ className="mb-4 block w-full rounded-full bg-[var(--brand-accent)] px-6 py-3 text-center text-sm font-semibold text-white transition-opacity hover:opacity-90"
333
+ >
334
+ Install this eSIM on this iPhone
335
+ </a>
336
+ )}
337
+
237
338
  {/* Manual install — always available once we know the credentials, because
238
339
  a QR is useless on the one phone the customer is reading this on. */}
239
340
  {ready && (order?.smdpAddress || manualCode) && (
@@ -276,6 +377,36 @@ export function ActivateClient({ orderId }: Props) {
276
377
  {copied ? "Copied" : "Copy activation string"}
277
378
  </button>
278
379
  )}
380
+
381
+ {/* The steps in words. A customer whose camera cannot reach a second
382
+ screen, and whose phone is too old for the one-tap link, has the
383
+ credentials above and no idea where to type them. */}
384
+ <div className="mt-6 grid gap-5 border-t border-sand-200 pt-5 dark:border-sand-800">
385
+ <div>
386
+ <p className="mb-2 text-xs font-semibold tracking-wide text-sand-500 uppercase">
387
+ On iPhone
388
+ </p>
389
+ <ol className="list-decimal space-y-1 pl-4 text-sm text-sand-700 dark:text-sand-300">
390
+ <li>Open Settings, then Cellular (or Mobile Service).</li>
391
+ <li>Tap Add eSIM, then Use QR Code.</li>
392
+ <li>Tap Enter Details Manually.</li>
393
+ <li>Paste the SM-DP+ address and activation code from above.</li>
394
+ <li>Leave data roaming on — that is how the plan connects abroad.</li>
395
+ </ol>
396
+ </div>
397
+ <div>
398
+ <p className="mb-2 text-xs font-semibold tracking-wide text-sand-500 uppercase">
399
+ On Android
400
+ </p>
401
+ <ol className="list-decimal space-y-1 pl-4 text-sm text-sand-700 dark:text-sand-300">
402
+ <li>Open Settings, then Network &amp; internet.</li>
403
+ <li>Tap SIMs, then Add SIM or Download a SIM instead.</li>
404
+ <li>Choose Need help? then Enter it manually.</li>
405
+ <li>Paste the activation code from above.</li>
406
+ <li>Turn on roaming for this SIM once it appears.</li>
407
+ </ol>
408
+ </div>
409
+ </div>
279
410
  </div>
280
411
  )}
281
412
 
@@ -2,7 +2,7 @@ export const dynamic = "force-dynamic";
2
2
  /**
3
3
  * /activate/[orderId]
4
4
  *
5
- * MANGO-8/9: Post-purchase eSIM activation page.
5
+ * Post-purchase eSIM activation page.
6
6
  *
7
7
  * Phone number / WhatsApp is collected HERE — optionally — after the user
8
8
  * has already paid and created their account. It is never required at sign-up.
@@ -1,14 +1,23 @@
1
1
  /**
2
2
  * POST /api/checkout/guest
3
3
  *
4
- * MANGO-8/9: Purchase-first flow — creates a Stripe Checkout session for
4
+ * Purchase-first flow — creates a Stripe Checkout session for
5
5
  * unauthenticated users. No Clerk auth required.
6
6
  *
7
- * Body: { templateId: string, channel?: string }
7
+ * Body: { templateId: string, channel?: string, uiMode?: "hosted" | "embedded" }
8
8
  *
9
- * Returns: { url: string } — Stripe-hosted checkout URL
9
+ * Returns one of:
10
+ * { url: string } — hosted: send the browser there
11
+ * { clientSecret: string, sessionId } — embedded: mount it in our page
10
12
  *
11
- * The Stripe success_url encodes the templateId so the /checkout/success
13
+ * Embedded is asked for only when the storefront has a Stripe publishable key,
14
+ * and the route still answers "hosted" for anything else, so a deploy without
15
+ * one keeps selling. The session is created with redirect_on_completion set to
16
+ * `if_required`: a redirect-based payment method still returns through
17
+ * `return_url`, and a plain card finishes in the iframe and hands control back
18
+ * to the page, which navigates onward itself.
19
+ *
20
+ * The success / return URL encodes the templateId so the /checkout/success
12
21
  * page can display the correct plan and prompt sign-up / sign-in.
13
22
  *
14
23
  * The line item is built inline from the plan's own price (`price_data`, no
@@ -22,10 +31,22 @@ import { NextRequest, NextResponse } from "next/server";
22
31
  import { fetchTemplateCatalog } from "@/vendor/carrier/client";
23
32
  import { decideGuestCheckout, normalizeChannel } from "@/vendor/carrier/catalog-guard";
24
33
 
34
+ interface GuestCheckoutBody {
35
+ templateId?: string;
36
+ channel?: string;
37
+ uiMode?: string;
38
+ /**
39
+ * Set when this purchase adds data to an eSIM that already exists. Carried
40
+ * into Stripe metadata so fulfilment tops up that ICCID instead of
41
+ * provisioning a second eSIM the customer neither wants nor can install.
42
+ */
43
+ topUpForOrderId?: string;
44
+ }
45
+
25
46
  export async function POST(req: NextRequest): Promise<NextResponse> {
26
- let body: { templateId?: string; channel?: string };
47
+ let body: GuestCheckoutBody;
27
48
  try {
28
- body = (await req.json()) as { templateId?: string; channel?: string };
49
+ body = (await req.json()) as GuestCheckoutBody;
29
50
  } catch {
30
51
  return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
31
52
  }
@@ -71,6 +92,12 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
71
92
  const successUrl =
72
93
  `${appUrl}/checkout/success?templateId=${encodeURIComponent(templateId)}&session_id={CHECKOUT_SESSION_ID}`;
73
94
  const cancelUrl = `${appUrl}/shop`;
95
+ const embedded = body.uiMode === "embedded";
96
+ // Same charset gate the rest of the storefront applies to a Stripe session id.
97
+ const topUpForOrderId =
98
+ typeof body.topUpForOrderId === "string" && /^[A-Za-z0-9_-]{1,256}$/.test(body.topUpForOrderId)
99
+ ? body.topUpForOrderId
100
+ : null;
74
101
 
75
102
  const formBody = new URLSearchParams({
76
103
  mode: "payment",
@@ -79,8 +106,16 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
79
106
  "line_items[0][price_data][product_data][name]": plan.name,
80
107
  "line_items[0][price_data][product_data][description]": plan.description,
81
108
  "line_items[0][quantity]": "1",
82
- success_url: successUrl,
83
- cancel_url: cancelUrl,
109
+ // Embedded sessions take `return_url` and reject `success_url` /
110
+ // `cancel_url`: there is no hosted page to come back from, and cancelling
111
+ // is just closing the form.
112
+ ...(embedded
113
+ ? {
114
+ ui_mode: "embedded",
115
+ redirect_on_completion: "if_required",
116
+ return_url: successUrl,
117
+ }
118
+ : { success_url: successUrl, cancel_url: cancelUrl }),
84
119
  // Store templateId in BOTH session and payment_intent metadata so the
85
120
  // provisioning webhook can read it. The `checkout.session.completed` event
86
121
  // exposes `session.metadata` (NOT `payment_intent.metadata`), so the
@@ -95,6 +130,15 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
95
130
  "payment_intent_data[metadata][channel]": channel,
96
131
  // Allow guest checkout (no Stripe account required)
97
132
  "payment_method_collection": "always",
133
+ // Session-level and payment-intent-level, for the same reason templateId
134
+ // is: `checkout.session.completed` only exposes session metadata, and a
135
+ // top-up that loses its target order provisions a brand-new eSIM.
136
+ ...(topUpForOrderId
137
+ ? {
138
+ "metadata[topUpForOrderId]": topUpForOrderId,
139
+ "payment_intent_data[metadata][topUpForOrderId]": topUpForOrderId,
140
+ }
141
+ : {}),
98
142
  });
99
143
 
100
144
  const resp = await fetch("https://api.stripe.com/v1/checkout/sessions", {
@@ -117,6 +161,25 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
117
161
  );
118
162
  }
119
163
 
120
- const session = (await resp.json()) as { url: string };
164
+ const session = (await resp.json()) as {
165
+ id?: string;
166
+ url?: string;
167
+ client_secret?: string;
168
+ };
169
+
170
+ if (embedded) {
171
+ if (!session.client_secret || !session.id) {
172
+ // Stripe accepted the session but did not return what an embedded form
173
+ // needs. Say so rather than shipping an empty iframe — the client falls
174
+ // back to the hosted page on this code.
175
+ console.error("[checkout] embedded session has no client_secret");
176
+ return NextResponse.json(
177
+ { error: "We could not start checkout. Please try again in a moment.", code: "embed_unavailable" },
178
+ { status: 502 },
179
+ );
180
+ }
181
+ return NextResponse.json({ clientSecret: session.client_secret, sessionId: session.id });
182
+ }
183
+
121
184
  return NextResponse.json({ url: session.url });
122
185
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * POST /api/orders/[orderId]/cancel
3
+ *
4
+ * Stop billing one eSIM. Signed-in callers only, and only for an order they
5
+ * have already claimed.
6
+ *
7
+ * POST rather than DELETE because what happens upstream is a state change on a
8
+ * plan, not the removal of a record: the order, the ICCID and the usage
9
+ * history all survive a cancellation and the customer can still read them.
10
+ */
11
+ import { NextResponse } from "next/server";
12
+ import { fulfilmentFailureResponse, guardOrderRoute } from "@/lib/order-route-guard";
13
+ import { cancelOrder } from "@/vendor/carrier/client";
14
+
15
+ export const dynamic = "force-dynamic";
16
+
17
+ interface Context {
18
+ params: Promise<{ orderId: string }>;
19
+ }
20
+
21
+ export async function POST(_req: Request, ctx: Context): Promise<NextResponse> {
22
+ const guard = await guardOrderRoute(ctx.params);
23
+ if (!guard.ok) return guard.response;
24
+
25
+ const result = await cancelOrder(guard.orderId);
26
+ if (result.ok) {
27
+ return NextResponse.json({
28
+ cancelledAt: result.cancelledAt ?? new Date().toISOString(),
29
+ });
30
+ }
31
+
32
+ return fulfilmentFailureResponse(result.reason, "cancel this plan", guard.orderId);
33
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * GET /api/orders/[orderId]/top-up-options
3
+ *
4
+ * What more data on this eSIM would cost. Signed-in callers only, and only for
5
+ * an order they have already claimed.
6
+ *
7
+ * An empty list is a successful answer: this plan cannot be topped up. The
8
+ * dashboard says so rather than showing a button that refuses on tap.
9
+ */
10
+ import { NextResponse } from "next/server";
11
+ import { fulfilmentFailureResponse, guardOrderRoute } from "@/lib/order-route-guard";
12
+ import { fetchTopUpOptions } from "@/vendor/carrier/client";
13
+
14
+ export const dynamic = "force-dynamic";
15
+
16
+ interface Context {
17
+ params: Promise<{ orderId: string }>;
18
+ }
19
+
20
+ export async function GET(_req: Request, ctx: Context): Promise<NextResponse> {
21
+ const guard = await guardOrderRoute(ctx.params);
22
+ if (!guard.ok) return guard.response;
23
+
24
+ const lookup = await fetchTopUpOptions(guard.orderId);
25
+ if (lookup.ok) return NextResponse.json({ options: lookup.options });
26
+
27
+ return fulfilmentFailureResponse(lookup.reason, "load top-up options", guard.orderId);
28
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * GET /api/orders/[orderId]/usage
3
+ *
4
+ * The usage meter's data source. Signed-in callers only, and only for an order
5
+ * they have already claimed.
6
+ */
7
+ import { NextResponse } from "next/server";
8
+ import { fulfilmentFailureResponse, guardOrderRoute } from "@/lib/order-route-guard";
9
+ import { fetchUsage } from "@/vendor/carrier/client";
10
+
11
+ export const dynamic = "force-dynamic";
12
+
13
+ interface Context {
14
+ params: Promise<{ orderId: string }>;
15
+ }
16
+
17
+ export async function GET(_req: Request, ctx: Context): Promise<NextResponse> {
18
+ const guard = await guardOrderRoute(ctx.params);
19
+ if (!guard.ok) return guard.response;
20
+
21
+ const lookup = await fetchUsage(guard.orderId);
22
+ if (lookup.ok) return NextResponse.json({ usage: lookup.usage });
23
+
24
+ return fulfilmentFailureResponse(lookup.reason, "read your usage", guard.orderId);
25
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * POST /api/profile/phone
3
3
  *
4
- * MANGO-8/9: Saves an optional phone number to the user's Clerk profile.
4
+ * Saves an optional phone number to the user's Clerk profile.
5
5
  * Called from /activate/[orderId] — never required, always skippable.
6
6
  *
7
7
  * Body: { phone: string }