@carrierllc/mcp 0.13.40 → 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 (69) hide show
  1. package/dist/{chunk-WVXII7ZR.js → chunk-4XUAUQ3U.js} +67 -7
  2. package/dist/chunk-4XUAUQ3U.js.map +1 -0
  3. package/dist/cli.js +56 -10
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.js +71 -17
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/plugin/carrier/skills/wallet/SKILL.md +1 -1
  9. package/templates/storefront/CLERK.md +23 -0
  10. package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +29 -22
  11. package/templates/storefront/src/app/activate/[orderId]/page.tsx +2 -2
  12. package/templates/storefront/src/app/api/checkout/claim/route.ts +7 -6
  13. package/templates/storefront/src/app/api/checkout/guest/route.ts +72 -17
  14. package/templates/storefront/src/app/api/orders/[orderId]/route.ts +2 -2
  15. package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +51 -25
  16. package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +165 -131
  17. package/templates/storefront/src/app/checkout/success/FocusHeading.tsx +20 -0
  18. package/templates/storefront/src/app/checkout/success/page.tsx +136 -15
  19. package/templates/storefront/src/app/dashboard/EsimControls.tsx +20 -24
  20. package/templates/storefront/src/app/dashboard/page.tsx +43 -23
  21. package/templates/storefront/src/app/globals.css +44 -5
  22. package/templates/storefront/src/app/help/page.tsx +2 -2
  23. package/templates/storefront/src/app/layout.tsx +5 -3
  24. package/templates/storefront/src/app/page.tsx +27 -7
  25. package/templates/storefront/src/app/shop/ShopClient.tsx +106 -72
  26. package/templates/storefront/src/app/shop/page.tsx +7 -2
  27. package/templates/storefront/src/app/sign-in/[[...sign-in]]/StorefrontSignInClient.tsx +297 -0
  28. package/templates/storefront/src/app/sign-in/[[...sign-in]]/page.tsx +13 -14
  29. package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +31 -28
  30. package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +5 -2
  31. package/templates/storefront/src/brand.config.ts +16 -0
  32. package/templates/storefront/src/components/Providers.tsx +20 -1
  33. package/templates/storefront/src/components/faq/FaqSection.tsx +2 -2
  34. package/templates/storefront/src/components/footer/StorefrontFooter.tsx +7 -7
  35. package/templates/storefront/src/components/landing/FinalCTA.tsx +4 -9
  36. package/templates/storefront/src/components/landing/HeroSection.tsx +4 -7
  37. package/templates/storefront/src/components/landing/HowItWorks.tsx +1 -1
  38. package/templates/storefront/src/components/landing/PlanCard.tsx +23 -13
  39. package/templates/storefront/src/components/landing/TierPlanBoard.tsx +237 -0
  40. package/templates/storefront/src/components/nav/StorefrontNav.tsx +8 -11
  41. package/templates/storefront/src/components/shop/CountrySearch.tsx +94 -0
  42. package/templates/storefront/src/components/theme/BrandStyles.tsx +2 -2
  43. package/templates/storefront/src/components/theme/ThemeToggle.tsx +1 -1
  44. package/templates/storefront/src/components/theme/brand-accent-css.ts +21 -0
  45. package/templates/storefront/src/components/theme/brand-style.ts +88 -0
  46. package/templates/storefront/src/components/theme/clerk-appearance.ts +18 -2
  47. package/templates/storefront/src/lib/buyer-auth-copy.ts +83 -0
  48. package/templates/storefront/src/lib/buyer-error.ts +32 -0
  49. package/templates/storefront/src/lib/buyer-session.ts +41 -0
  50. package/templates/storefront/src/lib/catalog-countries.ts +179 -0
  51. package/templates/storefront/src/lib/checkout-client-error.ts +28 -0
  52. package/templates/storefront/src/lib/checkout-copy.ts +89 -0
  53. package/templates/storefront/src/lib/checkout-order-claim.ts +8 -7
  54. package/templates/storefront/src/lib/checkout-return.ts +18 -0
  55. package/templates/storefront/src/lib/clerk-instance.ts +89 -0
  56. package/templates/storefront/src/lib/order-reference.ts +15 -0
  57. package/templates/storefront/src/lib/request-origin.ts +28 -0
  58. package/templates/storefront/src/lib/sanitize-auth-redirect.ts +20 -4
  59. package/templates/storefront/src/lib/tap-target.ts +17 -0
  60. package/templates/storefront/src/lib/verify-checkout-session.ts +118 -19
  61. package/templates/storefront/src/middleware.ts +11 -0
  62. package/templates/storefront/src/vendor/carrier/catalog-guard.ts +9 -5
  63. package/templates/storefront/src/vendor/carrier/client.ts +22 -4
  64. package/templates/storefront/src/vendor/carrier/country-offers.ts +106 -0
  65. package/templates/storefront/src/vendor/carrier/stripe-price.ts +134 -0
  66. package/templates/storefront/src/vendor/carrier/tiers.ts +161 -0
  67. package/templates/storefront/src/vendor/carrier/types.ts +22 -0
  68. package/templates/storefront/src/vendor/geo/index.ts +15 -4
  69. package/dist/chunk-WVXII7ZR.js.map +0 -1
