@carrierllc/mcp 0.13.41 → 0.13.47

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 (68) hide show
  1. package/dist/{chunk-6OAZJBLA.js → chunk-4XUAUQ3U.js} +67 -7
  2. package/dist/chunk-4XUAUQ3U.js.map +1 -0
  3. package/dist/cli.js +55 -9
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.js +66 -15
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/templates/storefront/CLERK.md +23 -0
  9. package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +29 -22
  10. package/templates/storefront/src/app/activate/[orderId]/page.tsx +2 -2
  11. package/templates/storefront/src/app/api/checkout/claim/route.ts +7 -6
  12. package/templates/storefront/src/app/api/checkout/guest/route.ts +72 -17
  13. package/templates/storefront/src/app/api/orders/[orderId]/route.ts +2 -2
  14. package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +51 -25
  15. package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +165 -131
  16. package/templates/storefront/src/app/checkout/success/FocusHeading.tsx +20 -0
  17. package/templates/storefront/src/app/checkout/success/page.tsx +136 -15
  18. package/templates/storefront/src/app/dashboard/EsimControls.tsx +20 -24
  19. package/templates/storefront/src/app/dashboard/page.tsx +43 -23
  20. package/templates/storefront/src/app/globals.css +44 -5
  21. package/templates/storefront/src/app/help/page.tsx +2 -2
  22. package/templates/storefront/src/app/layout.tsx +5 -3
  23. package/templates/storefront/src/app/page.tsx +27 -7
  24. package/templates/storefront/src/app/shop/ShopClient.tsx +106 -72
  25. package/templates/storefront/src/app/shop/page.tsx +7 -2
  26. package/templates/storefront/src/app/sign-in/[[...sign-in]]/StorefrontSignInClient.tsx +297 -0
  27. package/templates/storefront/src/app/sign-in/[[...sign-in]]/page.tsx +13 -14
  28. package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +31 -28
  29. package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +5 -2
  30. package/templates/storefront/src/brand.config.ts +16 -0
  31. package/templates/storefront/src/components/Providers.tsx +20 -1
  32. package/templates/storefront/src/components/faq/FaqSection.tsx +2 -2
  33. package/templates/storefront/src/components/footer/StorefrontFooter.tsx +7 -7
  34. package/templates/storefront/src/components/landing/FinalCTA.tsx +4 -9
  35. package/templates/storefront/src/components/landing/HeroSection.tsx +4 -7
  36. package/templates/storefront/src/components/landing/HowItWorks.tsx +1 -1
  37. package/templates/storefront/src/components/landing/PlanCard.tsx +23 -13
  38. package/templates/storefront/src/components/landing/TierPlanBoard.tsx +237 -0
  39. package/templates/storefront/src/components/nav/StorefrontNav.tsx +8 -11
  40. package/templates/storefront/src/components/shop/CountrySearch.tsx +94 -0
  41. package/templates/storefront/src/components/theme/BrandStyles.tsx +2 -2
  42. package/templates/storefront/src/components/theme/ThemeToggle.tsx +1 -1
  43. package/templates/storefront/src/components/theme/brand-accent-css.ts +21 -0
  44. package/templates/storefront/src/components/theme/brand-style.ts +88 -0
  45. package/templates/storefront/src/components/theme/clerk-appearance.ts +18 -2
  46. package/templates/storefront/src/lib/buyer-auth-copy.ts +83 -0
  47. package/templates/storefront/src/lib/buyer-error.ts +32 -0
  48. package/templates/storefront/src/lib/buyer-session.ts +41 -0
  49. package/templates/storefront/src/lib/catalog-countries.ts +179 -0
  50. package/templates/storefront/src/lib/checkout-client-error.ts +28 -0
  51. package/templates/storefront/src/lib/checkout-copy.ts +89 -0
  52. package/templates/storefront/src/lib/checkout-order-claim.ts +8 -7
  53. package/templates/storefront/src/lib/checkout-return.ts +18 -0
  54. package/templates/storefront/src/lib/clerk-instance.ts +89 -0
  55. package/templates/storefront/src/lib/order-reference.ts +15 -0
  56. package/templates/storefront/src/lib/request-origin.ts +28 -0
  57. package/templates/storefront/src/lib/sanitize-auth-redirect.ts +20 -4
  58. package/templates/storefront/src/lib/tap-target.ts +17 -0
  59. package/templates/storefront/src/lib/verify-checkout-session.ts +118 -19
  60. package/templates/storefront/src/middleware.ts +11 -0
  61. package/templates/storefront/src/vendor/carrier/catalog-guard.ts +9 -5
  62. package/templates/storefront/src/vendor/carrier/client.ts +22 -4
  63. package/templates/storefront/src/vendor/carrier/country-offers.ts +106 -0
  64. package/templates/storefront/src/vendor/carrier/stripe-price.ts +134 -0
  65. package/templates/storefront/src/vendor/carrier/tiers.ts +161 -0
  66. package/templates/storefront/src/vendor/carrier/types.ts +22 -0
  67. package/templates/storefront/src/vendor/geo/index.ts +15 -4
  68. package/dist/chunk-6OAZJBLA.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carrierllc/mcp",
