add subscription billing flow and Stripe integration components
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled

This commit is contained in:
Alaguraj0361 2026-10-17 19:58:12 +05:30
parent df5f593369
commit 5d5484c0b0
5 changed files with 646 additions and 63 deletions

View File

@ -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<BillingInterval>(
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 (
<div className="relative overflow-hidden rounded-3xl border border-slate-200/90 bg-white p-6 shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-3.5">
<div className="size-11 rounded-2xl bg-slate-100 flex items-center justify-center text-slate-700">
<Zap className="size-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-slate-900 text-base">Free Plan Active</span>
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-slate-100 text-slate-600 uppercase tracking-wider">
Basic Tier
</span>
</div>
<p className="text-xs text-slate-500 mt-0.5">
1 Managed Site • 100 Pages per crawl • Basic SEO metrics
</p>
</div>
</div>
<button
type="button"
onClick={onChangePlanClick}
className="h-10 px-5 rounded-xl bg-gradient-to-r from-[#3779b9] to-[#2563eb] text-white text-xs font-bold shadow-sm hover:opacity-95 transition-all flex items-center gap-2 self-start sm:self-auto cursor-pointer"
>
<span>Upgrade Plan</span>
<ArrowDown className="size-3.5" />
</button>
</div>
</div>
);
}
return (
<>
<div className="relative overflow-hidden rounded-[28px] border border-[#3779b9]/30 bg-gradient-to-br from-[#0f1d34] via-[#13233f] to-[#1a335a] text-white shadow-xl shadow-[#0f1d34]/15 p-6 sm:p-8 transition-all">
{/* Subtle decorative background glow */}
<div className="absolute top-0 right-0 -mr-16 -mt-16 size-72 rounded-full bg-[#3779b9]/20 blur-3xl pointer-events-none" />
<div className="absolute bottom-0 left-1/3 -mb-16 size-60 rounded-full bg-blue-500/10 blur-2xl pointer-events-none" />
<div className="relative z-10 flex flex-col lg:flex-row lg:items-center justify-between gap-6">
{/* Left: Plan Info & Time Remaining Highlight */}
<div className="space-y-4 max-w-2xl">
{/* Top Badges */}
<div className="flex flex-wrap items-center gap-2.5">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold tracking-wider uppercase bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
<span className="size-2 rounded-full bg-emerald-400 animate-pulse" />
{isTrial ? "7-Day Free Trial" : "Active Subscription"}
</span>
<span className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-semibold bg-white/10 text-slate-200 border border-white/10 backdrop-blur-sm">
<ShieldCheck className="size-3.5 text-cyan-300" />
CrawlerX {currentPlan.name} Tier
</span>
<span className="text-xs text-slate-300 font-medium">
• Billed {billing?.interval === "annual" ? "Annually" : "Monthly"}
</span>
</div>
{/* Title & Limits */}
<div>
<h2 className="text-xl sm:text-2xl font-extrabold text-white tracking-tight flex items-center gap-2.5">
<span>{currentPlan.name} Plan</span>
<span className="text-sm font-normal text-slate-300">
({currentPlan.sitesLimit})
</span>
</h2>
<p className="text-xs sm:text-sm text-slate-300 mt-1">
Includes {currentPlan.limits.pagesPerAudit} Pages/audit, {currentPlan.limits.trackedKeywords} Tracked Keywords, and AI Fix Engine.
</p>
</div>
{/* Countdown Box: Days & Hours Remaining */}
<div className="flex flex-wrap items-center gap-3 pt-1">
<div className="flex items-center gap-2 bg-white/10 border border-white/15 px-3.5 py-2 rounded-2xl backdrop-blur-md">
<Clock className="size-4 text-cyan-300 shrink-0" />
<div className="flex items-baseline gap-1.5">
<span className="text-lg sm:text-xl font-black text-white font-mono">
{days}
</span>
<span className="text-xs font-medium text-slate-300">
{days === 1 ? "day" : "days"}
</span>
<span className="text-lg sm:text-xl font-black text-white font-mono ml-1">
{hours}
</span>
<span className="text-xs font-medium text-slate-300">
{hours === 1 ? "hour" : "hours"}
</span>
<span className="text-xs text-slate-300 ml-1">remaining</span>
</div>
</div>
{billing?.formattedExpiry ? (
<div className="flex items-center gap-1.5 text-xs text-slate-300 bg-white/5 border border-white/10 px-3 py-2 rounded-2xl">
<Calendar className="size-3.5 text-slate-400 shrink-0" />
<span>
{isTrial ? "Trial ends" : "Next renewal"}:{" "}
<strong className="text-white font-semibold">
{billing.formattedExpiry}
</strong>
</span>
</div>
) : null}
</div>
{/* Progress bar of current cycle */}
<div className="space-y-1.5 max-w-md pt-0.5">
<div className="flex justify-between text-[11px] text-slate-300 font-medium">
<span>Billing Period Progress</span>
<span className="text-cyan-300 font-bold">{days} days left</span>
</div>
<div className="h-2 w-full bg-white/10 rounded-full overflow-hidden p-0.5">
<div
className="h-full rounded-full bg-gradient-to-r from-emerald-400 via-cyan-400 to-[#3779b9] transition-all duration-500"
style={{ width: `${percentRemaining}%` }}
/>
</div>
</div>
</div>
{/* Right: Actions (Renew Plan & Customer Portal) */}
<div className="flex flex-col sm:flex-row lg:flex-col gap-3 shrink-0 lg:w-64 pt-2 lg:pt-0">
{/* Primary Renew Plan Button */}
<button
type="button"
onClick={() => setShowRenewModal(true)}
disabled={isRenewing}
className="w-full h-12 px-5 rounded-2xl bg-gradient-to-r from-[#3779b9] via-[#2563eb] to-[#38bdf8] hover:from-[#2e68a3] hover:to-[#0284c7] text-white text-sm font-bold shadow-lg shadow-[#2563eb]/25 transition-all flex items-center justify-center gap-2 active:scale-[0.99] cursor-pointer disabled:opacity-60"
>
{isRenewing ? (
<>
<Loader2 className="size-4 animate-spin text-white" />
<span>Processing...</span>
</>
) : (
<>
<RefreshCw className="size-4 text-cyan-200" />
<span>Renew Plan</span>
<ChevronRight className="size-4 opacity-75" />
</>
)}
</button>
{/* Manage Subscription / Billing Portal */}
<button
type="button"
onClick={onOpenPortal}
disabled={isOpeningPortal}
className="w-full h-11 px-4 rounded-2xl bg-white/10 hover:bg-white/15 border border-white/15 text-white text-xs font-semibold transition-all flex items-center justify-center gap-2 active:scale-[0.99] cursor-pointer disabled:opacity-60 backdrop-blur-md"
>
{isOpeningPortal ? (
<>
<Loader2 className="size-3.5 animate-spin" />
<span>Opening Portal...</span>
</>
) : (
<>
<CreditCard className="size-3.5 text-slate-300" />
<span>Manage Billing & Invoices</span>
<ExternalLink className="size-3 opacity-60 ml-0.5" />
</>
)}
</button>
{/* Change Plan shortcut */}
{onChangePlanClick ? (
<button
type="button"
onClick={onChangePlanClick}
className="text-center text-xs font-semibold text-slate-300 hover:text-white transition-colors flex items-center justify-center gap-1 py-1 cursor-pointer"
>
<span>Change to another plan</span>
<ArrowDown className="size-3" />
</button>
) : null}
</div>
</div>
</div>
{/* Renew Plan Dialog / Modal */}
{showRenewModal ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="relative w-full max-w-md rounded-3xl bg-white p-6 sm:p-7 shadow-2xl border border-slate-200 text-[#0f1d34] space-y-5">
{/* Modal Header */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="size-10 rounded-2xl bg-blue-50 text-[#3779b9] flex items-center justify-center">
<RefreshCw className="size-5" />
</div>
<div>
<h3 className="text-lg font-bold text-slate-900">
Renew {currentPlan.name} Plan
</h3>
<p className="text-xs text-slate-500">
Extend your subscription cycle seamlessly
</p>
</div>
</div>
<button
type="button"
onClick={() => setShowRenewModal(false)}
className="size-8 rounded-full bg-slate-100 hover:bg-slate-200 text-slate-500 flex items-center justify-center text-sm font-bold cursor-pointer"
>
✕
</button>
</div>
{/* Current Details */}
<div className="p-4 rounded-2xl bg-slate-50 border border-slate-200/70 space-y-2 text-xs">
<div className="flex justify-between text-slate-600">
<span>Current Plan:</span>
<strong className="text-slate-900 font-semibold">{currentPlan.name} ({currentPlan.sitesLimit})</strong>
</div>
<div className="flex justify-between text-slate-600">
<span>Time Remaining:</span>
<strong className="text-emerald-700 font-semibold">{days} days, {hours} hours</strong>
</div>
<div className="flex justify-between text-slate-600">
<span>Target Renewal Date:</span>
<strong className="text-slate-900 font-semibold">{billing?.formattedExpiry ?? "At end of cycle"}</strong>
</div>
</div>
{/* Billing Interval Toggle */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-700 block">
Select Renewal Duration
</label>
<div className="grid grid-cols-2 gap-2.5">
<button
type="button"
onClick={() => setSelectedInterval("monthly")}
className={`p-3 rounded-2xl border text-left transition-all cursor-pointer ${
selectedInterval === "monthly"
? "border-[#3779b9] bg-blue-50/60 ring-2 ring-[#3779b9]/20"
: "border-slate-200 hover:border-slate-300 bg-white"
}`}
>
<div className="text-xs font-bold text-slate-900">Monthly Renewal</div>
<div className="text-sm font-extrabold text-[#3779b9] mt-0.5">
₹{currentPlan.monthlyPrice.INR.toLocaleString()}/mo
</div>
<div className="text-[10px] text-slate-500 mt-0.5">Billed every month</div>
</button>
<button
type="button"
onClick={() => setSelectedInterval("annual")}
className={`relative p-3 rounded-2xl border text-left transition-all cursor-pointer ${
selectedInterval === "annual"
? "border-[#3779b9] bg-blue-50/60 ring-2 ring-[#3779b9]/20"
: "border-slate-200 hover:border-slate-300 bg-white"
}`}
>
<span className="absolute -top-2 right-2 px-1.5 py-0.5 rounded-full text-[9px] font-extrabold bg-emerald-600 text-white">
SAVE 17%
</span>
<div className="text-xs font-bold text-slate-900">Annual Renewal</div>
<div className="text-sm font-extrabold text-[#3779b9] mt-0.5">
₹{currentPlan.annualMonthlyPrice.INR.toLocaleString()}/mo
</div>
<div className="text-[10px] text-slate-500 mt-0.5">Billed annually</div>
</button>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={() => setShowRenewModal(false)}
className="w-1/3 h-11 rounded-xl border border-slate-200 hover:bg-slate-100 text-slate-700 text-xs font-bold cursor-pointer transition-all"
>
Cancel
</button>
<button
type="button"
disabled={isRenewing}
onClick={() => {
setShowRenewModal(false);
onRenewPlan(currentPlan, selectedInterval);
}}
className="w-2/3 h-11 rounded-xl bg-gradient-to-r from-[#3779b9] to-[#2563eb] hover:from-[#2e68a3] hover:to-[#1d4ed8] text-white text-xs font-bold shadow-md shadow-[#3779b9]/25 flex items-center justify-center gap-1.5 cursor-pointer transition-all disabled:opacity-60"
>
{isRenewing ? (
<Loader2 className="size-4 animate-spin text-white" />
) : (
<>
<Sparkles className="size-3.5 text-cyan-200" />
<span>Proceed to Renew</span>
</>
)}
</button>
</div>
</div>
</div>
) : null}
</>
);
}

