From 5d5484c0b078f85495395a36c484a24660ea4349 Mon Sep 17 00:00:00 2001 From: Alaguraj0361 Date: Sat, 17 Oct 2026 19:58:12 +0530 Subject: [PATCH] add subscription billing flow and Stripe integration components --- .../billing/ActivePlanStatusBanner.tsx | 371 ++++++++++++++++++ .../features/billing/SubscribeAccountMenu.tsx | 51 +++ src/routes/_authenticated.subscribe.tsx | 105 ++--- src/server/billing/stripe-sync.ts | 143 ++++++- src/serverFunctions/stripe.ts | 39 +- 5 files changed, 646 insertions(+), 63 deletions(-) create mode 100644 src/client/features/billing/ActivePlanStatusBanner.tsx create mode 100644 src/client/features/billing/SubscribeAccountMenu.tsx diff --git a/src/client/features/billing/ActivePlanStatusBanner.tsx b/src/client/features/billing/ActivePlanStatusBanner.tsx new file mode 100644 index 0000000..4de7057 --- /dev/null +++ b/src/client/features/billing/ActivePlanStatusBanner.tsx @@ -0,0 +1,371 @@ +import * as React from "react"; +import { + Clock, + Calendar, + RefreshCw, + CreditCard, + ExternalLink, + Sparkles, + ShieldCheck, + ChevronRight, + ArrowDown, + Loader2, + Zap, +} from "lucide-react"; +import { CRAWLERX_PLANS, type PricingPlan, type BillingInterval } from "./PricingData"; + +export interface OrganizationBillingData { + isPaying: boolean; + planId: "free" | "starter" | "pro" | "agency"; + status: "active" | "trialing" | "canceled" | "past_due" | "incomplete" | "none"; + currentPeriodEnd: number | null; + daysRemaining: number | null; + hoursRemaining: number | null; + formattedExpiry: string | null; + isTrial: boolean; + cancelAtPeriodEnd: boolean; + interval: "monthly" | "annual"; + customerId: string | null; + hasCustomer: boolean; +} + +interface ActivePlanStatusBannerProps { + billing?: OrganizationBillingData; + onRenewPlan: (plan: PricingPlan, interval: BillingInterval) => void; + onOpenPortal: () => void; + isRenewing?: boolean; + isOpeningPortal?: boolean; + onChangePlanClick?: () => void; +} + +export function ActivePlanStatusBanner({ + billing, + onRenewPlan, + onOpenPortal, + isRenewing = false, + isOpeningPortal = false, + onChangePlanClick, +}: ActivePlanStatusBannerProps) { + const [showRenewModal, setShowRenewModal] = React.useState(false); + const [selectedInterval, setSelectedInterval] = React.useState( + billing?.interval ?? "monthly", + ); + + const planId = billing?.planId ?? "free"; + const currentPlan = + CRAWLERX_PLANS.find((p) => p.id === planId) ?? CRAWLERX_PLANS[0]; + + const isPaid = billing?.isPaying && planId !== "free"; + const days = billing?.daysRemaining ?? 0; + const hours = billing?.hoursRemaining ?? 0; + const isTrial = billing?.isTrial ?? false; + + // Approximate percentage of billing cycle remaining + const totalCycleDays = isTrial ? 7 : billing?.interval === "annual" ? 365 : 30; + const percentRemaining = Math.max( + 5, + Math.min(100, Math.round(((days * 24 + hours) / (totalCycleDays * 24)) * 100)), + ); + + if (!isPaid) { + return ( +
+
+
+
+ +
+
+
+ Free Plan Active + + Basic Tier + +
+

+ 1 Managed Site • 100 Pages per crawl • Basic SEO metrics +

+
+
+ +
+
+ ); + } + + return ( + <> +
+ {/* Subtle decorative background glow */} +
+
+ +
+ {/* Left: Plan Info & Time Remaining Highlight */} +
+ {/* Top Badges */} +
+ + + {isTrial ? "7-Day Free Trial" : "Active Subscription"} + + + + + CrawlerX {currentPlan.name} Tier + + + + • Billed {billing?.interval === "annual" ? "Annually" : "Monthly"} + +
+ + {/* Title & Limits */} +
+

+ {currentPlan.name} Plan + + ({currentPlan.sitesLimit}) + +

+

+ Includes {currentPlan.limits.pagesPerAudit} Pages/audit, {currentPlan.limits.trackedKeywords} Tracked Keywords, and AI Fix Engine. +

+
+ + {/* Countdown Box: Days & Hours Remaining */} +
+
+ +
+ + {days} + + + {days === 1 ? "day" : "days"} + + + {hours} + + + {hours === 1 ? "hour" : "hours"} + + remaining +
+
+ + {billing?.formattedExpiry ? ( +
+ + + {isTrial ? "Trial ends" : "Next renewal"}:{" "} + + {billing.formattedExpiry} + + +
+ ) : null} +
+ + {/* Progress bar of current cycle */} +
+
+ Billing Period Progress + {days} days left +
+
+
+
+
+
+ + {/* Right: Actions (Renew Plan & Customer Portal) */} +
+ {/* Primary Renew Plan Button */} + + + {/* Manage Subscription / Billing Portal */} + + + {/* Change Plan shortcut */} + {onChangePlanClick ? ( + + ) : null} +
+
+
+ + {/* Renew Plan Dialog / Modal */} + {showRenewModal ? ( +
+
+ {/* Modal Header */} +
+
+
+ +
+
+

+ Renew {currentPlan.name} Plan +

+

+ Extend your subscription cycle seamlessly +

+
+
+ +
+ + {/* Current Details */} +
+
+ Current Plan: + {currentPlan.name} ({currentPlan.sitesLimit}) +
+
+ Time Remaining: + {days} days, {hours} hours +
+
+ Target Renewal Date: + {billing?.formattedExpiry ?? "At end of cycle"} +
+
+ + {/* Billing Interval Toggle */} +
+ +
+ + + +
+
+ + {/* Action Buttons */} +
+ + +
+
+
+ ) : null} + + ); +} diff --git a/src/client/features/billing/SubscribeAccountMenu.tsx b/src/client/features/billing/SubscribeAccountMenu.tsx new file mode 100644 index 0000000..4e34cf6 --- /dev/null +++ b/src/client/features/billing/SubscribeAccountMenu.tsx @@ -0,0 +1,51 @@ +import { Link } from "@tanstack/react-router"; +import { Settings, User } from "lucide-react"; +import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems"; +import { signOutAndRedirect } from "@/lib/auth-client"; + +interface SubscribeAccountMenuProps { + email: string | undefined; +} + +export function SubscribeAccountMenu({ email }: SubscribeAccountMenuProps) { + if (!email) return null; + + return ( +
+ +
    +
  • + + {email} + +
  • +
  • + + + Settings + +
  • + +
  • + +
  • +
