core/apps/webapp/app/routes/settings.billing.tsx
2025-10-09 11:11:17 +05:30

595 lines
20 KiB
TypeScript

import {
json,
type LoaderFunctionArgs,
type ActionFunctionArgs,
} from "@remix-run/node";
import { useLoaderData, useFetcher } from "@remix-run/react";
import { requireUser, requireWorkpace } from "~/services/session.server";
import { getUsageSummary } from "~/services/billing.server";
import {
createCheckoutSession,
createBillingPortalSession,
downgradeSubscription,
} from "~/services/stripe.server";
import { CreditCard, TrendingUp, Calendar, AlertCircle } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Progress } from "~/components/ui/progress";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { prisma } from "~/db.server";
import { isBillingEnabled } from "~/config/billing.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const workspace = await requireWorkpace(request);
// Get usage summary
const usageSummary = await getUsageSummary(workspace.id);
// Get billing history
const subscription = await prisma.subscription.findUnique({
where: { workspaceId: workspace.id },
include: {
BillingHistory: {
orderBy: { createdAt: "desc" },
take: 10,
},
},
});
const billingEnabled = isBillingEnabled();
return json({
user,
workspace,
usageSummary: usageSummary as any,
billingHistory: subscription?.BillingHistory || [],
billingEnabled,
subscription: subscription
? {
status: subscription.status,
planType: subscription.planType,
currentPeriodEnd: subscription.currentPeriodEnd,
}
: null,
});
};
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUser(request);
const workspace = await requireWorkpace(request);
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "upgrade") {
const planType = formData.get("planType") as "PRO" | "MAX";
const origin = new URL(request.url).origin;
const checkoutUrl = await createCheckoutSession({
workspaceId: workspace.id,
planType,
email: user.email,
successUrl: `${origin}/settings/billing?success=true`,
cancelUrl: `${origin}/settings/billing?canceled=true`,
});
return json({ checkoutUrl });
}
if (intent === "manage") {
const origin = new URL(request.url).origin;
const portalUrl = await createBillingPortalSession({
workspaceId: workspace.id,
returnUrl: `${origin}/settings/billing`,
});
return json({ portalUrl });
}
if (intent === "downgrade") {
const targetPlan = formData.get("planType") as "FREE" | "PRO";
// Downgrade subscription - keeps credits until period end, then switches to new plan
await downgradeSubscription({
workspaceId: workspace.id,
newPlanType: targetPlan,
});
return json({
success: true,
message: `Successfully scheduled downgrade to ${targetPlan}. Your current credits will remain available until the end of your billing period.`,
});
}
return json({ error: "Invalid intent" }, { status: 400 });
};
export default function BillingSettings() {
const { usageSummary, billingHistory, billingEnabled, subscription } =
useLoaderData<typeof loader>();
const fetcher = useFetcher<typeof action>();
const [showPlansModal, setShowPlansModal] = useState(false);
const [showDowngradeDialog, setShowDowngradeDialog] = useState(false);
const [targetDowngradePlan, setTargetDowngradePlan] = useState<
"FREE" | "PRO" | null
>(null);
// Handle upgrade action
const handleUpgrade = (planType: "PRO" | "MAX") => {
fetcher.submit({ intent: "upgrade", planType }, { method: "POST" });
};
// Handle downgrade action
const handleDowngrade = (planType: "FREE" | "PRO") => {
setTargetDowngradePlan(planType);
setShowDowngradeDialog(true);
};
// Confirm and execute downgrade
const confirmDowngrade = () => {
if (targetDowngradePlan) {
fetcher.submit(
{ intent: "downgrade", planType: targetDowngradePlan },
{ method: "POST" },
);
setShowDowngradeDialog(false);
setTargetDowngradePlan(null);
}
};
// Determine if plan is upgrade, downgrade, or current
const getPlanAction = (targetPlan: "FREE" | "PRO" | "MAX") => {
const planOrder = { FREE: 0, PRO: 1, MAX: 2 };
const currentOrder =
planOrder[usageSummary.plan.type as keyof typeof planOrder];
const targetOrder = planOrder[targetPlan];
if (currentOrder === targetOrder) return "current";
if (targetOrder > currentOrder) return "upgrade";
return "downgrade";
};
// Handle plan selection
const handlePlanSelect = (planType: "FREE" | "PRO" | "MAX") => {
const action = getPlanAction(planType);
if (action === "current") return;
if (action === "upgrade") {
handleUpgrade(planType as "PRO" | "MAX");
} else {
handleDowngrade(planType as "FREE" | "PRO");
}
};
// Show success message after downgrade
if (fetcher.data && "success" in fetcher.data && fetcher.data.success) {
// Close modal and show message
setTimeout(() => {
setShowPlansModal(false);
window.location.reload(); // Reload to show updated plan info
}, 1500);
}
// Redirect to checkout/portal when URL is received
if (
fetcher.data &&
"checkoutUrl" in fetcher.data &&
fetcher.data.checkoutUrl
) {
window.location.href = fetcher.data.checkoutUrl;
}
if (fetcher.data && "portalUrl" in fetcher.data && fetcher.data.portalUrl) {
window.location.href = fetcher.data.portalUrl;
}
if (!billingEnabled) {
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold">Billing</h1>
<p className="text-muted-foreground">
Billing is disabled in self-hosted mode. You have unlimited usage.
</p>
</div>
</div>
);
}
if (!usageSummary) {
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold">Billing</h1>
<p className="text-muted-foreground">
No billing information available.
</p>
</div>
</div>
);
}
return (
<div className="p-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold">Billing</h1>
<p className="text-muted-foreground">
Manage your subscription, usage, and billing history
</p>
</div>
{/* Usage Section */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold">Current Usage</h2>
<div className="grid gap-4 md:grid-cols-3">
{/* Credits Card */}
<Card className="p-6">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-sm">Credits</span>
<CreditCard className="text-muted-foreground h-4 w-4" />
</div>
<div className="mb-2">
<span className="text-3xl font-bold">
{usageSummary.credits.available}
</span>
<span className="text-muted-foreground">
{" "}
/ {usageSummary.credits.monthly}
</span>
</div>
<Progress
segments={[{ value: 100 - usageSummary.credits.percentageUsed }]}
className="mb-2"
color="#c15e50"
/>
<p className="text-muted-foreground text-xs">
{usageSummary.credits.percentageUsed}% used this period
</p>
</Card>
{/* Usage Breakdown */}
<Card className="p-6">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Usage Breakdown
</span>
<TrendingUp className="text-muted-foreground h-4 w-4" />
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Facts</span>
<span className="font-medium">
{usageSummary.usage.episodes}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Searches</span>
<span className="font-medium">
{usageSummary.usage.searches}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Chat</span>
<span className="font-medium">{usageSummary.usage.chat}</span>
</div>
</div>
</Card>
{/* Billing Cycle */}
<Card className="p-6">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Billing Cycle
</span>
<Calendar className="text-muted-foreground h-4 w-4" />
</div>
<div className="mb-2">
<span className="text-3xl font-bold">
{usageSummary.billingCycle.daysRemaining}
</span>
<span className="text-muted-foreground"> days left</span>
</div>
<p className="text-muted-foreground text-xs">
Resets on{" "}
{new Date(usageSummary.billingCycle.end).toLocaleDateString()}
</p>
</Card>
</div>
{/* Overage Warning */}
{usageSummary.credits.overage > 0 && (
<Card className="mt-4 border-orange-500 bg-orange-50 p-4 dark:bg-orange-950">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
<div>
<h3 className="font-semibold text-orange-900 dark:text-orange-100">
Overage Usage Detected
</h3>
<p className="text-sm text-orange-700 dark:text-orange-300">
You've used {usageSummary.credits.overage} additional credits
beyond your monthly allocation.
{usageSummary.overage.enabled &&
usageSummary.overage.pricePerCredit && (
<>
{" "}
This will cost $
{(
usageSummary.credits.overage *
usageSummary.overage.pricePerCredit
).toFixed(2)}{" "}
extra this month.
</>
)}
</p>
</div>
</div>
</Card>
)}
</div>
{/* Plan Section */}
<div className="mb-8">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Plan</h2>
<Button variant="secondary" onClick={() => setShowPlansModal(true)}>
View All Plans
</Button>
</div>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<div className="mb-2 flex items-center gap-2">
<h3 className="text-xl font-bold">{usageSummary.plan.name}</h3>
<Badge
variant={
usageSummary.plan.type === "FREE" ? "secondary" : "default"
}
className="rounded"
>
{usageSummary.plan.type}
</Badge>
</div>
<p className="text-muted-foreground text-sm">
{usageSummary.credits.monthly} credits/month
{usageSummary.overage.enabled && (
<> + ${usageSummary.overage.pricePerCredit}/credit overage</>
)}
</p>
{subscription?.status === "CANCELED" &&
subscription.planType !== "FREE" && (
<div className="mt-3 flex items-start gap-2 rounded-md bg-orange-50 p-3 dark:bg-orange-950">
<AlertCircle className="mt-0.5 h-4 w-4 text-orange-600 dark:text-orange-400" />
<p className="text-sm text-orange-700 dark:text-orange-300">
Downgrading to FREE plan on{" "}
<strong>
{new Date(
subscription.currentPeriodEnd,
).toLocaleDateString()}
</strong>
. Your current credits and plan will remain active until
then.
</p>
</div>
)}
</div>
</div>
</Card>
</div>
{/* Invoices Section */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold">Invoices</h2>
{billingHistory.length === 0 ? (
<Card className="p-6">
<p className="text-muted-foreground text-center">No invoices yet</p>
</Card>
) : (
<Card>
<div className="divide-y">
{billingHistory.map((invoice) => (
<div
key={invoice.id}
className="flex items-center justify-between p-4"
>
<div>
<p className="font-medium">
{new Date(invoice.periodStart).toLocaleDateString()} -{" "}
{new Date(invoice.periodEnd).toLocaleDateString()}
</p>
</div>
<div className="text-right">
<p className="font-bold">
${invoice.totalAmount.toFixed(2)}
</p>
<Badge
variant={
invoice.stripePaymentStatus === "paid"
? "default"
: "destructive"
}
className="rounded"
>
{invoice.stripePaymentStatus || "pending"}
</Badge>
</div>
</div>
))}
</div>
</Card>
)}
</div>
{/* Plans Modal */}
<Dialog open={showPlansModal} onOpenChange={setShowPlansModal}>
<DialogContent className="max-w-5xl p-6">
<DialogHeader>
<DialogTitle>Choose Your CORE Plan</DialogTitle>
<DialogDescription>
Unlock the power of portable memory
</DialogDescription>
</DialogHeader>
<div className="grid gap-6 md:grid-cols-3">
{/* Free Plan */}
<Card className="p-6">
<div className="mb-4">
<h3 className="text-xl font-bold">Free</h3>
<p className="text-muted-foreground text-sm">
No credit card required
</p>
</div>
<div className="mb-6">
<span className="text-4xl font-bold">$0</span>
<span className="text-muted-foreground">/month</span>
</div>
<ul className="mb-6 space-y-2 text-sm">
<li className="flex items-start gap-2">
<span>Credits: 3k/mo</span>
</li>
<li className="flex items-start gap-2">
<span>No usage based</span>
</li>
</ul>
<Button
className="w-full"
variant="outline"
disabled={
usageSummary.plan.type === "FREE" ||
fetcher.state === "submitting"
}
onClick={() => handlePlanSelect("FREE")}
>
{usageSummary.plan.type === "FREE"
? "Current Plan"
: getPlanAction("FREE") === "downgrade"
? "Downgrade to Free"
: "Try CORE for free"}
</Button>
</Card>
{/* Pro Plan */}
<Card className="border-primary p-6">
<div className="mb-4">
<h3 className="text-xl font-bold">Pro</h3>
<p className="text-muted-foreground text-sm">
For Everyday Productivity
</p>
</div>
<div className="mb-6">
<span className="text-4xl font-bold">$19</span>
<span className="text-muted-foreground">/month</span>
</div>
<ul className="mb-6 space-y-2 text-sm">
<li className="flex items-start gap-2">
<span>Credits: 15k/mo</span>
</li>
<li className="flex items-start gap-2">
<span>$0.299 /1K Additional Credits</span>
</li>
</ul>
<Button
className="w-full"
variant="secondary"
disabled={
usageSummary.plan.type === "PRO" ||
fetcher.state === "submitting"
}
onClick={() => handlePlanSelect("PRO")}
>
{usageSummary.plan.type === "PRO"
? "Current Plan"
: getPlanAction("PRO") === "upgrade"
? "Upgrade to PRO"
: "Downgrade to PRO"}
</Button>
</Card>
{/* Max Plan */}
<Card className="p-6">
<div className="mb-4">
<h3 className="text-xl font-bold">Max</h3>
<p className="text-muted-foreground text-sm">
Get the most out of CORE
</p>
</div>
<div className="mb-6">
<span className="text-4xl font-bold">$99</span>
<span className="text-muted-foreground">/month</span>
</div>
<ul className="mb-6 space-y-2 text-sm">
<li className="flex items-start gap-2">
<span>Credits: 100k/mo</span>
</li>
<li className="flex items-start gap-2">
<span>$0.249 /1K Additional Credits</span>
</li>
</ul>
<Button
className="w-full"
variant="secondary"
disabled={
usageSummary.plan.type === "MAX" ||
fetcher.state === "submitting"
}
onClick={() => handlePlanSelect("MAX")}
>
{usageSummary.plan.type === "MAX"
? "Current Plan"
: "Upgrade to MAX"}
</Button>
</Card>
</div>
</DialogContent>
</Dialog>
{/* Downgrade Confirmation Dialog */}
<AlertDialog
open={showDowngradeDialog}
onOpenChange={setShowDowngradeDialog}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Downgrade</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to downgrade to the{" "}
<strong>{targetDowngradePlan}</strong> plan? Your current credits
will remain available until the end of your billing period, then
you'll be switched to the {targetDowngradePlan} plan.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDowngrade}>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}