@carrierllc/mcp 0.11.16 → 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 (35) hide show
  1. package/dist/{chunk-Y7D6FHHT.js → chunk-J52VP7E7.js} +14 -2
  2. package/dist/chunk-J52VP7E7.js.map +1 -0
  3. package/dist/cli.js +22 -6
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.js +2 -31
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/plugin/carrier/agents/carrier-storefront-builder.md +3 -3
  9. package/plugin/carrier/skills/storefront/SKILL.md +2 -2
  10. package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +145 -14
  11. package/templates/storefront/src/app/activate/[orderId]/page.tsx +1 -1
  12. package/templates/storefront/src/app/api/checkout/guest/route.ts +72 -9
  13. package/templates/storefront/src/app/api/orders/[orderId]/cancel/route.ts +33 -0
  14. package/templates/storefront/src/app/api/orders/[orderId]/top-up-options/route.ts +28 -0
  15. package/templates/storefront/src/app/api/orders/[orderId]/usage/route.ts +25 -0
  16. package/templates/storefront/src/app/api/profile/phone/route.ts +1 -1
  17. package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +142 -39
  18. package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +1 -1
  19. package/templates/storefront/src/app/checkout/success/page.tsx +1 -1
  20. package/templates/storefront/src/app/dashboard/EsimControls.tsx +293 -0
  21. package/templates/storefront/src/app/dashboard/page.tsx +12 -1
  22. package/templates/storefront/src/app/shop/ShopClient.tsx +126 -5
  23. package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +1 -1
  24. package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +1 -1
  25. package/templates/storefront/src/brand.config.ts +19 -0
  26. package/templates/storefront/src/components/checkout/EmbeddedStripeCheckout.tsx +191 -0
  27. package/templates/storefront/src/components/landing/PlanCard.tsx +12 -4
  28. package/templates/storefront/src/lib/checkout-order-claim.ts +68 -4
  29. package/templates/storefront/src/lib/order-route-guard.ts +85 -0
  30. package/templates/storefront/src/middleware.ts +1 -1
  31. package/templates/storefront/src/vendor/carrier/client.ts +86 -1
  32. package/templates/storefront/src/vendor/carrier/types.ts +34 -0
  33. package/templates/storefront/src/vendor/geo/index.ts +45 -0
  34. package/templates/storefront/src/vendor/ui/brand.ts +8 -1
  35. package/dist/chunk-Y7D6FHHT.js.map +0 -1
@@ -1,18 +1,72 @@
1
1
  "use client";
2
+ /**
3
+ * ShopClient — pick a plan, see what it costs, pay.
4
+ *
5
+ * Selecting a card used to navigate straight to /checkout, so the shopper was
6
+ * handed to Stripe without ever seeing the total they were about to be charged.
7
+ * The order summary is that missing beat: name, price, what is included, total.
8
+ *
9
+ * On a wide screen the summary is a sticky column and is always in view. On a
10
+ * phone it is stacked underneath the plans, which is where the real defect was:
11
+ * tapping a card changed something the customer could not see, so the tap read
12
+ * as dead. `scrollSummaryIntoView` answers the tap by bringing the summary —
13
+ * and the pay button in it — on screen.
14
+ */
2
15
 
16
+ import { useCallback, useRef, useState } from "react";
3
17
  import { useRouter } from "next/navigation";
4
18
  import { PlanCard } from "@/components/landing/PlanCard";
5
19
  import { conversionEvents } from "@/lib/conversion-events";
20
+ import { formatDataAllowance, formatPrice, formatValidity } from "@/vendor/geo";
6
21
  import type { SkuTemplate } from "@/vendor/carrier/types";
7
22
 