@@ -1,16 +1,18 @@
1
1
  type StripeCheckoutSession = {
2
+ status?: string;
2
3
  payment_status?: string;
3
4
  customer_email?: string | null;
4
5
  customer_details?: { email?: string | null };
6
+ metadata?: { storefront_id?: string; templateId?: string };
5
7
  };
6
8
 
7
9
  function sessionPurchaserEmail(session: StripeCheckoutSession): string | null {
8
10
  return session.customer_details?.email ?? session.customer_email ?? null;
9
11
  }
10
12
 
11
- type VerifyResult =
13
+ export type VerifyResult =
12
14
  | { ok: true; orderId: string; purchaserEmail: string | null }
13
- | { ok: false };
15
+ | { ok: false; status: "processing" | "unpaid" | "unavailable" | "rejected" };
14
16
 
15
17
  /**
16
18
  * Stripe Checkout session ids are opaque tokens (`cs_test_…` / `cs_live_…`).
@@ -20,6 +22,8 @@ type VerifyResult =
20
22
  * encode before interpolation.
21
23
  */
22
24
  const STRIPE_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,256}$/;
25
+ const STOREFRONT_ID_RE = /^sf_[A-Za-z0-9_-]{20}$/;
26
+ const STOREFRONT_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
23
27
 
24
28
  function safeStripeSessionId(sessionId: string): string | null {
25
29
  if (typeof sessionId !== "string" || !STRIPE_SESSION_ID_RE.test(sessionId)) {
@@ -29,42 +33,137 @@ function safeStripeSessionId(sessionId: string): string | null {
29
33
  }
30
34
 
31
35
  /**
32
- * Confirms a Stripe Checkout session is paid before allowing post-payment flows.
33
- * Mock sessions are only accepted when Stripe is not configured (local/dev builds).
36
+ * The shop this deployment sells for.
37
+ *
38
+ * `null` is a legacy storefront with no hosted slug. `"unavailable"` means a
39
+ * slug is configured but the canonical id could not be read — fail closed.
40
+ */
41
+ async function currentStorefrontId(): Promise<string | null | "unavailable"> {
42
+ const slug = process.env.NEXT_PUBLIC_STOREFRONT_SLUG?.trim() ?? "";
43
+ if (!slug) return null;
44
+ if (!STOREFRONT_SLUG_RE.test(slug)) return "unavailable";
45
+ const base = (process.env.NEXT_PUBLIC_CARRIER_API_URL ?? "").replace(/\/+$/, "");
46
+ if (!base) return "unavailable";
47
+
48
+ let response: Response;
49
+ try {
50
+ response = await fetch(`${base}/api/hosted/${encodeURIComponent(slug)}`, { cache: "no-store" });
51
+ } catch {
52
+ return "unavailable";
53
+ }
54
+ if (!response.ok) return "unavailable";
55
+
56
+ let shop: { id?: unknown; slug?: unknown };
57
+ try {
58
+ shop = (await response.json()) as { id?: unknown; slug?: unknown };
59
+ } catch {
60
+ return "unavailable";
61
+ }
62
+ if (!shop || typeof shop !== "object") return "unavailable";
63
+ if (typeof shop.id !== "string" || !STOREFRONT_ID_RE.test(shop.id) || shop.slug !== slug) {
64
+ return "unavailable";
65
+ }
66
+ return shop.id;
67
+ }
68
+
69
+ type ShopMatch = "match" | "mismatch" | "unavailable";
70
+
71
+ /**
72
+ * A failed shop lookup is unavailable. A lookup that succeeds and disagrees
73
+ * with the session is a mismatch. Those are different buyer states.
74
+ */
75
+ async function sessionShopMatch(
76
+ session: StripeCheckoutSession,
77
+ templateId: string,
78
+ ): Promise<ShopMatch> {
79
+ const current = await currentStorefrontId();
80
+ if (current === "unavailable") return "unavailable";
81
+ const metaTemplate = session.metadata?.templateId ?? "";
82
+ const sessionStoreId = session.metadata?.storefront_id ?? "";
83
+ const samePlan = metaTemplate.length > 0 && metaTemplate === templateId;
84
+ const sameStore = current === null ? sessionStoreId === "" : sessionStoreId === current;
85
+ return samePlan && sameStore ? "match" : "mismatch";
86
+ }
87
+
88
+ function paymentSettled(paymentStatus: string | undefined): boolean {
89
+ return paymentStatus === "paid" || paymentStatus === "no_payment_required";
90
+ }
91
+
92
+ /**
93
+ * Confirms a Stripe Checkout session is paid for this shop and this plan.
94
+ *
95
+ * `session_id=mock` is never a payment. `no_payment_required` counts as paid.
96
+ * A settled session whose `metadata.storefront_id` or `metadata.templateId`
97
+ * belongs to another shop is rejected with no purchaser email. A shop lookup
98
+ * that fails, or a non-JSON shop response, is unavailable. A completed
99
+ * session that is still unpaid is processing. An open or expired unpaid
100
+ * session did not go through. A failed Stripe read is unavailable.
34
101
  */
35
102
  export async function verifyCheckoutSession(
36
103
  sessionId: string,
37
104
  templateId: string,
38
105
  ): Promise<VerifyResult> {
39
- if (sessionId === "mock") {
40
- if (process.env.STRIPE_SECRET_KEY || !templateId) {
41
- return { ok: false };
42
- }
43
- return { ok: true, orderId: templateId, purchaserEmail: null };
106
+ if (sessionId === "mock" || templateId.length > 256) {
107
+ return { ok: false, status: "rejected" };
44
108
  }
45
109
 
46
110
  const safeSessionId = safeStripeSessionId(sessionId);
47
111
  if (!safeSessionId) {
48
- return { ok: false };
112
+ return { ok: false, status: "rejected" };
49
113
  }
50
114
 
51
115
  const stripeKey = process.env.STRIPE_SECRET_KEY;
52
116
  if (!stripeKey) {
53
- return { ok: false };
117
+ return { ok: false, status: "unavailable" };
54
118
  }
55
119
 
56
- const resp = await fetch(`https://api.stripe.com/v1/checkout/sessions/${safeSessionId}`, {
57
- headers: { Authorization: `Bearer ${stripeKey}` },
58
- });
120
+ let resp: Response;
121
+ try {
122
+ resp = await fetch(`https://api.stripe.com/v1/checkout/sessions/${safeSessionId}`, {
123
+ headers: { Authorization: `Bearer ${stripeKey}` },
124
+ });
125
+ } catch {
126
+ return { ok: false, status: "unavailable" };
127
+ }
59
128
 
60
129
  if (!resp.ok) {
61
- return { ok: false };
130
+ return { ok: false, status: "unavailable" };
131
+ }
132
+
133
+ let session: StripeCheckoutSession;
134
+ try {
135
+ session = (await resp.json()) as StripeCheckoutSession;
136
+ } catch {
137
+ return { ok: false, status: "unavailable" };
138
+ }
139
+ if (!session || typeof session !== "object") {
140
+ return { ok: false, status: "unavailable" };
141
+ }
142
+
143
+ const settled = paymentSettled(session.payment_status);
144
+ const awaiting = session.status === "complete" && session.payment_status === "unpaid";
145
+ if (settled || awaiting) {
146
+ const match = await sessionShopMatch(session, templateId);
147
+ switch (match) {
148
+ case "unavailable":
149
+ return { ok: false, status: "unavailable" };
150
+ case "mismatch":
151
+ return { ok: false, status: "rejected" };
152
+ case "match":
153
+ if (settled) {
154
+ return { ok: true, orderId: sessionId, purchaserEmail: sessionPurchaserEmail(session) };
155
+ }
156
+ return { ok: false, status: "processing" };
157
+ default: {
158
+ const unhandled: never = match;
159
+ return unhandled;
160
+ }
161
+ }
62
162
  }
63
163
 
64
- const session = (await resp.json()) as StripeCheckoutSession;
65
- if (session.payment_status !== "paid") {
66
- return { ok: false };
164
+ if (session.status === "expired" || session.payment_status === "unpaid") {
165
+ return { ok: false, status: "unpaid" };
67
166
  }
68
167
 
69
- return { ok: true, orderId: sessionId, purchaserEmail: sessionPurchaserEmail(session) };
168
+ return { ok: false, status: "unavailable" };
70
169
  }
@@ -4,6 +4,10 @@
4
4
  // the template's cf:build in the npm Release job. middleware.ts still runs on
5
5
  // the Edge runtime that the Cloudflare/OpenNext build supports.
6
6
  import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
7
+ import { applyClerkInstanceEnv } from "@/lib/clerk-instance";
8
+ import { buyerPendingTasksAllowed, pendingSessionTasks } from "@/lib/buyer-session";
9
+
10
+ applyClerkInstanceEnv();
7
11
 
8
12
  const isPublicRoute = createRouteMatcher([
9
13
  "/",
@@ -24,6 +28,13 @@ const isPublicRoute = createRouteMatcher([
24
28
 
25
29
  export default clerkMiddleware(async (auth, req) => {
26
30
  if (!isPublicRoute(req)) {
31
+ const { userId, sessionId, sessionStatus } = await auth({ treatPendingAsSignedOut: false });
32
+ const buyerPending =
33
+ Boolean(userId) &&
34
+ sessionStatus === "pending" &&
35
+ Boolean(sessionId) &&
36
+ buyerPendingTasksAllowed((await pendingSessionTasks(sessionId ?? "")) ?? []);
37
+ if (userId && (sessionStatus !== "pending" || buyerPending)) return;
27
38
  await auth.protect();
28
39
  }
29
40
  });
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { SkuTemplate } from "./types";
9
9
  import type { TemplateCatalog } from "./client";
10
+ import { isStripePriceId } from "./stripe-price";
10
11
 
11
12
  /** Codes the storefront maps to customer-facing copy. Never shown verbatim. */
12
13
  export type CheckoutRefusalCode = "plan_not_found" | "catalog_degraded" | "plan_not_sellable";
@@ -16,19 +17,22 @@ export type CheckoutDecision =
16
17
  | { ok: false; status: number; code: CheckoutRefusalCode; error: string };
17
18
 
18
19
  /**
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.
20
+ * A plan is only chargeable when the catalog row is a real product and it
21
+ * names the Stripe Price checkout will charge. The guest route sends that
22
+ * Price id. It does not send an inline amount, so a row with no Price id
23
+ * has nothing Stripe can charge.
23
24
  */
24
25
  function isSellable(plan: SkuTemplate): boolean {
25
26
  return (
27
+ plan.fixture !== true &&
28
+ !plan.id.startsWith("fixture-") &&
26
29
  Number.isSafeInteger(plan.price_cents) &&
27
30
  plan.price_cents > 0 &&
28
31
  typeof plan.currency === "string" &&
29
32
  /^[A-Za-z]{3}$/.test(plan.currency) &&
30
33
  typeof plan.name === "string" &&
31
- plan.name.trim().length > 0
34
+ plan.name.trim().length > 0 &&
35
+ isStripePriceId(plan.stripe_price_id)
32
36
  );
33
37
  }
34
38
 
@@ -12,7 +12,9 @@
12
12
  * to a charge must read `source`, not just the array. `fetchTemplates()` keeps
13
13
  * the plain-array shape for the pages that only render.
14
14
  */
15
+ import { countryCatalogMode, withCountryCodes } from "../../lib/catalog-countries";
15
16
  import type { EsimOrder, EsimUsage, SkuTemplate, TopUpOption } from "./types";
17
+ import { overlayStripePrices } from "./stripe-price";
16
18
 
17
19
  const MOCK_TEMPLATES: SkuTemplate[] = [
18
20
  {
@@ -111,7 +113,11 @@ function orderPath(orderId: string, action = ""): string | null {
111
113
  }
112
114
 
113
115
  function sampleCatalog(reason: CatalogDegradedReason): TemplateCatalog {
114
- return { templates: MOCK_TEMPLATES, source: "sample", reason };
116
+ return {
117
+ templates: withCountryCodes(MOCK_TEMPLATES, countryCatalogMode(process.env.NEXT_PUBLIC_COUNTRY_CATALOG)),
118
+ source: "sample",
119
+ reason,
120
+ };
115
121
  }
116
122
 
117
123
  /**
@@ -127,7 +133,9 @@ export async function fetchTemplateCatalog(): Promise<TemplateCatalog> {
127
133
  const templatesPath = slug
128
134
  ? `/api/templates?storefront=${encodeURIComponent(slug)}`
129
135
  : "/api/templates";
136
+ const key = apiKey();
130
137
  const res = await fetch(`${base}${templatesPath}`, {
138
+ headers: key ? { Authorization: `Bearer ${key}` } : {},
131
139
  next: slug
132
140
  ? { revalidate: CATALOG_REVALIDATE_SECONDS, tags: [`storefront:${slug}`] }
133
141
  : { revalidate: CATALOG_REVALIDATE_SECONDS },
@@ -137,15 +145,25 @@ export async function fetchTemplateCatalog(): Promise<TemplateCatalog> {
137
145
  if (!Array.isArray(templates) || templates.length === 0) {
138
146
  return sampleCatalog("upstream-error");
139
147
  }
140
- return { templates, source: "live" };
148
+ return {
149
+ templates: withCountryCodes(templates, countryCatalogMode(process.env.NEXT_PUBLIC_COUNTRY_CATALOG)),
150
+ source: "live",
151
+ };
141
152
  } catch {
142
153
  return sampleCatalog("network-error");
143
154
  }
144
155
  }
145
156
 
146
- /** Render-only convenience. Do not use on any path that creates a charge. */
157
+ /**
158
+ * Render-only convenience. Do not use on any path that creates a charge.
159
+ *
160
+ * When a test-mode Stripe key is set, each plan's displayed amount is the
161
+ * live Price `unit_amount`, not the catalog copy. Checkout still sends the
162
+ * Price id, so an edit in Stripe changes the page and the charge together.
163
+ */
147
164
  export async function fetchTemplates(): Promise<SkuTemplate[]> {
148
- return (await fetchTemplateCatalog()).templates;
165
+ const catalog = await fetchTemplateCatalog();
166
+ return overlayStripePrices(catalog.templates);
149
167
  }
150
168
 
151
169
  export async function createCheckoutSession(
@@ -0,0 +1,106 @@
1
+ /**
2
+ * One searchable row per country a plan covers.
3
+ *
4
+ * Same contract as `countryOffers` in
5
+ * `packages/storefront-runtime/src/countries.ts`: two-letter codes only,
6
+ * one row per code, cheapest catalogue price unconverted, no invented
7
+ * coverage. The template ships a copy because a scaffold is installed
8
+ * outside the monorepo. `storefront-country-search.test.js` fails if the
9
+ * two disagree.
10
+ */
11
+
12
+ const regionNames = new Intl.DisplayNames(["en"], { type: "region" });
13
+
14
+ export interface CountryOffer {
15
+ code: string;
16
+ name: string;
17
+ planIds: string[];
18
+ cheapestPlanId: string | null;
19
+ fromCents: number | null;
20
+ currency: string | null;
21
+ }
22
+
23
+ interface CoverablePlan {
24
+ id: string;
25
+ priceCents: number;
26
+ currency: string;
27
+ regions: string[];
28
+ }
29
+
30
+ function countryLabel(code: string): string {
31
+ const upper = code.trim().toUpperCase();
32
+ if (!/^[A-Z]{2}$/.test(upper)) return upper;
33
+ try {
34
+ const label = regionNames.of(upper);
35
+ if (!label || label.toUpperCase() === upper) return upper;
36
+ return label;
37
+ } catch {
38
+ return upper;
39
+ }
40
+ }
41
+
42
+ export function countryOffers(plans: CoverablePlan[]): CountryOffer[] {
43
+ const byCode = new Map<string, CoverablePlan[]>();
44
+ for (const plan of plans) {
45
+ for (const raw of plan.regions) {
46
+ const code = raw.trim().toUpperCase();
47
+ if (!/^[A-Z]{2}$/.test(code) || code === "ZZ") continue;
48
+ const list = byCode.get(code) ?? [];
49
+ if (!list.includes(plan)) list.push(plan);
50
+ byCode.set(code, list);
51
+ }
52
+ }
53
+ const offers: CountryOffer[] = [];
54
+ for (const [code, list] of byCode) {
55
+ const currencies = new Set(list.map((plan) => plan.currency.toUpperCase()));
56
+ const single = currencies.size === 1;
57
+ const cheapest = single ? list.reduce((a, b) => (b.priceCents < a.priceCents ? b : a)) : null;
58
+ offers.push({
59
+ code,
60
+ name: countryLabel(code),
61
+ planIds: list.map((plan) => plan.id),
62
+ cheapestPlanId: cheapest?.id ?? null,
63
+ fromCents: cheapest?.priceCents ?? null,
64
+ currency: single ? [...currencies][0] ?? null : null,
65
+ });
66
+ }
67
+ return offers.sort((a, b) => a.name.localeCompare(b.name, "en"));
68
+ }
69
+
70
+ /** Names buyers type that are not the Intl label. */
71
+ const SEARCH_ALIASES: Record<string, string> = {
72
+ turkey: "TR",
73
+ "czech republic": "CZ",
74
+ uk: "GB",
75
+ usa: "US",
76
+ uae: "AE",
77
+ holland: "NL",
78
+ };
79
+
80
+ /** Shown before the buyer types, in this order, and only when the catalog sells them. */
81
+ export const POPULAR_COUNTRY_CODES = ["DE", "FR", "ES", "IT", "GB", "US", "JP", "TR", "NL", "TH"] as const;
82
+
83
+ export function filterCountryOffers(offers: CountryOffer[], query: string): CountryOffer[] {
84
+ const needle = fold(query);
85
+ if (needle === "") return offers;
86
+ const alias = SEARCH_ALIASES[needle];
87
+ // An exact alias is that country. "UK" must not also match Ukraine.
88
+ if (alias) return offers.filter((offer) => offer.code === alias || fold(offer.name) === needle);
89
+ return offers.filter(
90
+ (offer) => fold(offer.name).includes(needle) || offer.code.toLowerCase() === needle,
91
+ );
92
+ }
93
+
94
+ export function visibleCountryOffers(offers: CountryOffer[], query: string): CountryOffer[] {
95
+ if (fold(query) === "") {
96
+ const rank = new Map<string, number>(POPULAR_COUNTRY_CODES.map((code, index) => [code, index]));
97
+ return offers
98
+ .filter((offer) => rank.has(offer.code))
99
+ .sort((a, b) => (rank.get(a.code) ?? 0) - (rank.get(b.code) ?? 0));
100
+ }
101
+ return filterCountryOffers(offers, query);
102
+ }
103
+
104
+ function fold(value: string): string {
105
+ return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim().toLowerCase();
106
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The amount on the page is the Stripe Price checkout will charge.
3
+ *
4
+ * Catalog `price_cents` is only a sellability record. A Price edited in
5
+ * Stripe test mode must show up on the next render, so display reads
6
+ * `unit_amount` from that Price and the Checkout Session sends the Price id.
7
+ * Live keys are refused: this template does not create live charges.
8
+ */
9
+ import { formatPrice } from "../geo/index";
10
+ import type { SkuTemplate } from "./types";
11
+
12
+ /** Stripe Price ids we will put on a Checkout Session. */
13
+ export const STRIPE_PRICE_ID_RE = /^price_[A-Za-z0-9_]+$/;
14
+
15
+ export interface StripePriceSnapshot {
16
+ id: string;
17
+ unit_amount: number | null;
18
+ currency: string;
19
+ }
20
+
21
+ export function isStripePriceId(value: unknown): value is string {
22
+ return typeof value === "string" && STRIPE_PRICE_ID_RE.test(value);
23
+ }
24
+
25
+ /** A live secret must never reach Stripe from this template. */
26
+ export function isLiveStripeKey(secret: string | undefined): boolean {
27
+ return typeof secret === "string" && (secret.startsWith("sk_live_") || secret.startsWith("rk_live_"));
28
+ }
29
+
30
+ export function isTestStripeKey(secret: string | undefined): secret is string {
31
+ return typeof secret === "string" && (secret.startsWith("sk_test_") || secret.startsWith("rk_test_"));
32
+ }
33
+
34
+ /**
35
+ * Replace the catalog amount with the Price checkout charges.
36
+ *
37
+ * A snapshot for a different Price id is ignored. A Price that cannot be
38
+ * read does not fall back to the catalog amount: the page shows unavailable.
39
+ */
40
+ export function applyStripePrice(plan: SkuTemplate, price: StripePriceSnapshot | null): SkuTemplate {
41
+ if (!isStripePriceId(plan.stripe_price_id)) return plan;
42
+ const readable =
43
+ price !== null &&
44
+ price.id === plan.stripe_price_id &&
45
+ price.unit_amount !== null &&
46
+ Number.isSafeInteger(price.unit_amount) &&
47
+ price.unit_amount > 0 &&
48
+ /^[A-Za-z]{3}$/.test(price.currency);
49
+ if (!readable || !price || price.unit_amount === null) {
50
+ return { ...plan, price_unavailable: true };
51
+ }
52
+ return {
53
+ ...plan,
54
+ price_cents: price.unit_amount,
55
+ currency: price.currency.toUpperCase(),
56
+ price_unavailable: false,
57
+ };
58
+ }
59
+
60
+ /** What the plan card renders. `unitAmount` is the Stripe Price `unit_amount`. */
61
+ export function chargedPagePrice(
62
+ plan: SkuTemplate,
63
+ price: StripePriceSnapshot | null,
64
+ ): { unitAmount: number | null; currency: string; label: string } {
65
+ const charged = applyStripePrice(plan, price);
66
+ if (charged.price_unavailable) {
67
+ return { unitAmount: null, currency: charged.currency, label: "Unavailable" };
68
+ }
69
+ return {
70
+ unitAmount: charged.price_cents,
71
+ currency: charged.currency,
72
+ label: formatPrice(charged.price_cents, charged.currency),
73
+ };
74
+ }
75
+
76
+ /** Checkout Session line item. The Price id, never inline `price_data`. */
77
+ export function checkoutPriceFields(plan: SkuTemplate): Record<string, string> | null {
78
+ if (plan.price_unavailable === true || !isStripePriceId(plan.stripe_price_id)) return null;
79
+ return {
80
+ "line_items[0][price]": plan.stripe_price_id,
81
+ "line_items[0][quantity]": "1",
82
+ };
83
+ }
84
+
85
+ export async function overlayStripePrices(
86
+ plans: SkuTemplate[],
87
+ deps: { secret?: string; fetchImpl?: typeof fetch } = {},
88
+ ): Promise<SkuTemplate[]> {
89
+ const secret = deps.secret ?? process.env.STRIPE_SECRET_KEY;
90
+ // Only a test key may be sent to Stripe. A live key, or a non-Stripe
91
+ // placeholder used by unit tests, leaves the catalog amount on the page.
92
+ if (!isTestStripeKey(secret)) return plans;
93
+ const fetchImpl = deps.fetchImpl ?? fetch;
94
+ const cache = new Map<string, StripePriceSnapshot | null>();
95
+ const priced: SkuTemplate[] = [];
96
+ for (const plan of plans) {
97
+ const id = plan.stripe_price_id;
98
+ if (!isStripePriceId(id)) {
99
+ priced.push(plan);
100
+ continue;
101
+ }
102
+ let snapshot = cache.get(id);
103
+ if (snapshot === undefined) {
104
+ snapshot = await readStripePrice(id, secret, fetchImpl);
105
+ cache.set(id, snapshot);
106
+ }
107
+ priced.push(applyStripePrice(plan, snapshot));
108
+ }
109
+ return priced;
110
+ }
111
+
112
+ async function readStripePrice(
113
+ id: string,
114
+ secret: string,
115
+ fetchImpl: typeof fetch,
116
+ ): Promise<StripePriceSnapshot | null> {
117
+ try {
118
+ const resp = await fetchImpl(`https://api.stripe.com/v1/prices/${encodeURIComponent(id)}`, {
119
+ headers: { Authorization: `Bearer ${secret}` },
120
+ cache: "no-store",
121
+ });
122
+ if (!resp.ok) return null;
123
+ const body = (await resp.json()) as {
124
+ id?: unknown;
125
+ unit_amount?: unknown;
126
+ currency?: unknown;
127
+ };
128
+ if (typeof body.id !== "string" || typeof body.currency !== "string") return null;
129
+ const unitAmount = typeof body.unit_amount === "number" ? body.unit_amount : null;
130
+ return { id: body.id, unit_amount: unitAmount, currency: body.currency };
131
+ } catch {
132
+ return null;
133
+ }
134
+ }