@carrierllc/mcp 0.2.17 → 0.2.18
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/README.md +21 -1
- package/dist/cli.js +881 -80
- package/dist/cli.js.map +1 -1
- package/dist/index.js +177 -22
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/plugin/.claude-plugin/marketplace.json +2 -2
- package/plugin/carrier/.claude-plugin/plugin.json +1 -1
- package/plugin/carrier/README.md +31 -4
- package/plugin/carrier/agents/carrier-billing-auditor.md +1 -1
- package/plugin/carrier/commands/billing.md +8 -1
- package/plugin/carrier/commands/provision.md +18 -8
- package/plugin/carrier/commands/wallet.md +31 -0
- package/plugin/carrier/skills/carrier-operations/SKILL.md +12 -5
- package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +125 -0
- package/templates/storefront/src/app/activate/[orderId]/page.tsx +16 -12
- package/templates/storefront/src/app/api/checkout/claim/route.ts +30 -0
- package/templates/storefront/src/app/api/checkout/guest/route.ts +91 -0
- package/templates/storefront/src/app/api/profile/phone/route.ts +40 -0
- package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +71 -13
- package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +415 -0
- package/templates/storefront/src/app/checkout/success/page.tsx +59 -0
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +199 -0
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +18 -3
- package/templates/storefront/src/lib/checkout-order-claim.ts +127 -0
- package/templates/storefront/src/lib/complete-email-sign-up.ts +50 -0
- package/templates/storefront/src/lib/verify-checkout-session.ts +53 -0
- package/templates/storefront/src/middleware.ts +6 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* StorefrontSignUpClient
|
|
4
|
+
*
|
|
5
|
+
* MANGO-6/7: Custom email + password sign-up for the storefront.
|
|
6
|
+
* Phone number is NEVER required. It is collected optionally on /activate/[orderId].
|
|
7
|
+
*
|
|
8
|
+
* Clerk v6 API (storefront uses @clerk/nextjs ^6):
|
|
9
|
+
* useSignUp() → { signUp, isLoaded }
|
|
10
|
+
* useClerk() → { setActive }
|
|
11
|
+
*/
|
|
12
|
+
import { useState, FormEvent } from "react";
|
|
13
|
+
import { useSignUp } from "@clerk/nextjs/legacy";
|
|
14
|
+
import { useClerk } from "@clerk/nextjs";
|
|
15
|
+
import { useSearchParams, useRouter } from "next/navigation";
|
|
16
|
+
import { completeEmailSignUpAfterVerify } from "@/lib/complete-email-sign-up";
|
|
17
|
+
import { sanitizeAuthRedirect } from "@/lib/sanitize-auth-redirect";
|
|
18
|
+
|
|
19
|
+
type FlowStep = "credentials" | "verify" | "complete";
|
|
20
|
+
|
|
21
|
+
export function StorefrontSignUpClient() {
|
|
22
|
+
const searchParams = useSearchParams();
|
|
23
|
+
const rawRedirect = searchParams.get("redirect_url") ?? undefined;
|
|
24
|
+
const redirectTarget = sanitizeAuthRedirect(rawRedirect, "/dashboard");
|
|
25
|
+
|
|
26
|
+
const { signUp, isLoaded } = useSignUp();
|
|
27
|
+
const { setActive } = useClerk();
|
|
28
|
+
const router = useRouter();
|
|
29
|
+
|
|
30
|
+
const [step, setStep] = useState<FlowStep>("credentials");
|
|
31
|
+
const [email, setEmail] = useState("");
|
|
32
|
+
const [password, setPassword] = useState("");
|
|
33
|
+
const [code, setCode] = useState("");
|
|
34
|
+
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
const [loading, setLoading] = useState(false);
|
|
36
|
+
|
|
37
|
+
function clerkErrMsg(err: unknown): string {
|
|
38
|
+
const e = err as { errors?: Array<{ longMessage?: string; message?: string }> };
|
|
39
|
+
return (
|
|
40
|
+
e?.errors?.[0]?.longMessage ??
|
|
41
|
+
e?.errors?.[0]?.message ??
|
|
42
|
+
"Something went wrong. Please try again."
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function handleCredentials(e: FormEvent<HTMLFormElement>) {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
if (!isLoaded || !signUp) return;
|
|
49
|
+
setError(null);
|
|
50
|
+
setLoading(true);
|
|
51
|
+
try {
|
|
52
|
+
await signUp.create({ emailAddress: email, password });
|
|
53
|
+
await signUp.prepareEmailAddressVerification({ strategy: "email_code" });
|
|
54
|
+
setStep("verify");
|
|
55
|
+
} catch (err) {
|
|
56
|
+
setError(clerkErrMsg(err));
|
|
57
|
+
} finally {
|
|
58
|
+
setLoading(false);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function handleVerify(e: FormEvent<HTMLFormElement>) {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
if (!isLoaded || !signUp) return;
|
|
65
|
+
setError(null);
|
|
66
|
+
setLoading(true);
|
|
67
|
+
try {
|
|
68
|
+
const result = await signUp.attemptEmailAddressVerification({ code });
|
|
69
|
+
const completion = await completeEmailSignUpAfterVerify(signUp, result);
|
|
70
|
+
if (completion.status === "complete") {
|
|
71
|
+
setStep("complete");
|
|
72
|
+
await setActive({ session: completion.sessionId });
|
|
73
|
+
router.replace(redirectTarget);
|
|
74
|
+
} else {
|
|
75
|
+
setError(completion.message);
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
setError(clerkErrMsg(err));
|
|
79
|
+
} finally {
|
|
80
|
+
setLoading(false);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="w-full max-w-sm rounded-2xl border border-white/10 bg-white/5 p-8 backdrop-blur">
|
|
86
|
+
{step === "credentials" && (
|
|
87
|
+
<>
|
|
88
|
+
<h1 className="text-xl font-bold text-white mb-1">Create account</h1>
|
|
89
|
+
<p className="text-xs text-white/40 mb-6">Email and password — no phone required.</p>
|
|
90
|
+
<form onSubmit={handleCredentials} className="space-y-4">
|
|
91
|
+
<div>
|
|
92
|
+
<label className="block text-xs font-medium text-white/60 mb-1" htmlFor="sf-email">
|
|
93
|
+
Email address
|
|
94
|
+
</label>
|
|
95
|
+
<input
|
|
96
|
+
id="sf-email"
|
|
97
|
+
type="email"
|
|
98
|
+
autoComplete="email"
|
|
99
|
+
required
|
|
100
|
+
value={email}
|
|
101
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
102
|
+
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white placeholder-white/20 focus:border-[var(--brand-accent)] focus:outline-none"
|
|
103
|
+
placeholder="you@example.com"
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
<div>
|
|
107
|
+
<label className="block text-xs font-medium text-white/60 mb-1" htmlFor="sf-password">
|
|
108
|
+
Password
|
|
109
|
+
</label>
|
|
110
|
+
<input
|
|
111
|
+
id="sf-password"
|
|
112
|
+
type="password"
|
|
113
|
+
autoComplete="new-password"
|
|
114
|
+
required
|
|
115
|
+
minLength={8}
|
|
116
|
+
value={password}
|
|
117
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
118
|
+
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white placeholder-white/20 focus:border-[var(--brand-accent)] focus:outline-none"
|
|
119
|
+
placeholder="Min. 8 characters"
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
{error && (
|
|
123
|
+
<p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
|
|
124
|
+
{error}
|
|
125
|
+
</p>
|
|
126
|
+
)}
|
|
127
|
+
<button
|
|
128
|
+
type="submit"
|
|
129
|
+
disabled={loading || !isLoaded}
|
|
130
|
+
className="w-full rounded-lg bg-[var(--brand-accent)] py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
131
|
+
>
|
|
132
|
+
{loading ? "Creating account…" : "Continue"}
|
|
133
|
+
</button>
|
|
134
|
+
</form>
|
|
135
|
+
<p className="mt-4 text-center text-xs text-white/30">
|
|
136
|
+
Already have an account?{" "}
|
|
137
|
+
<a href="/sign-in" className="text-[var(--brand-accent)] hover:underline">
|
|
138
|
+
Sign in
|
|
139
|
+
</a>
|
|
140
|
+
</p>
|
|
141
|
+
</>
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
{step === "verify" && (
|
|
145
|
+
<>
|
|
146
|
+
<h1 className="text-xl font-bold text-white mb-1">Check your email</h1>
|
|
147
|
+
<p className="text-xs text-white/40 mb-6">
|
|
148
|
+
We sent a 6-digit code to <span className="text-white/70">{email}</span>.
|
|
149
|
+
</p>
|
|
150
|
+
<form onSubmit={handleVerify} className="space-y-4">
|
|
151
|
+
<div>
|
|
152
|
+
<label className="block text-xs font-medium text-white/60 mb-1" htmlFor="sf-code">
|
|
153
|
+
Verification code
|
|
154
|
+
</label>
|
|
155
|
+
<input
|
|
156
|
+
id="sf-code"
|
|
157
|
+
type="text"
|
|
158
|
+
inputMode="numeric"
|
|
159
|
+
autoComplete="one-time-code"
|
|
160
|
+
required
|
|
161
|
+
maxLength={6}
|
|
162
|
+
value={code}
|
|
163
|
+
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
|
|
164
|
+
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-center text-lg tracking-[0.4em] text-white placeholder-white/20 focus:border-[var(--brand-accent)] focus:outline-none"
|
|
165
|
+
placeholder="000000"
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
{error && (
|
|
169
|
+
<p className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
|
|
170
|
+
{error}
|
|
171
|
+
</p>
|
|
172
|
+
)}
|
|
173
|
+
<button
|
|
174
|
+
type="submit"
|
|
175
|
+
disabled={loading || code.length < 6}
|
|
176
|
+
className="w-full rounded-lg bg-[var(--brand-accent)] py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
177
|
+
>
|
|
178
|
+
{loading ? "Verifying…" : "Verify email"}
|
|
179
|
+
</button>
|
|
180
|
+
</form>
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
onClick={() => { setStep("credentials"); setError(null); setCode(""); }}
|
|
184
|
+
className="mt-4 w-full text-center text-xs text-white/30 hover:text-white/50"
|
|
185
|
+
>
|
|
186
|
+
← Back
|
|
187
|
+
</button>
|
|
188
|
+
</>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{step === "complete" && (
|
|
192
|
+
<div className="text-center space-y-3 py-4">
|
|
193
|
+
<div className="mx-auto size-8 animate-spin rounded-full border-2 border-[var(--brand-accent)] border-t-transparent" />
|
|
194
|
+
<p className="text-sm text-white/60">Setting up your account…</p>
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
export const dynamic = "force-dynamic";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* /sign-up — Custom email + password sign-up flow for the storefront.
|
|
4
|
+
*
|
|
5
|
+
* MANGO-6/7: Phone number is never required here. It is collected optionally
|
|
6
|
+
* on /activate/[orderId] after purchase.
|
|
7
|
+
*/
|
|
8
|
+
import { Suspense } from "react";
|
|
9
|
+
import { StorefrontSignUpClient } from "./StorefrontSignUpClient";
|
|
4
10
|
|
|
5
11
|
export const metadata = { title: "Sign up" };
|
|
6
12
|
|
|
7
13
|
export default function SignUpPage() {
|
|
8
14
|
return (
|
|
9
15
|
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4">
|
|
10
|
-
<
|
|
16
|
+
<Suspense
|
|
17
|
+
fallback={
|
|
18
|
+
<div className="text-center space-y-3">
|
|
19
|
+
<div className="mx-auto size-8 animate-spin rounded-full border-2 border-[var(--brand-accent)] border-t-transparent" />
|
|
20
|
+
<p className="text-sm text-white/60">Loading…</p>
|
|
21
|
+
</div>
|
|
22
|
+
}
|
|
23
|
+
>
|
|
24
|
+
<StorefrontSignUpClient />
|
|
25
|
+
</Suspense>
|
|
11
26
|
</div>
|
|
12
27
|
);
|
|
13
28
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { clerkClient } from "@clerk/nextjs/server";
|
|
2
|
+
import { verifyCheckoutSession } from "@/lib/verify-checkout-session";
|
|
3
|
+
|
|
4
|
+
type StripeSession = {
|
|
5
|
+
metadata?: Record<string, string>;
|
|
6
|
+
customer_email?: string | null;
|
|
7
|
+
customer_details?: { email?: string | null };
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
async function fetchStripeSession(sessionId: string): Promise<StripeSession | null> {
|
|
11
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
12
|
+
if (!stripeKey) return null;
|
|
13
|
+
|
|
14
|
+
const resp = await fetch(`https://api.stripe.com/v1/checkout/sessions/${sessionId}`, {
|
|
15
|
+
headers: { Authorization: `Bearer ${stripeKey}` },
|
|
16
|
+
});
|
|
17
|
+
if (!resp.ok) return null;
|
|
18
|
+
return (await resp.json()) as StripeSession;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function setStripeClaimedBy(sessionId: string, userId: string): Promise<boolean> {
|
|
22
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
23
|
+
if (!stripeKey) return false;
|
|
24
|
+
|
|
25
|
+
const formBody = new URLSearchParams({
|
|
26
|
+
"metadata[claimedByUserId]": userId,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const resp = await fetch(`https://api.stripe.com/v1/checkout/sessions/${sessionId}`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: {
|
|
32
|
+
Authorization: `Bearer ${stripeKey}`,
|
|
33
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
34
|
+
},
|
|
35
|
+
body: formBody.toString(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return resp.ok;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function claimedOrderIds(metadata: Record<string, unknown> | undefined): string[] {
|
|
42
|
+
const value = metadata?.claimedOrderIds;
|
|
43
|
+
return Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sessionPurchaserEmail(session: StripeSession): string | null {
|
|
47
|
+
return session.customer_details?.email ?? session.customer_email ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function userMatchesSessionPurchaser(session: StripeSession, userId: string): Promise<boolean> {
|
|
51
|
+
const purchaserEmail = sessionPurchaserEmail(session);
|
|
52
|
+
if (!purchaserEmail) return true;
|
|
53
|
+
|
|
54
|
+
const client = await clerkClient();
|
|
55
|
+
const user = await client.users.getUser(userId);
|
|
56
|
+
const normalizedPurchaserEmail = purchaserEmail.toLowerCase();
|
|
57
|
+
return user.emailAddresses.some(
|
|
58
|
+
(address) => address.emailAddress.toLowerCase() === normalizedPurchaserEmail,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function addClaimedOrderToUser(userId: string, orderId: string): Promise<void> {
|
|
63
|
+
const client = await clerkClient();
|
|
64
|
+
const user = await client.users.getUser(userId);
|
|
65
|
+
const existing = claimedOrderIds(user.privateMetadata as Record<string, unknown> | undefined);
|
|
66
|
+
if (existing.includes(orderId)) return;
|
|
67
|
+
|
|
68
|
+
await client.users.updateUserMetadata(userId, {
|
|
69
|
+
privateMetadata: { claimedOrderIds: [...existing, orderId] },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function claimCheckoutOrder(
|
|
74
|
+
userId: string,
|
|
75
|
+
sessionId: string,
|
|
76
|
+
templateId: string,
|
|
77
|
+
): Promise<{ ok: true; orderId: string } | { ok: false; status: number; error: string }> {
|
|
78
|
+
const verification = await verifyCheckoutSession(sessionId, templateId);
|
|
79
|
+
if (!verification.ok) {
|
|
80
|
+
return { ok: false, status: 400, error: "Invalid or unpaid checkout session" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const orderId = verification.orderId;
|
|
84
|
+
|
|
85
|
+
if (sessionId !== "mock" && process.env.STRIPE_SECRET_KEY) {
|
|
86
|
+
const session = await fetchStripeSession(sessionId);
|
|
87
|
+
if (!session) {
|
|
88
|
+
return { ok: false, status: 400, error: "Invalid or unpaid checkout session" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!(await userMatchesSessionPurchaser(session, userId))) {
|
|
92
|
+
return { ok: false, status: 403, error: "This order is linked to another account" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const claimedBy = session.metadata?.claimedByUserId;
|
|
96
|
+
if (claimedBy && claimedBy !== userId) {
|
|
97
|
+
return { ok: false, status: 403, error: "This order is linked to another account" };
|
|
98
|
+
}
|
|
99
|
+
if (!claimedBy) {
|
|
100
|
+
const updated = await setStripeClaimedBy(sessionId, userId);
|
|
101
|
+
if (!updated) {
|
|
102
|
+
return { ok: false, status: 502, error: "Failed to claim order" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const afterClaim = await fetchStripeSession(sessionId);
|
|
106
|
+
if (afterClaim?.metadata?.claimedByUserId !== userId) {
|
|
107
|
+
return { ok: false, status: 403, error: "This order is linked to another account" };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await addClaimedOrderToUser(userId, orderId);
|
|
113
|
+
return { ok: true, orderId };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function userOwnsOrder(userId: string, orderId: string): Promise<boolean> {
|
|
117
|
+
if (process.env.STRIPE_SECRET_KEY && orderId.startsWith("cs_")) {
|
|
118
|
+
const session = await fetchStripeSession(orderId);
|
|
119
|
+
return session?.metadata?.claimedByUserId === userId;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const client = await clerkClient();
|
|
123
|
+
const user = await client.users.getUser(userId);
|
|
124
|
+
return claimedOrderIds(user.privateMetadata as Record<string, unknown> | undefined).includes(
|
|
125
|
+
orderId,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
type SignUpVerificationResult = {
|
|
2
|
+
status: string;
|
|
3
|
+
createdSessionId: string | null;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
type SignUpForEmailVerify = {
|
|
7
|
+
missingFields?: string[];
|
|
8
|
+
unverifiedFields?: string[];
|
|
9
|
+
update: (params: Record<string, never>) => Promise<SignUpVerificationResult>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type EmailSignUpCompletion =
|
|
13
|
+
| { status: "complete"; sessionId: string }
|
|
14
|
+
| { status: "incomplete"; message: string };
|
|
15
|
+
|
|
16
|
+
export async function completeEmailSignUpAfterVerify(
|
|
17
|
+
signUp: SignUpForEmailVerify,
|
|
18
|
+
result: SignUpVerificationResult,
|
|
19
|
+
): Promise<EmailSignUpCompletion> {
|
|
20
|
+
if (result.status === "complete" && result.createdSessionId) {
|
|
21
|
+
return { status: "complete", sessionId: result.createdSessionId };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (result.status === "missing_requirements") {
|
|
25
|
+
const missingFields = signUp.missingFields ?? [];
|
|
26
|
+
const unverifiedFields = signUp.unverifiedFields ?? [];
|
|
27
|
+
const onlyPhoneMissing =
|
|
28
|
+
missingFields.length === 1 &&
|
|
29
|
+
missingFields[0] === "phone_number" &&
|
|
30
|
+
unverifiedFields.length === 0;
|
|
31
|
+
|
|
32
|
+
if (onlyPhoneMissing) {
|
|
33
|
+
try {
|
|
34
|
+
const updated = await signUp.update({});
|
|
35
|
+
if (updated.status === "complete" && updated.createdSessionId) {
|
|
36
|
+
return { status: "complete", sessionId: updated.createdSessionId };
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// Fall through to generic incomplete message.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
status: "incomplete",
|
|
45
|
+
message: "One more step required. Please complete your profile to continue.",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { status: "incomplete", message: "Verification incomplete. Please try again." };
|
|
50
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
type StripeCheckoutSession = {
|
|
2
|
+
payment_status?: string;
|
|
3
|
+
customer_email?: string | null;
|
|
4
|
+
customer_details?: { email?: string | null };
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
function sessionPurchaserEmail(session: StripeCheckoutSession): string | null {
|
|
8
|
+
return session.customer_details?.email ?? session.customer_email ?? null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type VerifyResult =
|
|
12
|
+
| { ok: true; orderId: string; purchaserEmail: string | null }
|
|
13
|
+
| { ok: false };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Confirms a Stripe Checkout session is paid before allowing post-payment flows.
|
|
17
|
+
* Mock sessions are only accepted when Stripe is not configured (local/dev builds).
|
|
18
|
+
*/
|
|
19
|
+
export async function verifyCheckoutSession(
|
|
20
|
+
sessionId: string,
|
|
21
|
+
templateId: string,
|
|
22
|
+
): Promise<VerifyResult> {
|
|
23
|
+
if (sessionId === "mock") {
|
|
24
|
+
if (process.env.STRIPE_SECRET_KEY || !templateId) {
|
|
25
|
+
return { ok: false };
|
|
26
|
+
}
|
|
27
|
+
return { ok: true, orderId: templateId, purchaserEmail: null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!sessionId) {
|
|
31
|
+
return { ok: false };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
35
|
+
if (!stripeKey) {
|
|
36
|
+
return { ok: false };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const resp = await fetch(`https://api.stripe.com/v1/checkout/sessions/${sessionId}`, {
|
|
40
|
+
headers: { Authorization: `Bearer ${stripeKey}` },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!resp.ok) {
|
|
44
|
+
return { ok: false };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const session = (await resp.json()) as StripeCheckoutSession;
|
|
48
|
+
if (session.payment_status !== "paid") {
|
|
49
|
+
return { ok: false };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { ok: true, orderId: sessionId, purchaserEmail: sessionPurchaserEmail(session) };
|
|
53
|
+
}
|
|
@@ -9,6 +9,12 @@ 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
|
|
13
|
+
// (user has not yet created an account at this point in the purchase flow)
|
|
14
|
+
"/api/checkout/guest",
|
|
15
|
+
"/checkout/success(.*)",
|
|
16
|
+
// The checkout page itself is public — unauthenticated users can initiate payment
|
|
17
|
+
"/checkout/(.*)",
|
|
12
18
|
]);
|
|
13
19
|
|
|
14
20
|
export default clerkMiddleware(async (auth, req) => {
|