Skip to content

Commit 7a64199

Browse files
feat: enhance pricing page with subscription confirmation and error handling
- Refactor PricingPage component to utilize Suspense for loading state. - Implement subscription confirmation logic after checkout session completion. - Improve error handling and messaging for subscription loading and checkout processes. - Remove money-back guarantee footer note from the pricing page. - Add new API route for confirming checkout sessions with Stripe. - Update tests to reflect changes in the pricing page structure.
1 parent 0f848d3 commit 7a64199

6 files changed

Lines changed: 222 additions & 56 deletions

File tree

app/(main)/pricing/page.tsx

Lines changed: 126 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"use client";
22

33
import dynamic from "next/dynamic";
4-
import { useEffect, useMemo, useState } from "react";
4+
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
5+
import { useRouter, useSearchParams } from "next/navigation";
56
import { LoadingSpinner } from "@/components/LoadingSpinner";
67
import type { PricingTier, PricingTierInfo } from "@/types";
78

@@ -13,55 +14,122 @@ const PricingPage = dynamic(
1314
}
1415
);
1516

16-
export default function PricingRoutePage() {
17+
function PricingRouteContent() {
18+
const router = useRouter();
19+
const searchParams = useSearchParams();
1720
const [tiers, setTiers] = useState<PricingTierInfo[]>([]);
1821
const [currentTier, setCurrentTier] = useState<PricingTier>("FREE");
19-
const [message, setMessage] = useState<string | null>(null);
22+
const [message, setMessage] = useState<{
23+
text: string;
24+
tone: "success" | "warning";
25+
} | null>(null);
2026

2127
const paidTierSet = useMemo(
2228
() => new Set<PricingTier>(["ADS_FREE", "LOCAL", "BYOK", "HOSTED_AI"]),
2329
[]
2430
);
2531

32+
const loadSubscriptionData = useCallback(async () => {
33+
try {
34+
const [tiersRes, currentRes] = await Promise.all([
35+
fetch("/api/subscription/tiers", {
36+
method: "GET",
37+
credentials: "include",
38+
cache: "no-store",
39+
}),
40+
fetch("/api/subscription/current", {
41+
method: "GET",
42+
credentials: "include",
43+
cache: "no-store",
44+
}),
45+
]);
46+
47+
if (tiersRes.ok) {
48+
const tiersData = (await tiersRes.json()) as {
49+
data?: PricingTierInfo[];
50+
};
51+
setTiers(tiersData.data ?? []);
52+
}
53+
if (currentRes.ok) {
54+
const currentData = (await currentRes.json()) as {
55+
data?: { tier?: PricingTier };
56+
};
57+
setCurrentTier(currentData.data?.tier ?? "FREE");
58+
}
59+
} catch {
60+
setMessage({
61+
text: "Failed to load pricing details. Please refresh.",
62+
tone: "warning",
63+
});
64+
}
65+
}, []);
66+
2667
useEffect(() => {
27-
const loadData = async () => {
28-
try {
29-
const [tiersRes, currentRes] = await Promise.all([
30-
fetch("/api/subscription/tiers", {
31-
method: "GET",
32-
credentials: "include",
33-
cache: "no-store",
34-
}),
35-
fetch("/api/subscription/current", {
36-
method: "GET",
37-
credentials: "include",
38-
cache: "no-store",
39-
}),
40-
]);
68+
void loadSubscriptionData();
69+
}, [loadSubscriptionData]);
70+
71+
const checkoutSessionId = searchParams.get("session_id")?.trim() ?? "";
72+
73+
useEffect(() => {
74+
if (!checkoutSessionId) return;
4175

42-
if (tiersRes.ok) {
43-
const tiersData = (await tiersRes.json()) as {
44-
data?: PricingTierInfo[];
45-
};
46-
setTiers(tiersData.data ?? []);
76+
let cancelled = false;
77+
78+
const confirmAndRefresh = async () => {
79+
try {
80+
const res = await fetch("/api/stripe/confirm-checkout-session", {
81+
method: "POST",
82+
headers: { "Content-Type": "application/json" },
83+
credentials: "include",
84+
body: JSON.stringify({ sessionId: checkoutSessionId }),
85+
});
86+
const payload = (await res.json()) as {
87+
success?: boolean;
88+
error?: string;
89+
};
90+
if (cancelled) return;
91+
if (!res.ok || !payload.success) {
92+
setMessage({
93+
text:
94+
payload.error ??
95+
"Could not confirm your subscription. If you were charged, contact support.",
96+
tone: "warning",
97+
});
98+
return;
4799
}
48-
if (currentRes.ok) {
49-
const currentData = (await currentRes.json()) as {
50-
data?: { tier?: PricingTier };
51-
};
52-
setCurrentTier(currentData.data?.tier ?? "FREE");
100+
await loadSubscriptionData();
101+
if (!cancelled) {
102+
setMessage({
103+
text: "Subscription active. Thank you!",
104+
tone: "success",
105+
});
53106
}
54107
} catch {
55-
setMessage("Failed to load pricing details. Please refresh.");
108+
if (!cancelled) {
109+
setMessage({
110+
text: "Could not confirm checkout. Please refresh the page.",
111+
tone: "warning",
112+
});
113+
}
114+
} finally {
115+
if (!cancelled) {
116+
router.replace("/pricing", { scroll: false });
117+
}
56118
}
57119
};
58120

59-
void loadData();
60-
}, []);
121+
void confirmAndRefresh();
122+
return () => {
123+
cancelled = true;
124+
};
125+
}, [router, checkoutSessionId, loadSubscriptionData]);
61126

62127
const handleSelectTier = async (tier: PricingTier) => {
63128
if (!paidTierSet.has(tier)) {
64-
setMessage("Free tier is already active by default.");
129+
setMessage({
130+
text: "Free tier is already active by default.",
131+
tone: "warning",
132+
});
65133
return;
66134
}
67135
setMessage(null);
@@ -78,12 +146,15 @@ export default function PricingRoutePage() {
78146
error?: string;
79147
};
80148
if (!response.ok || !payload.data?.url) {
81-
setMessage(payload.error ?? "Failed to start checkout.");
149+
setMessage({
150+
text: payload.error ?? "Failed to start checkout.",
151+
tone: "warning",
152+
});
82153
return;
83154
}
84155
window.location.assign(payload.data.url);
85156
} catch {
86-
setMessage("Failed to start checkout.");
157+
setMessage({ text: "Failed to start checkout.", tone: "warning" });
87158
}
88159
};
89160

@@ -95,10 +166,30 @@ export default function PricingRoutePage() {
95166
onSelectTier={handleSelectTier}
96167
/>
97168
{message && (
98-
<p className="mt-4 text-center text-sm text-amber-600 dark:text-amber-400">
99-
{message}
169+
<p
170+
className={`mt-4 text-center text-sm ${
171+
message.tone === "success"
172+
? "text-emerald-600 dark:text-emerald-400"
173+
: "text-amber-600 dark:text-amber-400"
174+
}`}
175+
>
176+
{message.text}
100177
</p>
101178
)}
102179
</div>
103180
);
104181
}
182+
183+
export default function PricingRoutePage() {
184+
return (
185+
<Suspense
186+
fallback={
187+
<div className="mt-6 sm:mt-8 lg:mt-10 flex justify-center py-16">
188+
<LoadingSpinner size="md" message="Loading pricing..." />
189+
</div>
190+
}
191+
>
192+
<PricingRouteContent />
193+
</Suspense>
194+
);
195+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getAuthenticatedUser } from "@/lib/server-auth";
3+
import { stripeBillingService } from "@/services/stripe-billing.service";
4+
5+
export const dynamic = "force-dynamic";
6+
7+
export async function POST(request: NextRequest) {
8+
try {
9+
const auth = await getAuthenticatedUser(request);
10+
if (!auth) {
11+
return NextResponse.json(
12+
{ success: false, error: "Authentication required" },
13+
{ status: 401 }
14+
);
15+
}
16+
17+
const body = (await request.json()) as { sessionId?: string };
18+
const sessionId = body.sessionId?.trim();
19+
if (!sessionId) {
20+
return NextResponse.json(
21+
{ success: false, error: "sessionId is required" },
22+
{ status: 400 }
23+
);
24+
}
25+
26+
await stripeBillingService.confirmCheckoutSessionAfterRedirect(
27+
sessionId,
28+
auth.id
29+
);
30+
31+
return NextResponse.json({ success: true, received: true });
32+
} catch (error) {
33+
return NextResponse.json(
34+
{
35+
success: false,
36+
error:
37+
error instanceof Error
38+
? error.message
39+
: "Failed to confirm checkout session",
40+
},
41+
{ status: 400 }
42+
);
43+
}
44+
}

app/api/stripe/webhook/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export async function POST(request: NextRequest) {
3434
try {
3535
switch (event.type) {
3636
case "checkout.session.completed":
37+
case "checkout.session.async_payment_succeeded":
3738
await stripeBillingService.syncFromCheckoutCompleted(
3839
event.data.object as Stripe.Checkout.Session
3940
);

components/PricingPage.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,6 @@ export function PricingPage({
185185
Complete checkout to activate.
186186
</p>
187187
)}
188-
189-
{/* Footer note */}
190-
<p className="mt-10 text-center text-xs text-gray-400 dark:text-gray-500">
191-
All paid plans include a 7-day money-back guarantee. Cancel anytime.
192-
</p>
193188
</section>
194189
);
195190
}

components/__tests__/PricingPage.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,11 @@ describe("PricingPage", () => {
179179
).toBeInTheDocument();
180180
});
181181

182-
it("should display the money-back guarantee footer note", () => {
182+
it("should not display a money-back guarantee footer note", () => {
183183
renderPricingPage();
184184
expect(
185-
screen.getByText(/All paid plans include a 7-day money-back guarantee/)
186-
).toBeInTheDocument();
185+
screen.queryByText(/All paid plans include a 7-day money-back guarantee/)
186+
).not.toBeInTheDocument();
187187
});
188188
});
189189

services/stripe-billing.service.ts

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ function normalizeSubscriptionStatus(value: string): StoredSubscriptionStatus {
3838
: "canceled";
3939
}
4040

41+
/** Stripe often returns an id string; expanded responses use `{ id }`. */
42+
function stripeObjectId(
43+
ref: string | { id: string } | null | undefined
44+
): string {
45+
if (ref == null) return "";
46+
if (typeof ref === "string") return ref;
47+
if (typeof ref === "object" && typeof ref.id === "string") return ref.id;
48+
return "";
49+
}
50+
4151
function requireStripeClient(): Stripe {
4252
const key = getStripeSecretKey();
4353
if (!key) {
@@ -97,7 +107,7 @@ export class StripeBillingService {
97107
targetTier: args.tier,
98108
},
99109
},
100-
success_url: `${args.baseUrl}/pricing?success=1`,
110+
success_url: `${args.baseUrl}/pricing?success=1&session_id={CHECKOUT_SESSION_ID}`,
101111
cancel_url: `${args.baseUrl}/pricing?canceled=1`,
102112
customer_email: args.email,
103113
metadata: {
@@ -151,13 +161,43 @@ export class StripeBillingService {
151161
return stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
152162
}
153163

164+
/**
165+
* Syncs subscription after the user returns from Checkout with `?session_id=…`.
166+
* Ensures Appwrite updates when webhooks are not delivered (e.g. local dev
167+
* without `stripe listen`, or a delayed webhook).
168+
*/
169+
async confirmCheckoutSessionAfterRedirect(
170+
sessionId: string,
171+
appUserId: string
172+
): Promise<void> {
173+
const stripe = requireStripeClient();
174+
const session = await stripe.checkout.sessions.retrieve(sessionId, {
175+
expand: ["subscription", "customer"],
176+
});
177+
const metaUserId = session.metadata?.appUserId?.trim();
178+
if (!metaUserId || metaUserId !== appUserId) {
179+
throw new Error("This checkout session does not belong to your account.");
180+
}
181+
if (session.mode !== "subscription") {
182+
throw new Error("Invalid checkout session.");
183+
}
184+
if (session.status !== "complete") {
185+
throw new Error("Checkout is not complete yet.");
186+
}
187+
if (
188+
session.payment_status !== "paid" &&
189+
session.payment_status !== "no_payment_required"
190+
) {
191+
throw new Error(`Payment not complete (${session.payment_status}).`);
192+
}
193+
await this.syncFromCheckoutCompleted(session);
194+
}
195+
154196
async syncFromCheckoutCompleted(session: Stripe.Checkout.Session) {
155197
const stripe = requireStripeClient();
156198
const userId = session.metadata?.appUserId;
157-
const subscriptionId =
158-
typeof session.subscription === "string" ? session.subscription : "";
159-
const customerId =
160-
typeof session.customer === "string" ? session.customer : "";
199+
const subscriptionId = stripeObjectId(session.subscription);
200+
const customerId = stripeObjectId(session.customer);
161201
const priceId = session.metadata?.stripePriceId ?? "";
162202
if (!userId || !subscriptionId) return;
163203

@@ -185,8 +225,7 @@ export class StripeBillingService {
185225
}
186226

187227
async syncFromSubscriptionUpdated(subscription: Stripe.Subscription) {
188-
const customerId =
189-
typeof subscription.customer === "string" ? subscription.customer : "";
228+
const customerId = stripeObjectId(subscription.customer);
190229
const priceId = subscription.items.data[0]?.price?.id ?? "";
191230
const userId = await this.resolveUserIdFromSubscription(subscription);
192231
if (!userId) return;
@@ -208,8 +247,7 @@ export class StripeBillingService {
208247
const userId = await this.resolveUserIdFromSubscription(subscription);
209248
if (!userId) return;
210249
const priceId = subscription.items.data[0]?.price?.id ?? "";
211-
const customerId =
212-
typeof subscription.customer === "string" ? subscription.customer : "";
250+
const customerId = stripeObjectId(subscription.customer);
213251
const bounds = getPeriodBounds(subscription);
214252
await this.writeSubscriptionRecord({
215253
userId,
@@ -234,10 +272,7 @@ export class StripeBillingService {
234272
const metadataUserId = subscription.metadata?.appUserId?.trim();
235273
if (metadataUserId) return metadataUserId;
236274

237-
const customerId =
238-
typeof subscription.customer === "string"
239-
? subscription.customer
240-
: subscription.customer?.id;
275+
const customerId = stripeObjectId(subscription.customer);
241276
if (customerId) {
242277
const byCustomer =
243278
await subscriptionStoreService.getByStripeCustomerId(customerId);

0 commit comments

Comments
 (0)