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