3
- "version": "0.13.41",
3
+ "version": "0.13.47",
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)",
@@ -0,0 +1,23 @@
1
+ # Clerk instance switch
2
+
3
+ This template does not create or configure a Clerk application. Dev is the default. Production is a config switch for a later approval.
4
+
5
+ Set the switch with `NEXT_PUBLIC_CLERK_INSTANCE`.
6
+
7
+ | `NEXT_PUBLIC_CLERK_INSTANCE` | Publishable key | Secret key |
8
+ | --- | --- | --- |
9
+ | unset, or `dev` | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | `CLERK_SECRET_KEY` |
10
+ | `production` | `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY_PRODUCTION` | `CLERK_SECRET_KEY_PRODUCTION` |
11
+
12
+ Dev mode ignores the production key names even when they are present. Production mode does not fall back to the dev key names. Leave the production names empty until that instance exists.
13
+
14
+ `NEXT_PUBLIC_PASSWORD_RESET_ENABLED` controls the buyer reset path. Leave it unset to show Forgot password. Set it to `false` to hide that path on a storefront.
15
+
16
+ The sign-in title is `Sign in to` plus `NEXT_PUBLIC_BRAND_NAME`. Buyers are not shown an organization chooser: a session whose only pending task is `choose-organization` continues to `/dashboard`.
17
+
18
+ Buyers sign in and sign up on this shop. Set both of these so Clerk does not send them to its hosted pages:
19
+
20
+ - `NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in`
21
+ - `NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up`
22
+
23
+ `ClerkProvider` sets the same paths. A return path stays on this origin: a same-origin absolute URL is kept as its path, and that path is carried into Create account.
@@ -19,12 +19,15 @@
19
19
  * Phone is NEVER required — user can skip.
20
20
  */
21
21
  import { useCallback, useEffect, useRef, useState, FormEvent } from "react";
22
+ import brand from "@/brand.config";
23
+ import { buyerFacingError } from "@/lib/buyer-error";
22
24
  import {
23
25
  ANDROID_INSTALL_STEPS,
24
26
  IPHONE_INSTALL_STEPS,
25
27
  directInstallUrl,
26
28
  manualActivationCode,
27
29
  } from "@/lib/esim-install";
30
+ import { shortOrderReference, supportOrderHref } from "@/lib/order-reference";
28
31
  import type { EsimOrder } from "@/vendor/carrier/types";
29
32
 
30
33
  interface Props {
@@ -94,11 +97,9 @@ export function ActivateClient({ orderId }: Props) {
94
97
  setState({
95
98
  phase: "error",
96
99
  terminal,
97
- message:
98
- payload.error ??
99
- (terminal
100
- ? "We can't find this order on your account."
101
- : "We could not load your eSIM details right now."),
100
+ message: terminal
101
+ ? "We can't find this order on your account."
102
+ : "We could not load your eSIM details right now.",
102
103
  });
103
104
  return null;
104
105
  }
@@ -194,13 +195,16 @@ export function ActivateClient({ orderId }: Props) {
194
195
  body: JSON.stringify({ phone }),
195
196
  });
196
197
  if (!res.ok) {
197
- const err = (await res.json()) as { error?: string };
198
- throw new Error(err.error ?? "Failed to save phone number");
198
+ try {
199
+ await res.json();
200
+ } catch {
201
+ // The body is not buyer copy.
202
+ }
203
+ throw new Error("phone");
199
204
  }
200
205
  setSubmitted(true);