23
+ /** Above this width the summary is a sticky column and never leaves the fold. */
24
+ const WIDE_VIEWPORT = "(min-width: 1024px)";
25
+
26
+ function prefersReducedMotion(): boolean {
27
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
28
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
29
+ }
30
+
31
+ function isWideViewport(): boolean {
32
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
33
+ return window.matchMedia(WIDE_VIEWPORT).matches;
34
+ }
35
+
8
36
  export function ShopClient({ plans }: { plans: SkuTemplate[] }) {
9
37
  const router = useRouter();
38
+ const [selected, setSelected] = useState<SkuTemplate | null>(null);
39
+ const summaryRef = useRef<HTMLElement | null>(null);
40
+
41
+ /**
42
+ * Bring the summary on screen after a selection, on narrow viewports only.
43
+ *
44
+ * `scrollIntoView` is missing in some engines and in jsdom, so the call is
45
+ * optional-chained: a browser that cannot scroll must still select the plan.
46
+ */
47
+ const scrollSummaryIntoView = useCallback(() => {
48
+ if (isWideViewport()) return;
49
+ requestAnimationFrame(() => {
50
+ summaryRef.current?.scrollIntoView?.({
51
+ behavior: prefersReducedMotion() ? "auto" : "smooth",
52
+ block: "start",
53
+ });
54
+ });
55
+ }, []);
10
56
 
11
57
  function handleSelect(plan: SkuTemplate) {
12
58
  conversionEvents.selectPlan(plan.name);
13
- router.push(`/checkout/${plan.id}`);
59
+ setSelected(plan);
60
+ scrollSummaryIntoView();
61
+ }
62
+
63
+ function handleContinue() {
64
+ if (!selected) return;
65
+ router.push(`/checkout/${selected.id}`);
14
66
  }
15
67
 
68
+ const total = selected ? formatPrice(selected.price_cents, selected.currency) : null;
69
+
16
70
  return (
17
71
  <div className="mx-auto max-w-6xl px-4 py-16">
18
72
  <h1 className="text-4xl font-semibold tracking-tight text-sand-900 dark:text-sand-50">
@@ -21,10 +75,77 @@ export function ShopClient({ plans }: { plans: SkuTemplate[] }) {
21
75
  <p className="mt-2 mb-12 text-sand-600 dark:text-sand-400">
22
76
  Instant activation. No contracts. Price next to the limit.
23
77
  </p>
24
- <div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
25
- {plans.map((plan, i) => (
26
- <PlanCard key={plan.id} plan={plan} featured={i === 1} onSelect={handleSelect} />
27
- ))}
78
+
79
+ <div className="grid grid-cols-1 items-start gap-8 lg:grid-cols-[minmax(0,1fr)_320px]">
80
+ <div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
81
+ {plans.map((plan, i) => (
82
+ <PlanCard
83
+ key={plan.id}
84
+ plan={plan}
85
+ featured={i === 1}
86
+ selected={selected?.id === plan.id}
87
+ onSelect={handleSelect}
88
+ />
89
+ ))}
90
+ </div>
91
+
92
+ <section
93
+ ref={summaryRef}
94
+ aria-label="Order summary"
95
+ aria-live="polite"
96
+ className="scroll-mt-24 rounded-2xl border border-sand-200 bg-white p-6 lg:sticky lg:top-24 dark:border-sand-800 dark:bg-sand-900"
97
+ >
98
+ <p className="text-xs font-semibold tracking-widest text-sand-500 uppercase">
99
+ Your order
100
+ </p>
101
+ <h2 className="mt-3 text-xl font-semibold tracking-tight text-sand-900 dark:text-sand-50">
102
+ {selected?.name ?? "Pick a plan"}
103
+ </h2>
104
+ <p className="mt-1 text-sm text-sand-500">
105
+ {selected
106
+ ? `${formatDataAllowance(selected.dataGb)} · ${formatValidity(selected.validityDays)}`
107
+ : "Choose a plan on the left and the total appears here."}
108
+ </p>
109
+
110
+ <dl className="my-5 grid gap-3 border-y border-sand-200 py-5 text-sm dark:border-sand-800">
111
+ <div className="flex items-baseline justify-between gap-3">
112
+ <dt className="text-sand-500">Plan</dt>
113
+ <dd className="font-mono tabular-nums text-sand-900 dark:text-sand-50">
114
+ {total ?? "—"}
115
+ </dd>
116
+ </div>
117
+ <div className="flex items-baseline justify-between gap-3">
118
+ <dt className="text-sand-500">eSIM activation</dt>
119
+ <dd className="font-mono tabular-nums text-leaf-600">Free</dd>
120
+ </div>
121
+ <div className="flex items-baseline justify-between gap-3">
122
+ <dt className="text-sand-500">Tax</dt>
123
+ <dd className="font-mono tabular-nums text-sand-900 dark:text-sand-50">Included</dd>
124
+ </div>
125
+ </dl>
126
+
127
+ <div className="flex items-baseline justify-between gap-3">
128
+ <span className="text-sm font-semibold text-sand-900 dark:text-sand-50">
129
+ Total today
130
+ </span>
131
+ <span className="font-mono text-2xl font-medium tabular-nums tracking-tight text-sand-900 dark:text-sand-50">
132
+ {total ?? "—"}
133
+ </span>
134
+ </div>
135
+
136
+ <button
137
+ type="button"
138
+ onClick={handleContinue}
139
+ disabled={!selected}
140
+ className="mt-6 w-full rounded-full px-6 py-3 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
141
+ style={{ background: "var(--brand-accent)" }}
142
+ >
143
+ {selected ? "Continue to payment" : "Select a plan first"}
144
+ </button>
145
+ <p className="mt-3 text-xs leading-relaxed text-sand-400">
146
+ Your QR code is ready the moment the payment clears. Install it before you fly.
147
+ </p>
148
+ </section>
28
149
  </div>
29
150
  </div>
30
151
  );
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * StorefrontSignUpClient
4
4
  *
5
- * MANGO-6/7: Custom email + password sign-up for the storefront.
5
+ * Custom email + password sign-up for the storefront.
6
6
  * Phone number is NEVER required. It is collected optionally on /activate/[orderId].
7
7
  *
8
8
  * Clerk v6 API (storefront uses @clerk/nextjs ^6):
@@ -2,7 +2,7 @@ export const dynamic = "force-dynamic";
2
2
  /**
3
3
  * /sign-up — Custom email + password sign-up flow for the storefront.
4
4
  *
5
- * MANGO-6/7: Phone number is never required here. It is collected optionally
5
+ * Phone number is never required here. It is collected optionally
6
6
  * on /activate/[orderId] after purchase.
7
7
  */
8
8
  import { Suspense } from "react";
@@ -6,10 +6,29 @@ const brand = {
6
6
  name: process.env.NEXT_PUBLIC_BRAND_NAME ?? "Carrier",
7
7
  legalName: "Lifecycle Innovations Limited",
8
8
  tagline: "Programmable connectivity, on demand.",
9
+ /** One sentence. Used for <meta name="description"> and social cards. */
10
+ description: "Data that works in 200+ countries. Instant QR activation. Human support.",
9
11
  domain: "carrier.llc",
10
12
  supportUrl: "https://carrier.llc/help",
11
13
  supportEmail: "support@carrier.llc",
12
14
  supportWhatsapp: "+17864604829",
15
+ /** Display name on transactional mail. */
16
+ emailFromName: "Carrier",
17
+ /**
18
+ * Mailbox that transactional mail is sent from. The address is this plus
19
+ * `domain`, never a literal: a from-address that drifts off the brand domain
20
+ * is mail the operator cannot authenticate, which is mail that bounces.
21
+ */
22
+ emailFromMailbox: "hello",
23
+ /**
24
+ * Public origin of this storefront, no trailing slash.
25
+ *
26
+ * Every absolute URL the server builds — Stripe return URLs, the activation
27
+ * link in an email — is derived from this. `carrier site deploy` writes the
28
+ * deployed origin into NEXT_PUBLIC_APP_URL, so the literal below is only the
29
+ * fallback for a scaffold that has not been deployed yet.
30
+ */
31
+ appUrl: process.env.NEXT_PUBLIC_APP_URL ?? "https://carrier.llc",
13
32
  colors: {
14
33
  bg: "#FAFAF7",
15
34
  accent: "#FF6B35",
@@ -0,0 +1,191 @@
1
+ "use client";
2
+ /**
3
+ * Stripe's embedded Checkout, mounted inside this storefront's own page.
4
+ *
5
+ * The alternative is `window.location.href = session.url`, which hands the
6
+ * customer to checkout.stripe.com and back: one full page load out of the
7
+ * brand and one back in, and every step between a plan and an installed eSIM
8
+ * is a step people drop out on. Embedded keeps the form here; Stripe still
9
+ * owns the iframe, so no card data touches this app.
10
+ *
11
+ * Stripe.js is loaded from js.stripe.com at runtime rather than bundled. That
12
+ * is Stripe's own requirement — the library must be served from their origin
13
+ * to stay PCI-compliant — and it keeps the template free of the @stripe/*
14
+ * packages, so a scaffolded storefront installs exactly what it did before.
15
+ *
16
+ * `onComplete` is the point of this component. The session is created with
17
+ * `redirect_on_completion=if_required`, so a card that needs no redirect
18
+ * finishes inside the iframe and leaves the customer looking at a done screen
19
+ * that goes nowhere. onComplete is where we take them onward.
20
+ */
21
+
22
+ import { useEffect, useRef, useState } from "react";
23
+
24
+ const STRIPE_JS_SRC = "https://js.stripe.com/v3/";
25
+
26
+ /**
27
+ * Empty when the deploy has no publishable key. The caller checks this before
28
+ * asking for an embedded session, so an unconfigured storefront falls back to
29
+ * the hosted redirect instead of mounting a form that can never load.
30
+ */
31
+ const PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "";
32
+
33
+ export const canEmbedStripeCheckout = PUBLISHABLE_KEY.length > 0;
34
+
35
+ interface EmbeddedCheckoutInstance {
36
+ mount: (selector: string | HTMLElement) => void;
37
+ unmount: () => void;
38
+ destroy: () => void;
39
+ }
40
+
41
+ interface StripeInstance {
42
+ createEmbeddedCheckoutPage?: (options: {
43
+ fetchClientSecret: () => Promise<string>;
44
+ onComplete?: () => void;
45
+ }) => Promise<EmbeddedCheckoutInstance>;
46
+ /** Name this method carried before `createEmbeddedCheckoutPage`. */
47
+ initEmbeddedCheckout?: (options: {
48
+ fetchClientSecret: () => Promise<string>;
49
+ onComplete?: () => void;
50
+ }) => Promise<EmbeddedCheckoutInstance>;
51
+ }
52
+
53
+ type StripeConstructor = (key: string) => StripeInstance;
54
+
55
+ declare global {
56
+ interface Window {
57
+ Stripe?: StripeConstructor;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Load Stripe.js once per document, sharing one in-flight load between mounts.
63
+ *
64
+ * The promise is cached rather than the script tag. Attaching listeners to a
65
+ * tag that has already finished is the trap here: a script that failed fires
66
+ * neither `load` nor `error` again, so a second attempt would wait forever and
67
+ * the customer would sit on a spinner on the payment step. On failure the tag
68
+ * and the cached promise are both dropped, so a retry starts genuinely fresh.
69
+ */
70
+ let stripeJsPromise: Promise<StripeConstructor> | null = null;
71
+
72
+ function loadStripeJs(): Promise<StripeConstructor> {
73
+ if (typeof window === "undefined") {
74
+ return Promise.reject(new Error("Stripe.js cannot load on the server"));
75
+ }
76
+ if (window.Stripe) return Promise.resolve(window.Stripe);
77
+ if (stripeJsPromise) return stripeJsPromise;
78
+
79
+ stripeJsPromise = new Promise<StripeConstructor>((resolve, reject) => {
80
+ const script = document.createElement("script");
81
+ const fail = (message: string) => {
82
+ script.remove();
83
+ reject(new Error(message));
84
+ };
85
+ script.addEventListener(
86
+ "load",
87
+ () => {
88
+ if (window.Stripe) resolve(window.Stripe);
89
+ else fail("Stripe.js loaded without exposing Stripe");
90
+ },
91
+ { once: true },
92
+ );
93
+ script.addEventListener("error", () => fail("Stripe.js failed to load"), { once: true });
94
+ script.src = STRIPE_JS_SRC;
95
+ script.async = true;
96
+ document.head.appendChild(script);
97
+ });
98
+
99
+ // Drop the cache on failure so the next mount retries instead of replaying
100
+ // the rejection for the rest of the session.
101
+ stripeJsPromise.catch(() => {
102
+ stripeJsPromise = null;
103
+ });
104
+
105
+ return stripeJsPromise;
106
+ }
107
+
108
+ interface Props {
109
+ /** `client_secret` of a Checkout Session created with ui_mode=embedded. */
110
+ clientSecret: string;
111
+ /**
112
+ * Called once the session completes without a redirect. This is what stops
113
+ * the customer stranded on Stripe's "payment received" panel.
114
+ */
115
+ onComplete: () => void;
116
+ /** Stripe.js could not load or mount. The caller falls back to hosted. */
117
+ onUnavailable?: (reason: string) => void;
118
+ }
119
+
120
+ export function EmbeddedStripeCheckout({ clientSecret, onComplete, onUnavailable }: Props) {
121
+ const containerRef = useRef<HTMLDivElement | null>(null);
122
+ const [mounted, setMounted] = useState(false);
123
+
124
+ // onComplete and onUnavailable are read through refs so that a caller who
125
+ // passes inline closures does not re-run this effect and open a second
126
+ // Checkout instance against one session.
127
+ const onCompleteRef = useRef(onComplete);
128
+ onCompleteRef.current = onComplete;
129
+ const onUnavailableRef = useRef(onUnavailable);
130
+ onUnavailableRef.current = onUnavailable;
131
+
132
+ useEffect(() => {
133
+ if (!canEmbedStripeCheckout || !clientSecret) return;
134
+ let cancelled = false;
135
+ let instance: EmbeddedCheckoutInstance | null = null;
136
+
137
+ void (async () => {
138
+ try {
139
+ const Stripe = await loadStripeJs();
140
+ if (cancelled) return;
141
+ const stripe = Stripe(PUBLISHABLE_KEY);
142
+ const create = stripe.createEmbeddedCheckoutPage ?? stripe.initEmbeddedCheckout;
143
+ if (!create) throw new Error("Stripe.js has no embedded Checkout constructor");
144
+
145
+ instance = await create.call(stripe, {
146
+ // A pure getter. Stripe re-reads this when it remounts, and fetching
147
+ // a fresh session here would charge one customer against two orders.
148
+ fetchClientSecret: () => Promise.resolve(clientSecret),
149
+ onComplete: () => onCompleteRef.current(),
150
+ });
151
+ if (cancelled || !containerRef.current) {
152
+ instance.destroy();
153
+ return;
154
+ }
155
+ instance.mount(containerRef.current);
156
+ setMounted(true);
157
+ } catch (error) {
158
+ if (cancelled) return;
159
+ onUnavailableRef.current?.(error instanceof Error ? error.message : String(error));
160
+ }
161
+ })();
162
+
163
+ return () => {
164
+ cancelled = true;
165
+ // destroy() rather than unmount(): React may remount this component, and
166
+ // a stale instance holding the same session would render twice.
167
+ try {
168
+ instance?.destroy();
169
+ } catch {
170
+ // The iframe is already gone. Nothing to clean up.
171
+ }
172
+ };
173
+ }, [clientSecret]);
174
+
175
+ if (!canEmbedStripeCheckout) return null;
176
+
177
+ return (
178
+ <div
179
+ data-testid="embedded-stripe-checkout"
180
+ className="rounded-2xl border border-sand-200 bg-white p-4 dark:border-sand-800 dark:bg-sand-900"
181
+ >
182
+ {!mounted && (
183
+ <div className="py-12 text-center">
184
+ <div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2 border-[var(--brand-accent)] border-t-transparent" />
185
+ <p className="text-sm text-sand-600 dark:text-sand-400">Loading the payment form…</p>
186
+ </div>
187
+ )}
188
+ <div ref={containerRef} />
189
+ </div>
190
+ );
191
+ }
@@ -10,9 +10,15 @@ interface PlanCardProps {
10
10
  href?: string;
11
11
  onSelect?: (plan: SkuTemplate) => void;
12
12
  featured?: boolean;
13
+ /**
14
+ * This plan is the one currently in the order summary. Only meaningful on
15
+ * the select-then-continue flow; a card that links straight to checkout is
16
+ * never in a selected state.
17
+ */
18
+ selected?: boolean;
13
19
  }
14
20
 
15
- export function PlanCard({ plan, href, onSelect, featured }: PlanCardProps) {
21
+ export function PlanCard({ plan, href, onSelect, featured, selected }: PlanCardProps) {
16
22
  const destination = href ?? (onSelect ? undefined : `/checkout/${plan.id}`);
17
23
 
18
24
  function handleSelect() {
@@ -50,19 +56,20 @@ export function PlanCard({ plan, href, onSelect, featured }: PlanCardProps) {
50
56
  <span
51
57
  className={cn(
52
58
  "mt-auto block rounded-full py-2 text-center text-sm font-semibold transition-colors",
53
- featured
59
+ featured || selected
54
60
  ? "text-white"
55
61
  : "border border-sand-300 text-sand-900 hover:bg-sand-100 dark:border-sand-700 dark:text-sand-50 dark:hover:bg-sand-800",
56
62
  )}
57
- style={featured ? { background: "var(--brand-accent)" } : undefined}
63
+ style={featured || selected ? { background: "var(--brand-accent)" } : undefined}
58
64
  >
59
- Select plan
65
+ {selected ? "Selected" : "Select plan"}
60
66
  </span>
61
67
  </>
62
68
  );
63
69
 
64
70
  const cardClass = cn(
65
71
  "relative flex flex-col gap-4 rounded-2xl border p-6 pt-8 transition-colors",
72
+ selected && "ring-2 ring-[var(--brand-accent)] ring-offset-2 ring-offset-sand-50 dark:ring-offset-sand-950",
66
73
  featured
67
74
  ? "border-[var(--brand-accent)] bg-[color-mix(in_srgb,var(--brand-accent)_6%,white)] dark:bg-[color-mix(in_srgb,var(--brand-accent)_12%,#0F0E0A)]"
68
75
  : "border-sand-200 bg-white hover:border-sand-300 dark:border-sand-800 dark:bg-sand-900 dark:hover:border-sand-700",
@@ -81,6 +88,7 @@ export function PlanCard({ plan, href, onSelect, featured }: PlanCardProps) {
81
88
  return (
82
89
  <motion.button
83
90
  type="button"
91
+ aria-pressed={selected ?? undefined}
84
92
  whileHover={{ y: -4 }}
85
93
  className={cn(cardClass, "w-full text-left")}
86
94
  onClick={handleSelect}
@@ -61,18 +61,44 @@ function sessionPurchaserEmail(session: StripeSession): string | null {
61
61
  return session.customer_details?.email ?? session.customer_email ?? null;
62
62
  }
63
63
 
64
- async function userMatchesSessionPurchaser(session: StripeSession, userId: string): Promise<boolean> {
64
+ /**
65
+ * Does this Clerk user own the address that paid for the session?
66
+ *
67
+ * `requireVerified` is the difference between two very different questions.
68
+ * Claiming an unclaimed order only needs the addresses on the account, because
69
+ * nobody loses anything. Taking an order off another account needs a verified
70
+ * address, because an unverified one is a string the claimant typed.
71
+ */
72
+ async function userMatchesSessionPurchaser(
73
+ session: StripeSession,
74
+ userId: string,
75
+ opts: { requireVerified?: boolean } = {},
76
+ ): Promise<boolean> {
65
77
  const purchaserEmail = sessionPurchaserEmail(session);
66
- if (!purchaserEmail) return true;
78
+ if (!purchaserEmail) return !opts.requireVerified;
67
79
 
68
80
  const client = await clerkClient();
69
81
  const user = await client.users.getUser(userId);
70
82
  const normalizedPurchaserEmail = purchaserEmail.toLowerCase();
71
83
  return user.emailAddresses.some(
72
- (address) => address.emailAddress.toLowerCase() === normalizedPurchaserEmail,
84
+ (address) =>
85
+ address.emailAddress.toLowerCase() === normalizedPurchaserEmail &&
86
+ (!opts.requireVerified || address.verification?.status === "verified"),
73
87
  );
74
88
  }
75
89
 
90
+ /** Drop an order id from a user's claimed list. Used when the order moves away. */
91
+ async function removeClaimedOrderFromUser(userId: string, orderId: string): Promise<void> {
92
+ const client = await clerkClient();
93
+ const user = await client.users.getUser(userId);
94
+ const existing = claimedOrderIds(user.privateMetadata as Record<string, unknown> | undefined);
95
+ if (!existing.includes(orderId)) return;
96
+
97
+ await client.users.updateUserMetadata(userId, {
98
+ privateMetadata: { claimedOrderIds: existing.filter((id) => id !== orderId) },
99
+ });
100
+ }
101
+
76
102
  async function addClaimedOrderToUser(userId: string, orderId: string): Promise<void> {
77
103
  const client = await clerkClient();
78
104
  const user = await client.users.getUser(userId);
@@ -108,7 +134,45 @@ export async function claimCheckoutOrder(
108
134
 
109
135
  const claimedBy = session.metadata?.claimedByUserId;
110
136
  if (claimedBy && claimedBy !== userId) {
111
- return { ok: false, status: 403, error: "This order is linked to another account" };
137
+ /*
138
+ * The order is already on another account. That is the normal shape of a
139
+ * guest purchase that has since become a real account: pay signed out,
140
+ * the order lands on whichever account first claimed it, and then the
141
+ * buyer signs in — or signs up — as themselves.
142
+ *
143
+ * Refusing here made the person who paid for the eSIM the one who could
144
+ * not see it. So the order moves to the account that owns the paying
145
+ * address, rather than the claim being rewritten to point at an identity
146
+ * nobody verified. The address is the fact Stripe recorded at payment
147
+ * time; a Clerk id is just who got here first.
148
+ *
149
+ * The bar for a move is a *verified* address on the signed-in account.
150
+ * Without that, anyone who learned a session id could take someone
151
+ * else's order by typing their email into a new account.
152
+ */
153
+ const ownsPayingAddress = await userMatchesSessionPurchaser(session, userId, {
154
+ requireVerified: true,
155
+ });
156
+ if (!ownsPayingAddress) {
157
+ return { ok: false, status: 403, error: "This order is linked to another account" };
158
+ }
159
+
160
+ const moved = await setStripeClaimedBy(sessionId, userId);
161
+ if (!moved) {
162
+ return { ok: false, status: 502, error: "Failed to claim order" };
163
+ }
164
+ const afterMove = await fetchStripeSession(sessionId);
165
+ if (afterMove?.metadata?.claimedByUserId !== userId) {
166
+ return { ok: false, status: 403, error: "This order is linked to another account" };
167
+ }
168
+
169
+ // Best-effort tidy-up. The order is already on the right account; a
170
+ // stale id left on the old one shows a dashboard row that no longer
171
+ // resolves, which is untidy, not broken.
172
+ await removeClaimedOrderFromUser(claimedBy, orderId).catch(() => {});
173
+
174
+ await addClaimedOrderToUser(userId, orderId);
175
+ return { ok: true, orderId };
112
176
  }
113
177
  if (!claimedBy) {
114
178
  const updated = await setStripeClaimedBy(sessionId, userId);
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The gate every order-scoped route handler stands behind.
3
+ *
4
+ * Signed in, and the order is one this user has claimed. Both checks belong
5
+ * here rather than in each handler because a route handler is reachable
6
+ * directly, and an ownership check that only three of four routes perform is
7
+ * the one an attacker uses.
8
+ *
9
+ * Refusals are deliberately indistinguishable: an order that does not exist
10
+ * and an order belonging to somebody else both answer 404, so the route cannot
11
+ * be used to find out which order ids are real.
12
+ */
13
+ import { auth } from "@clerk/nextjs/server";
14
+ import { NextResponse } from "next/server";
15
+ import { userOwnsOrder } from "@/lib/checkout-order-claim";
16
+ import type { OrderLookupError } from "@/vendor/carrier/client";
17
+
18
+ export type OrderRouteGuard =
19
+ | { ok: true; userId: string; orderId: string }
20
+ | { ok: false; response: NextResponse };
21
+
22
+ export async function guardOrderRoute(
23
+ params: Promise<{ orderId: string }>,
24
+ ): Promise<OrderRouteGuard> {
25
+ const { userId } = await auth();
26
+ if (!userId) {
27
+ return { ok: false, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
28
+ }
29
+
30
+ const { orderId } = await params;
31
+ if (!orderId) {
32
+ return {
33
+ ok: false,
34
+ response: NextResponse.json({ error: "orderId is required" }, { status: 400 }),
35
+ };
36
+ }
37
+
38
+ if (!(await userOwnsOrder(userId, orderId))) {
39
+ return {
40
+ ok: false,
41
+ response: NextResponse.json({ error: "Order not found" }, { status: 404 }),
42
+ };
43
+ }
44
+
45
+ return { ok: true, userId, orderId };
46
+ }
47
+
48
+ /**
49
+ * One fulfilment failure, as customer copy plus a status.
50
+ *
51
+ * `action` names the thing that did not happen ("read your usage", "cancel
52
+ * this plan"), because "something went wrong" tells the reader nothing about
53
+ * whether their eSIM is still working.
54
+ */
55
+ export function fulfilmentFailureResponse(
56
+ reason: OrderLookupError,
57
+ action: string,
58
+ orderId: string,
59
+ ): NextResponse {
60
+ if (reason === "not-found") {
61
+ return NextResponse.json({ error: "Order not found", code: "not_found" }, { status: 404 });
62
+ }
63
+
64
+ if (reason === "unconfigured") {
65
+ console.error(
66
+ `[orders] NEXT_PUBLIC_CARRIER_API_URL is unset — cannot ${action} for ${orderId}`,
67
+ );
68
+ return NextResponse.json(
69
+ {
70
+ error: `We can't ${action} yet. Please contact support with your order number.`,
71
+ code: "fulfilment_unconfigured",
72
+ },
73
+ { status: 503 },
74
+ );
75
+ }
76
+
77
+ console.error(`[orders] failed to ${action} for ${orderId}: ${reason}`);
78
+ return NextResponse.json(
79
+ {
80
+ error: `We couldn't ${action} right now. Please try again in a moment.`,
81
+ code: "fulfilment_unavailable",
82
+ },
83
+ { status: 502 },
84
+ );
85
+ }
@@ -9,7 +9,7 @@ const isPublicRoute = createRouteMatcher([
9
9
  "/sign-in(.*)",
10
10
  "/sign-up(.*)",
11
11
  "/api/health",
12
- // MANGO-8/9: guest checkout API and post-payment success page are public
12
+ // Guest checkout API and post-payment success page are public
13
13
  // (user has not yet created an account at this point in the purchase flow)
14
14
  "/api/checkout/guest",
15
15
  "/checkout/success(.*)",