+
+ ); +} diff --git a/src/routes/_authenticated.subscribe.tsx b/src/routes/_authenticated.subscribe.tsx index 5bbf9ce..eac2a04 100644 --- a/src/routes/_authenticated.subscribe.tsx +++ b/src/routes/_authenticated.subscribe.tsx @@ -1,10 +1,9 @@ import { Link, createFileRoute, useNavigate } from "@tanstack/react-router"; import { useCustomer } from "autumn-js/react"; import { useEffect, useState } from "react"; -import { ArrowLeft, ChevronDown, Globe, Settings, User } from "lucide-react"; -import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems"; +import { ArrowLeft, ChevronDown, Globe } from "lucide-react"; import { captureClientEvent } from "@/client/lib/posthog"; -import { signOutAndRedirect, useSession } from "@/lib/auth-client"; +import { useSession } from "@/lib/auth-client"; import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getSubscribeRouteState } from "@/client/features/billing/route-state"; @@ -20,6 +19,7 @@ import type { } from "@/client/features/billing/PricingData"; import { createStripeCheckoutSession, + createStripePortalSession, getOrganizationBilling, verifyStripeCheckoutSession, } from "@/serverFunctions/stripe"; @@ -30,6 +30,8 @@ import { import { useQuery, useQueryClient } from "@tanstack/react-query"; import { getProjects } from "@/serverFunctions/projects"; import { WorkspaceUsageBanner } from "@/client/features/billing/WorkspaceUsageBanner"; +import { ActivePlanStatusBanner } from "@/client/features/billing/ActivePlanStatusBanner"; +import { SubscribeAccountMenu } from "@/client/features/billing/SubscribeAccountMenu"; import { toast } from "sonner"; const SUPPORT_EMAIL = "support@metatroncube.com"; @@ -68,6 +70,8 @@ function SubscribePage() { } = Route.useSearch(); const { data: session } = useSession(); const [loadingPlanId, setLoadingPlanId] = useState(null); + const [isRenewing, setIsRenewing] = useState(false); + const [isOpeningPortal, setIsOpeningPortal] = useState(false); const [error, setError] = useState(null); const [finalizingTimedOut, setFinalizingTimedOut] = useState(false); const [hasVerifiedSession, setHasVerifiedSession] = useState(false); @@ -247,13 +251,48 @@ function SubscribePage() { } } + async function handleRenewPlan( + plan: PricingPlan, + interval: BillingInterval = "monthly", + ) { + setIsRenewing(true); + try { + await handleSelectPlan(plan, interval, "INR"); + } finally { + setIsRenewing(false); + } + } + + async function handleOpenPortal() { + if (!canManageBilling) { + toast.error("Only the organization owner can manage billing."); + return; + } + + setIsOpeningPortal(true); + try { + const res = await createStripePortalSession(); + if (res?.portalUrl) { + window.location.assign(res.portalUrl); + return; + } + throw new Error("Unable to retrieve Stripe billing portal URL."); + } catch (err: unknown) { + const errObj = err as { message?: string; data?: { message?: string } } | undefined; + const msg = errObj?.data?.message || errObj?.message || "Failed to open billing portal."; + toast.error(msg); + } finally { + setIsOpeningPortal(false); + } + } + return (
{/* 3D Organic Fluid Corner Ribbons & Aura in Theme Blues */} {/* Main Content Container */} -
+
{/* Top Navbar */}
{/* Exact CrawlerX Cube Logo & Subtitle */} @@ -296,10 +335,23 @@ function SubscribePage() { Dashboard - +
+ {/* Active Plan Status & Expiry Details */} + { + const el = document.getElementById("pricing-plans"); + el?.scrollIntoView({ behavior: "smooth" }); + }} + /> + {/* Workspace Usage Notice */} ); } - -function SubscribePageAccountMenu({ email }: { email: string | undefined }) { - if (!email) return null; - - return ( -
- -
    -
  • - - {email} - -
  • -
  • - - - Settings - -
  • - -
  • - -
  • -
-
- ); -} diff --git a/src/server/billing/stripe-sync.ts b/src/server/billing/stripe-sync.ts index b377d01..e82b91b 100644 --- a/src/server/billing/stripe-sync.ts +++ b/src/server/billing/stripe-sync.ts @@ -32,7 +32,105 @@ export async function syncStripeSubscriptionStatus(args: { }); } -export async function getOrganizationBillingStatus(organizationId: string) { +export interface OrganizationBillingDetails { + isPaying: boolean; + planId: "free" | "starter" | "pro" | "agency"; + status: "active" | "trialing" | "canceled" | "past_due" | "incomplete" | "none"; + currentPeriodEnd: number | null; + daysRemaining: number | null; + hoursRemaining: number | null; + formattedExpiry: string | null; + isTrial: boolean; + cancelAtPeriodEnd: boolean; + interval: "monthly" | "annual"; + customerId: string | null; + hasCustomer: boolean; +} + +interface ParsedCustomerData { + periodEndMs: number | null; + isTrial: boolean; + cancelAtPeriodEnd: boolean; + customerId: string | null; + interval: "monthly" | "annual"; +} + +function parseStripeCustomerJson(jsonStr: string, isTrialing: boolean): ParsedCustomerData { + let periodEndMs: number | null = null; + let isTrial = isTrialing; + let cancelAtPeriodEnd = false; + let customerId: string | null = null; + let interval: "monthly" | "annual" = "monthly"; + + try { + const data = JSON.parse(jsonStr) as Record; + const sub = + data && typeof data.subscription === "object" && data.subscription !== null + ? (data.subscription as Record) + : data; + + const rawCustomer = data.customer ?? sub?.customer; + if (typeof rawCustomer === "string") { + customerId = rawCustomer; + } else if (rawCustomer && typeof rawCustomer === "object" && "id" in rawCustomer) { + customerId = String((rawCustomer as { id: unknown }).id); + } + + if (sub) { + if (sub.status === "trialing" || Boolean(sub.trial_end)) { + isTrial = true; + } + cancelAtPeriodEnd = Boolean(sub.cancel_at_period_end); + + const items = sub.items as + | { data?: Array<{ current_period_end?: number; plan?: { interval?: string } }> } + | undefined; + const itemPeriodEnd = items?.data?.[0]?.current_period_end; + const subPeriodEnd = (sub.current_period_end ?? sub.trial_end) as number | undefined; + const rawSeconds = itemPeriodEnd ?? subPeriodEnd; + if (typeof rawSeconds === "number" && rawSeconds > 0) { + periodEndMs = rawSeconds * 1000; + } + + if (items?.data?.[0]?.plan?.interval === "year") { + interval = "annual"; + } + } else if (typeof data.expires_at === "number") { + periodEndMs = data.expires_at * 1000; + } + } catch { + // Ignore JSON parse errors + } + + return { periodEndMs, isTrial, cancelAtPeriodEnd, customerId, interval }; +} + +function calculateTimeRemaining(periodEndMs: number | null) { + if (!periodEndMs) { + return { daysRemaining: null, hoursRemaining: null, formattedExpiry: null }; + } + const diffMs = periodEndMs - Date.now(); + const totalHours = Math.max(0, Math.floor(diffMs / (1000 * 60 * 60))); + const daysRemaining = Math.floor(totalHours / 24); + const hoursRemaining = totalHours % 24; + + let formattedExpiry: string; + try { + formattedExpiry = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(periodEndMs)); + } catch { + formattedExpiry = new Date(periodEndMs).toDateString(); + } + + return { daysRemaining, hoursRemaining, formattedExpiry }; +} + +export async function getOrganizationBillingStatus( + organizationId: string, +): Promise { const [row] = await db .select() .from(billingCustomerStatus) @@ -42,8 +140,17 @@ export async function getOrganizationBillingStatus(organizationId: string) { if (!row) { return { isPaying: false, - planId: "free" as const, - status: "none" as const, + planId: "free", + status: "none", + currentPeriodEnd: null, + daysRemaining: null, + hoursRemaining: null, + formattedExpiry: null, + isTrial: false, + cancelAtPeriodEnd: false, + interval: "monthly", + customerId: null, + hasCustomer: false, }; } @@ -58,9 +165,35 @@ export async function getOrganizationBillingStatus(organizationId: string) { ? (row.paidPlanId as "free" | "starter" | "pro" | "agency") : "free"; + const parsed = parseStripeCustomerJson( + row.customerJson, + row.paidPlanStatus === "trialing", + ); + + let finalPeriodEnd = parsed.periodEndMs; + if (isValidPaying && !finalPeriodEnd && row.syncedAt) { + const syncTime = new Date(row.syncedAt).getTime(); + if (!Number.isNaN(syncTime)) { + const days = parsed.isTrial ? 7 : (parsed.interval === "annual" ? 365 : 30); + finalPeriodEnd = syncTime + days * 24 * 60 * 60 * 1000; + } + } + + const { daysRemaining, hoursRemaining, formattedExpiry } = + calculateTimeRemaining(finalPeriodEnd); + return { isPaying: isValidPaying, - planId: isValidPaying ? matchedPlan : ("free" as const), - status: row.paidPlanStatus ?? "none", + planId: isValidPaying ? matchedPlan : "free", + status: (row.paidPlanStatus as OrganizationBillingDetails["status"]) ?? "none", + currentPeriodEnd: finalPeriodEnd, + daysRemaining, + hoursRemaining, + formattedExpiry, + isTrial: parsed.isTrial, + cancelAtPeriodEnd: parsed.cancelAtPeriodEnd, + interval: parsed.interval, + customerId: parsed.customerId, + hasCustomer: Boolean(parsed.customerId), }; } diff --git a/src/serverFunctions/stripe.ts b/src/serverFunctions/stripe.ts index eff88f7..7279a2e 100644 --- a/src/serverFunctions/stripe.ts +++ b/src/serverFunctions/stripe.ts @@ -181,19 +181,38 @@ export const createStripePortalSession = createServerFn({ method: "POST" }) const baseUrl = (await getOptionalEnvValue("BETTER_AUTH_URL")) ?? "http://localhost:3001"; - const existingCustomers = await stripe.customers.list({ - email: context.userEmail, - limit: 1, - }); + const billingStatus = await getOrganizationBillingStatus(context.organizationId); + let customerId = billingStatus.customerId; - if (existingCustomers.data.length === 0) { + if (!customerId) { + const existingCustomers = await stripe.customers.list({ + email: context.userEmail, + limit: 1, + }); + if (existingCustomers.data.length > 0) { + customerId = existingCustomers.data[0].id; + } + } + + if (!customerId) { throw new AppError("NOT_FOUND", "No Stripe customer found for this account."); } - const portalSession = await stripe.billingPortal.sessions.create({ - customer: existingCustomers.data[0].id, - return_url: `${baseUrl}/subscribe`, - }); + try { + const portalSession = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `${baseUrl}/subscribe`, + }); - return { portalUrl: portalSession.url }; + return { portalUrl: portalSession.url }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("activate the customer portal")) { + throw new AppError( + "INTERNAL_ERROR", + "Stripe Customer Portal is not active yet in your Stripe dashboard settings. Please use the direct Renew Plan button or enable Customer Portal in your Stripe settings.", + ); + } + throw new AppError("INTERNAL_ERROR", msg); + } });