201
206
  } catch (err) {
202
- const msg = err instanceof Error ? err.message : "Failed to save. Please try again.";
203
- setSaveError(msg);
207
+ setSaveError(buyerFacingError("phone", err));
204
208
  } finally {
205
209
  setSaving(false);
206
210
  }
@@ -231,7 +235,7 @@ export function ActivateClient({ orderId }: Props) {
231
235
  <h1 className="text-2xl font-bold text-sand-900 dark:text-sand-50">
232
236
  {ready ? "Your eSIM is ready" : "Setting up your eSIM"}
233
237
  </h1>
234
- <p className="mt-2 text-sm text-sand-500">Order: {orderId}</p>
238
+ <p className="mt-2 text-sm text-sand-700 dark:text-sand-200">Reference {shortOrderReference(orderId)}</p>
235
239
  </div>
236
240
 
237
241
  <div className="rounded-2xl border border-sand-200 dark:border-sand-800 bg-white dark:bg-sand-900 p-8 text-center mb-8">
@@ -248,8 +252,11 @@ export function ActivateClient({ orderId }: Props) {
248
252
  {state.terminal ? "We can't show this eSIM" : "We couldn't load your eSIM"}
249
253
  </p>
250
254
  <p className="text-sand-600 dark:text-sand-400 text-sm mb-4">{state.message}</p>
251
- <p className="text-sand-400 text-xs mb-4">
252
- Your payment is safe and your order number is above. Quote it if you contact us.
255
+ <p className="text-sand-700 dark:text-sand-200 text-sm mb-4">
256
+ Your payment is safe.{" "}
257
+ <a className="inline-flex min-h-11 items-center font-medium text-[var(--brand-accent-text)]" href={supportOrderHref(brand.supportEmail, orderId)}>
258
+ Email {brand.supportEmail}
259
+ </a>
253
260
  </p>
254
261
  <div className="flex items-center justify-center gap-3">
255
262
  {/* No "Try again" on a terminal answer: the same request would
@@ -263,7 +270,7 @@ export function ActivateClient({ orderId }: Props) {
263
270
  setState({ phase: "loading" });
264
271
  void loadOrder();
265
272
  }}
266
- className="rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
273
+ className="rounded-full bg-[var(--brand-accent)] px-5 min-h-11 text-base font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90"
267
274
  >
268
275
  Try again
269
276
  </button>
@@ -272,8 +279,8 @@ export function ActivateClient({ orderId }: Props) {
272
279
  href="/contact"
273
280
  className={
274
281
  state.terminal
275
- ? "rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
276
- : "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"
282
+ ? "rounded-full bg-[var(--brand-accent)] px-5 min-h-11 text-base font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90"
283
+ : "rounded-full border border-sand-300 dark:border-sand-700 px-5 min-h-11 text-base text-sand-900 dark:text-sand-50 hover:bg-white/10 transition-colors"
277
284
  }
278
285
  >
279
286
  Contact support
@@ -300,13 +307,13 @@ export function ActivateClient({ orderId }: Props) {
300
307
  setState({ phase: "loading" });
301
308
  setPollCycle((n) => n + 1);
302
309
  }}
303
- 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"
310
+ className="rounded-full border border-sand-300 dark:border-sand-700 px-5 min-h-11 text-base text-sand-900 dark:text-sand-50 hover:bg-white/10 transition-colors"
304
311
  >
305
312
  Check again
306
313
  </button>
307
314
  <a
308
315
  href="/contact"
309
- className="rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
316
+ className="rounded-full bg-[var(--brand-accent)] px-5 min-h-11 text-base font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90"
310
317
  >
311
318
  Contact support
312
319
  </a>
@@ -337,7 +344,7 @@ export function ActivateClient({ orderId }: Props) {
337
344
  </p>
338
345
  <a
339
346
  href="/contact"
340
- className="inline-block rounded-full bg-[var(--brand-accent)] px-5 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
347
+ className="inline-block rounded-full bg-[var(--brand-accent)] px-5 min-h-11 text-base font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90"
341
348
  >
342
349
  Contact support
343
350
  </a>
@@ -390,7 +397,7 @@ export function ActivateClient({ orderId }: Props) {
390
397
  {ready && manualCode && (
391
398
  <a
392
399
  href={directInstallUrl(manualCode)}
393
- 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"
400
+ className="mb-4 block w-full rounded-full bg-[var(--brand-accent)] px-6 py-3 text-center text-sm font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90"
394
401
  >
395
402
  Install this eSIM on this iPhone
396
403
  </a>
@@ -433,7 +440,7 @@ export function ActivateClient({ orderId }: Props) {
433
440
  <button
434
441
  type="button"
435
442
  onClick={() => void copyActivationCode(manualCode)}
436
- className="mt-4 w-full rounded-lg border border-sand-300 dark:border-sand-700 px-4 py-2 text-sm text-sand-900 dark:text-sand-50 hover:bg-white/10 transition-colors"
443
+ className="mt-4 w-full rounded-lg border border-sand-300 dark:border-sand-700 px-4 min-h-11 text-base text-sand-900 dark:text-sand-50 hover:bg-white/10 transition-colors"
437
444
  >
438
445
  {copied ? "Copied" : "Copy activation string"}
439
446
  </button>
@@ -499,12 +506,12 @@ export function ActivateClient({ orderId }: Props) {
499
506
  value={phone}
500
507
  onChange={(e) => setPhone(e.target.value)}
501
508
  placeholder="+1 555 000 0000"
502
- className="flex-1 rounded-lg border border-sand-200 dark:border-sand-800 bg-sand-50 dark:bg-sand-950 px-3 py-2 text-sm text-sand-900 dark:text-sand-50 placeholder:text-sand-400 focus:border-[var(--brand-accent)] focus:outline-none"
509
+ className="flex-1 rounded-lg border border-sand-200 dark:border-sand-800 bg-sand-50 dark:bg-sand-950 px-3 min-h-11 text-base text-sand-900 dark:text-sand-50 placeholder:text-sand-400 focus:border-[var(--brand-accent)]"
503
510
  />
504
511
  <button
505
512
  type="submit"
506
513
  disabled={saving || !phone.trim()}
507
- className="rounded-lg bg-[var(--brand-accent)] px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50 whitespace-nowrap"
514
+ className="rounded-lg bg-[var(--brand-accent)] px-4 min-h-11 text-base font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90 disabled:opacity-50 whitespace-nowrap"
508
515
  >
509
516
  {saving ? "Saving…" : "Save"}
510
517
  </button>
@@ -9,8 +9,8 @@ export const dynamic = "force-dynamic";
9
9
  *
10
10
  * The orderId is the Stripe Checkout session_id (or templateId in dev/mock mode).
11
11
  */
12
- import { auth } from "@clerk/nextjs/server";
13
12
  import { redirect } from "next/navigation";
13
+ import { buyerUserId } from "@/lib/buyer-session";
14
14
  import { userOwnsOrder } from "@/lib/checkout-order-claim";
15
15
  import { ActivateClient } from "./ActivateClient";
16
16
 
@@ -21,7 +21,7 @@ interface Props {
21
21
  }
22
22
 
23
23
  export default async function ActivatePage({ params }: Props) {
24
- const { userId } = await auth();
24
+ const userId = await buyerUserId();
25
25
  if (!userId) redirect("/sign-in");
26
26
 
27
27
  const { orderId } = await params;
@@ -1,29 +1,30 @@
1
- import { auth } from "@clerk/nextjs/server";
2
1
  import { NextRequest, NextResponse } from "next/server";
2
+ import { buyerFacingError } from "@/lib/checkout-copy";
3
+ import { buyerUserId } from "@/lib/buyer-session";
3
4
  import { claimCheckoutOrder } from "@/lib/checkout-order-claim";
4
5
 
5
6
  export async function POST(req: NextRequest): Promise<NextResponse> {
6
- const { userId } = await auth();
7
+ const userId = await buyerUserId();
7
8
  if (!userId) {
8
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
9
+ return NextResponse.json({ error: buyerFacingError(401) }, { status: 401 });
9
10
  }
10
11
 
11
12
  let body: { sessionId?: string; templateId?: string };
12
13
  try {
13
14
  body = (await req.json()) as { sessionId?: string; templateId?: string };
14
15
  } catch {
15
- return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
16
+ return NextResponse.json({ error: buyerFacingError(400) }, { status: 400 });
16
17
  }
17
18
 
18
19
  const sessionId = body.sessionId ?? "";
19
20
  const templateId = body.templateId ?? "";
20
21
  if (!sessionId || !templateId) {
21
- return NextResponse.json({ error: "sessionId and templateId are required" }, { status: 400 });
22
+ return NextResponse.json({ error: buyerFacingError(400) }, { status: 400 });
22
23
  }
23
24
 
24
25
  const result = await claimCheckoutOrder(userId, sessionId, templateId);
25
26
  if (!result.ok) {
26
- return NextResponse.json({ error: result.error }, { status: result.status });
27
+ return NextResponse.json({ error: buyerFacingError(result.status) }, { status: result.status });
27
28
  }
28
29
 
29
30
  return NextResponse.json({ ok: true, orderId: result.orderId });
@@ -20,18 +20,20 @@
20
20
  * The success / return URL encodes the templateId so the /checkout/success
21
21
  * page can display the correct plan and prompt sign-up / sign-in.
22
22
  *
23
- * The line item is built inline from the plan's own price (`price_data`, no
24
- * Stripe price id), which means Stripe accepts whatever number we send. So the
25
- * plan has to be real before we get here: with a live Stripe key, a catalog
26
- * that fell back to sample plans is refused outright. Charging for
27
- * "Visit — Calm" at EUR 4.99 when no such product exists is a real payment
28
- * against nothing, and no webhook downstream can fulfil it.
23
+ * The line item is a Stripe Price id. The session charges that Price's
24
+ * current `unit_amount`, which is also what the page renders. A catalog that
25
+ * fell back to sample plans is refused when Stripe is configured: those plans
26
+ * have no Price id, and charging one would take money for a product that
27
+ * cannot be fulfilled. A live secret is refused as well — this template
28
+ * charges in Stripe test mode only.
29
29
  */
30
30
  import { auth, clerkClient } from "@clerk/nextjs/server";
31
31
  import { NextRequest, NextResponse } from "next/server";
32
32
  import { orderPurchaserEmail, userOwnsOrder } from "@/lib/checkout-order-claim";
33
33
  import { fetchOrder, fetchTopUpOptions, fetchTemplateCatalog } from "@/vendor/carrier/client";
34
34
  import { decideGuestCheckout, normalizeChannel } from "@/vendor/carrier/catalog-guard";
35
+ import { checkoutPriceFields, isLiveStripeKey, isStripePriceId, overlayStripePrices } from "@/vendor/carrier/stripe-price";
36
+ import type { SkuTemplate } from "@/vendor/carrier/types";
35
37
 
36
38
  interface GuestCheckoutBody {
37
39
  templateId?: string;
@@ -45,6 +47,26 @@ interface GuestCheckoutBody {
45
47
  topUpForOrderId?: string;
46
48
  }
47
49
 
50
+ /**
51
+ * The catalog only carries `stripe_price_id` for a caller whose bearer equals
52
+ * the worker's CARRIER_STOREFRONT_API_KEY. With a different or unset key the
53
+ * catalog still answers 200 and every plan arrives without a Price id, so the
54
+ * guard refuses each sale. The refusal stays: there is no inline-amount
55
+ * fallback. This line makes the cause visible in the server log, with no
56
+ * secret in it.
57
+ */
58
+ function logPlanNotSellable(plan: SkuTemplate | undefined): void {
59
+ if (plan && !isStripePriceId(plan.stripe_price_id)) {
60
+ console.error(
61
+ `[checkout] plan ${plan.id} has no Stripe Price id, so it cannot be sold. ` +
62
+ "The catalog omits it unless CARRIER_API_KEY on this storefront equals " +
63
+ "CARRIER_STOREFRONT_API_KEY on the Carrier worker; check both match.",
64
+ );
65
+ } else {
66
+ console.error("[checkout] plan is not sellable: the catalog row is incomplete");
67
+ }
68
+ }
69
+
48
70
  export async function POST(req: NextRequest): Promise<NextResponse> {
49
71
  // Resolve server identity for every request, independent of client input.
50
72
  // First purchases may remain anonymous; only top-ups require this identity.
@@ -113,7 +135,11 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
113
135
  if (!base || !/^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(slug)) {
114
136
  return NextResponse.json({ error: "Storefront unavailable" }, { status: 503 });
115
137
  }
116
- const response = await fetch(`${base}/api/hosted/${encodeURIComponent(slug)}`, { cache: "no-store" });
138
+ const catalogKey = process.env.CARRIER_API_KEY;
139
+ const response = await fetch(`${base}/api/hosted/${encodeURIComponent(slug)}`, {
140
+ cache: "no-store",
141
+ headers: catalogKey ? { Authorization: `Bearer ${catalogKey}` } : {},
142
+ });
117
143
  if (!response.ok) return NextResponse.json({ error: "Storefront unavailable" }, { status: 503 });
118
144
  const shop = await response.json();
119
145
  if (!shop || typeof shop !== "object" || Array.isArray(shop) ||
@@ -124,6 +150,16 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
124
150
  return NextResponse.json({ error: "Storefront unavailable" }, { status: 503 });
125
151
  }
126
152
  storefrontId = shop.id;
153
+ // A shop with its own connected Stripe account sells under that
154
+ // account. This template creates sessions on the platform account only,
155
+ // so a connected shop is refused until account-scoped checkout exists.
156
+ if (typeof shop.stripe_account_id === "string" && shop.stripe_account_id) {
157
+ console.error("[checkout] storefront has a connected Stripe account; platform-scoped checkout refused");
158
+ return NextResponse.json(
159
+ { error: "That plan cannot be purchased right now. Please try again in a few minutes.", code: "plan_not_sellable" },
160
+ { status: 503 },
161
+ );
162
+ }
127
163
  catalog = { source: "live" as const, templates: shop.plans };
128
164
  } else {
129
165
  catalog = await fetchTemplateCatalog();
@@ -143,6 +179,9 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
143
179
  "NEXT_PUBLIC_CARRIER_API_URL is unset or the catalog request failed",
144
180
  );
145
181
  }
182
+ if (decision.code === "plan_not_sellable") {
183
+ logPlanNotSellable(catalog.templates.find((p: SkuTemplate) => p.id === templateId));
184
+ }
146
185
  return NextResponse.json({ error: decision.error, code: decision.code }, { status: decision.status });
147
186
  }
148
187
 
@@ -155,12 +194,19 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
155
194
  }
156
195
  }
157
196
 
197
+ if (isLiveStripeKey(stripeKey)) {
198
+ console.error("[checkout] refusing a live Stripe key; test mode only");
199
+ return NextResponse.json(
200
+ { error: "Checkout is unavailable right now. Please try again in a moment.", code: "stripe_live_refused" },
201
+ { status: 503 },
202
+ );
203
+ }
204
+
158
205
  if (!stripeKey) {
159
- // No Stripe configured — return a mock URL so the storefront still builds
160
- // and renders without credentials. No money moves on this path.
161
- const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
162
- const successUrl = `${appUrl}/checkout/success?templateId=${encodeURIComponent(templateId)}&session_id=mock`;
163
- return NextResponse.json({ url: successUrl });
206
+ return NextResponse.json(
207
+ { error: "Checkout is unavailable right now.", code: "stripe_unconfigured" },
208
+ { status: 503 },
209
+ );
164
210
  }
165
211
 
166
212
  const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "";
@@ -180,14 +226,23 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
180
226
  return NextResponse.json({ error: "This order cannot be topped up" }, { status: 409 });
181
227
  }