View File

@ -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 (
<div className="dropdown dropdown-end">
<button
type="button"
tabIndex={0}
className="btn btn-ghost btn-circle btn-sm"
aria-label="Open account menu"
>
<User className="h-4 w-4" />
</button>
<ul
tabIndex={0}
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-2xl border border-slate-200 bg-white p-2 shadow-xl"
>
<li className="menu-title max-w-full">
<span className="truncate text-slate-900 text-xs" data-ph-mask>
{email}
</span>
</li>
<li>
<Link to="/settings" className="flex items-center gap-2 text-xs">
<Settings className="h-3.5 w-3.5" />
Settings
</Link>
</li>
<ThemePreferenceMenuItems />
<li>
<button
type="button"
className="text-rose-600 text-xs"
onClick={() => void signOutAndRedirect()}
>
Sign out
</button>
</li>
</ul>
</div>
);
}

View File

@ -1,10 +1,9 @@
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router"; import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { useCustomer } from "autumn-js/react"; import { useCustomer } from "autumn-js/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ArrowLeft, ChevronDown, Globe, Settings, User } from "lucide-react"; import { ArrowLeft, ChevronDown, Globe } from "lucide-react";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { captureClientEvent } from "@/client/lib/posthog"; 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 { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getSubscribeRouteState } from "@/client/features/billing/route-state"; import { getSubscribeRouteState } from "@/client/features/billing/route-state";
@ -20,6 +19,7 @@ import type {
} from "@/client/features/billing/PricingData"; } from "@/client/features/billing/PricingData";
import { import {
createStripeCheckoutSession, createStripeCheckoutSession,
createStripePortalSession,
getOrganizationBilling, getOrganizationBilling,
verifyStripeCheckoutSession, verifyStripeCheckoutSession,
} from "@/serverFunctions/stripe"; } from "@/serverFunctions/stripe";
@ -30,6 +30,8 @@ import {
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getProjects } from "@/serverFunctions/projects"; import { getProjects } from "@/serverFunctions/projects";
import { WorkspaceUsageBanner } from "@/client/features/billing/WorkspaceUsageBanner"; 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"; import { toast } from "sonner";
const SUPPORT_EMAIL = "support@metatroncube.com"; const SUPPORT_EMAIL = "support@metatroncube.com";
@ -68,6 +70,8 @@ function SubscribePage() {
} = Route.useSearch(); } = Route.useSearch();
const { data: session } = useSession(); const { data: session } = useSession();
const [loadingPlanId, setLoadingPlanId] = useState<string | null>(null); const [loadingPlanId, setLoadingPlanId] = useState<string | null>(null);
const [isRenewing, setIsRenewing] = useState(false);
const [isOpeningPortal, setIsOpeningPortal] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [finalizingTimedOut, setFinalizingTimedOut] = useState(false); const [finalizingTimedOut, setFinalizingTimedOut] = useState(false);
const [hasVerifiedSession, setHasVerifiedSession] = 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 ( return (
<div className="relative min-h-screen bg-[#f8fafc] text-[#151515] overflow-x-hidden selection:bg-[#3779b9] selection:text-white"> <div className="relative min-h-screen bg-[#f8fafc] text-[#151515] overflow-x-hidden selection:bg-[#3779b9] selection:text-white">
{/* 3D Organic Fluid Corner Ribbons & Aura in Theme Blues */} {/* 3D Organic Fluid Corner Ribbons & Aura in Theme Blues */}
<CornerWaveDecoration /> <CornerWaveDecoration />
{/* Main Content Container */} {/* Main Content Container */}
<div className="relative z-10 max-w-[1680px] w-full mx-auto px-4 sm:px-8 lg:px-12 py-6 sm:py-8 space-y-12"> <div className="relative z-10 max-w-[1680px] w-full mx-auto px-4 sm:px-8 lg:px-12 py-6 sm:py-8 space-y-10">
{/* Top Navbar */} {/* Top Navbar */}
<header className="flex items-center justify-between gap-4 py-2"> <header className="flex items-center justify-between gap-4 py-2">
{/* Exact CrawlerX Cube Logo & Subtitle */} {/* Exact CrawlerX Cube Logo & Subtitle */}
@ -296,10 +335,23 @@ function SubscribePage() {
<span>Dashboard</span> <span>Dashboard</span>
</Link> </Link>
<SubscribePageAccountMenu email={session?.user?.email} /> <SubscribeAccountMenu email={session?.user?.email} />
</div> </div>
</header> </header>
{/* Active Plan Status & Expiry Details */}
<ActivePlanStatusBanner
billing={billingQuery.data}
onRenewPlan={handleRenewPlan}
onOpenPortal={handleOpenPortal}
isRenewing={isRenewing}
isOpeningPortal={isOpeningPortal}
onChangePlanClick={() => {
const el = document.getElementById("pricing-plans");
el?.scrollIntoView({ behavior: "smooth" });
}}
/>
{/* Workspace Usage Notice */} {/* Workspace Usage Notice */}
<WorkspaceUsageBanner <WorkspaceUsageBanner
currentPlanId={activePlanId} currentPlanId={activePlanId}
@ -333,46 +385,3 @@ function SubscribePage() {
</div> </div>
); );
} }
function SubscribePageAccountMenu({ email }: { email: string | undefined }) {
if (!email) return null;
return (
<div className="dropdown dropdown-end">
<button
type="button"
tabIndex={0}
className="btn btn-ghost btn-circle btn-sm"
aria-label="Open account menu"
>
<User className="h-4 w-4" />
</button>
<ul
tabIndex={0}
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-2xl border border-slate-200 bg-white p-2 shadow-xl"
>
<li className="menu-title max-w-full">
<span className="truncate text-slate-900 text-xs" data-ph-mask>
{email}
</span>
</li>
<li>
<Link to="/settings" className="flex items-center gap-2 text-xs">
<Settings className="h-3.5 w-3.5" />
Settings
</Link>
</li>
<ThemePreferenceMenuItems />
<li>
<button
type="button"
className="text-rose-600 text-xs"
onClick={() => void signOutAndRedirect()}
>
Sign out
</button>
</li>
</ul>
</div>
);
}