182
228
  }
229
+ const [priced] = await overlayStripePrices([plan]);
230
+ const priceFields = checkoutPriceFields(priced ?? plan);
231
+ if (!priceFields) {
232
+ logPlanNotSellable(priced ?? plan);
233
+ return NextResponse.json(
234
+ {
235
+ error: "That plan cannot be purchased right now. Please try again in a few minutes.",
236
+ code: "plan_not_sellable",
237
+ },
238
+ { status: 503 },
239
+ );
240
+ }
241
+
183
242
  const formBody = new URLSearchParams({
184
243
  mode: "payment",
185
244
  ...(buyerEmail ? { customer_email: buyerEmail } : {}),
186
- "line_items[0][price_data][currency]": plan.currency.toLowerCase(),
187
- "line_items[0][price_data][unit_amount]": String(plan.price_cents),
188
- "line_items[0][price_data][product_data][name]": plan.name,
189
- "line_items[0][price_data][product_data][description]": plan.description,
190
- "line_items[0][quantity]": "1",
245
+ ...priceFields,
191
246
  // Embedded sessions take `return_url` and reject `success_url` /
192
247
  // `cancel_url`: there is no hosted page to come back from, and cancelling
193
248
  // is just closing the form.
@@ -9,8 +9,8 @@
9
9
  * an order must say so; the one thing it must never do is draw a reassuring
10
10
  * placeholder while the eSIM behind it does not exist.
11
11
  */
12
- import { auth } from "@clerk/nextjs/server";
13
12
  import { NextResponse } from "next/server";
13
+ import { buyerUserId } from "@/lib/buyer-session";
14
14
  import { userOwnsOrder } from "@/lib/checkout-order-claim";
15
15
  import { fetchOrder } from "@/vendor/carrier/client";
16
16
 
@@ -21,7 +21,7 @@ interface Context {
21
21
  }
22
22
 
23
23
  export async function GET(_req: Request, ctx: Context): Promise<NextResponse> {
24
- const { userId } = await auth();
24
+ const userId = await buyerUserId();
25
25
  if (!userId) {
26
26
  return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
27
27
  }
@@ -14,9 +14,10 @@
14
14
  * publishable key the browser goes to Stripe's hosted page and comes back.
15
15
  * Both take money; the embedded one loses fewer people on the way.
16
16
  *
17
- * After payment the user lands on /checkout/success, where they create an
18
- * account (email + password, no phone) or sign in, and are then sent to
19
- * /activate/[orderId].
17
+ * After payment the user lands on /checkout/success. That page confirms the
18
+ * payment on its own. Saving the eSIM to an account is an inline step on the
19
+ * same page, and it does not start another charge. A mock session id is an
20
+ * error, not a confirmation.
20
21
  *
21
22
  * Every string on this page is read by a customer. It names no environment
22
23
  * variable and gives no configuration instructions — an operator's deployment
@@ -30,6 +31,12 @@ import {
30
31
  EmbeddedStripeCheckout,
31
32
  canEmbedStripeCheckout,
32
33
  } from "@/components/checkout/EmbeddedStripeCheckout";
34
+ import {
35
+ BuyerCopyError,
36
+ checkoutErrorCopy,
37
+ GENERIC_CHECKOUT_ERROR,
38
+ readCheckoutJson,
39
+ } from "@/lib/checkout-client-error";
33
40
  import { conversionEvents } from "@/lib/conversion-events";
34
41
  import { normalizeChannel } from "@/vendor/carrier/catalog-guard";
35
42
  import type { SkuTemplate } from "@/vendor/carrier/types";
@@ -40,8 +47,6 @@ interface Props {
40
47
  session: { url: string } | null;
41
48
  }
42
49
 
43
- const GENERIC_CHECKOUT_ERROR = "Checkout is unavailable right now. Please try again in a moment.";
44
-
45
50
  /**
46
51
  * Where this visit came from, read off the landing URL.
47
52
  *
@@ -94,19 +99,21 @@ export function CheckoutClient({ plan, session }: Props) {
94
99
  }),
95
100
  })
96
101
  .then(async (res) => {
102
+ let body: { error?: string; code?: string; url?: string; clientSecret?: string; sessionId?: string } | null;
103
+ try {
104
+ body = (await readCheckoutJson(res)) as typeof body;
105
+ } catch {
106
+ body = null;
107
+ }
108
+ if (!body || typeof body !== "object") {
109
+ throw new BuyerCopyError(GENERIC_CHECKOUT_ERROR);
110
+ }
97
111
  if (!res.ok) {
98
- // The route returns customer-safe copy for every refusal it knows
99
- // about. Anything else (a proxy error page, say) gets the generic
100
- // line rather than whatever text happened to come back.
101
- const err = (await res.json().catch(() => ({}))) as { error?: string; code?: string };
102
- if (embed && err.code === "embed_unavailable") throw new EmbedUnavailable();
103
- throw new Error(err.code && err.error ? err.error : GENERIC_CHECKOUT_ERROR);
112
+ if (embed && body.code === "embed_unavailable") throw new EmbedUnavailable();
113
+ if (body.code && body.error) throw new BuyerCopyError(body.error);
114
+ throw new BuyerCopyError(GENERIC_CHECKOUT_ERROR);
104
115
  }
105
- return res.json() as Promise<{
106
- url?: string;
107
- clientSecret?: string;
108
- sessionId?: string;
109
- }>;
116
+ return body;
110
117
  })
111
118
  .then((data) => {
112
119
  if (embed && data.clientSecret && data.sessionId) {
@@ -116,10 +123,13 @@ export function CheckoutClient({ plan, session }: Props) {
116
123
  return;
117
124
  }
118
125
  if (data.url) {
126
+ if (data.url.includes("session_id=mock")) {
127
+ throw new BuyerCopyError(GENERIC_CHECKOUT_ERROR);
128
+ }
119
129
  window.location.href = data.url;
120
130
  return;
121
131
  }
122
- throw new Error(GENERIC_CHECKOUT_ERROR);
132
+ throw new BuyerCopyError(GENERIC_CHECKOUT_ERROR);
123
133
  })
124
134
  .catch((err: unknown) => {
125
135
  if (err instanceof EmbedUnavailable && !embedAbandonedRef.current) {
@@ -130,7 +140,7 @@ export function CheckoutClient({ plan, session }: Props) {
130
140
  return;
131
141
  }
132
142
  guestCheckoutPlanIdRef.current = null;
133
- setCheckoutError(err instanceof Error ? err.message : GENERIC_CHECKOUT_ERROR);
143
+ setCheckoutError(checkoutErrorCopy(err));
134
144
  setLoading(false);
135
145
  });
136
146
  };
@@ -140,7 +150,7 @@ export function CheckoutClient({ plan, session }: Props) {
140
150
  );
141
151
 
142
152
  useEffect(() => {
143
- if (!isLoaded) return;
153
+ if (!isLoaded || plan.price_unavailable === true) return;
144
154
 
145
155
  conversionEvents.beginCheckout(plan.name, plan.price_cents / 100);
146
156
 
@@ -189,14 +199,30 @@ export function CheckoutClient({ plan, session }: Props) {
189
199
  startGuestCheckout(false);
190
200
  }, [startGuestCheckout]);
191
201
 
202
+ if (plan.price_unavailable === true) {
203
+ return (
204
+ <div className="mx-auto max-w-lg px-4 py-32 text-center">
205
+ <p className="text-sand-700 dark:text-sand-200">
206
+ We can&apos;t show a price for this plan right now, so it can&apos;t be bought yet.
207
+ </p>
208
+ <a
209
+ href="/shop"
210
+ className="mt-6 inline-flex min-h-11 items-center rounded-full border border-sand-300 px-6 text-base text-sand-900 dark:border-sand-700 dark:text-sand-50"
211
+ >
212
+ ← Back to shop
213
+ </a>
214
+ </div>
215
+ );
216
+ }
217
+
192
218
  if (checkoutError) {
193
219
  return (
194
220
  <div className="mx-auto max-w-lg px-4 py-32 text-center">
195
- <p className="text-sand-700 dark:text-sand-200">{checkoutError}</p>
196
- <p className="mt-2 text-sm text-sand-400">{plan.name}</p>
221
+ <p role="alert" className="text-sand-700 dark:text-sand-200">{checkoutError}</p>
222
+ <p className="mt-2 text-base dark:text-sand-200 text-sand-600">{plan.name}</p>
197
223
  <a
198
224
  href="/shop"
199
- className="mt-6 inline-block rounded-full border border-sand-300 px-6 py-2 text-sm text-sand-900 transition-colors hover:bg-white/10 dark:border-sand-700 dark:text-sand-50"
225
+ className="mt-6 inline-flex min-h-11 items-center rounded-full border border-sand-300 px-6 text-base text-sand-900 dark:border-sand-700 dark:text-sand-50"
200
226
  >
201
227
  ← Back to shop
202
228
  </a>
@@ -210,7 +236,7 @@ export function CheckoutClient({ plan, session }: Props) {
210
236
  <h1 className="text-2xl font-semibold tracking-tight text-sand-900 dark:text-sand-50">
211
237
  Almost there.
212
238
  </h1>
213
- <p className="mt-2 mb-8 text-sm text-sand-500">
239
+ <p className="mt-2 mb-8 text-base text-sand-700 dark:text-sand-200">
214
240
  {plan.name} — your QR code is ready the moment this clears.
215
241
  </p>
216
242
  <EmbeddedStripeCheckout
@@ -225,10 +251,10 @@ export function CheckoutClient({ plan, session }: Props) {
225
251
  return (
226
252
  <div className="mx-auto max-w-lg px-4 py-32 text-center">
227
253
  <div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-[var(--brand-accent)] border-t-transparent" />
228
- <p className="text-sand-600 dark:text-sand-400">
254
+ <p className="text-sand-700 dark:text-sand-200">
229
255
  {loading ? "Preparing your checkout…" : "Redirecting to payment…"}
230
256
  </p>
231
- <p className="mt-2 text-sm text-sand-400">{plan.name}</p>
257
+ <p className="mt-2 text-base dark:text-sand-200 text-sand-600">{plan.name}</p>
232
258
  </div>
233
259
  );
234
260
  }