View File

@ -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<string, unknown>;
const sub =
data && typeof data.subscription === "object" && data.subscription !== null
? (data.subscription as Record<string, unknown>)
: 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<OrganizationBillingDetails> {
const [row] = await db const [row] = await db
.select() .select()
.from(billingCustomerStatus) .from(billingCustomerStatus)
@ -42,8 +140,17 @@ export async function getOrganizationBillingStatus(organizationId: string) {
if (!row) { if (!row) {
return { return {
isPaying: false, isPaying: false,
planId: "free" as const, planId: "free",
status: "none" as const, 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") ? (row.paidPlanId as "free" | "starter" | "pro" | "agency")
: "free"; : "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 { return {
isPaying: isValidPaying, isPaying: isValidPaying,
planId: isValidPaying ? matchedPlan : ("free" as const), planId: isValidPaying ? matchedPlan : "free",
status: row.paidPlanStatus ?? "none", 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),
}; };
} }

View File

@ -181,19 +181,38 @@ export const createStripePortalSession = createServerFn({ method: "POST" })
const baseUrl = const baseUrl =
(await getOptionalEnvValue("BETTER_AUTH_URL")) ?? "http://localhost:3001"; (await getOptionalEnvValue("BETTER_AUTH_URL")) ?? "http://localhost:3001";
const billingStatus = await getOrganizationBillingStatus(context.organizationId);
let customerId = billingStatus.customerId;
if (!customerId) {
const existingCustomers = await stripe.customers.list({ const existingCustomers = await stripe.customers.list({
email: context.userEmail, email: context.userEmail,
limit: 1, limit: 1,
}); });
if (existingCustomers.data.length > 0) {
customerId = existingCustomers.data[0].id;
}
}
if (existingCustomers.data.length === 0) { if (!customerId) {
throw new AppError("NOT_FOUND", "No Stripe customer found for this account."); throw new AppError("NOT_FOUND", "No Stripe customer found for this account.");
} }
try {
const portalSession = await stripe.billingPortal.sessions.create({ const portalSession = await stripe.billingPortal.sessions.create({
customer: existingCustomers.data[0].id, customer: customerId,
return_url: `${baseUrl}/subscribe`, 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);
}
}); });