From 2f39c0ca03bda776d1f4a9c9d309c78b67c1dea2 Mon Sep 17 00:00:00 2001 From: Nitheesh D R Date: Tue, 8 Sep 2026 00:10:01 +0530 Subject: [PATCH] =?UTF-8?q?PUL-24:=20add=20Lite=20plan=20(=E2=82=B91,999/m?= =?UTF-8?q?o,=20analytics=20only,=20no=20WhatsApp)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new tier below Go/Plus: every analytics module, no WhatsApp surface at all. Unlike Go/Plus (which differ only in message volume), Lite is a different kind of plan, so it's enforced end to end rather than just listed on the pricing page: - src/lib/billing/usage.ts: PlanId widened to include "lite"; planMessageLimit returns 0 for it unconditionally (no "usable Lite subscription that can still send," unlike Go's past_due grace window); new planIncludesWhatsApp / hasWhatsAppPlan for the page/route gate below. - src/proxy.ts: WHATSAPP_PAGES (campaigns, flows, abandoned-checkouts, order-confirmations, menu, inbox, whatsapp-cloud-pilot, assistant) redirect a Lite account to Settings -> Billing with an upgrade prompt; WHATSAPP_API (/api/whatsapp*, /api/whatsapp-cloud*, /api/ai/chat) returns 402 for both a session and an API key. The assistant is gated as a whole -- its value is inseparable from the WhatsApp actions it proposes. - Sidebar: WhatsApp-only items stay visible for Lite (not hidden) with a lock icon, and link straight to the upgrade prompt -- discoverable, not disabled with no explanation. - SubscriptionBanner no longer fires for Lite: its 0 message limit is by design, not an unpaid/inactive subscription, and the existing "Add a subscription" copy would have been actively wrong for an account that already has one. - Marketing: PLANS/ANALYTICS_MODULES/WHATSAPP_MODULES in lib/marketing/plans.ts drive /pricing and the landing page's three-tier cards; copy rewritten off the old "every tier, every feature" framing. - Billing plumbing widened for the third plan id: checkout route + Razorpay plan-id env var, Settings billing card, invoices/webhook routes, export/invoice.ts, openapi.ts. - Migration widens the profiles/billing_invoices plan check constraints to allow 'lite' (already applied to production). Verified end to end against a real account switched to Lite: sidebar lock icons, direct-URL redirect to the upgrade prompt, 402 on a WhatsApp API route, and the existing AI-summary widget surfacing the new error message cleanly through its own retry UI. Needs a real Razorpay Plan (RAZORPAY_PLAN_ID_LITE) before checkout can actually charge for this plan -- flagged separately before creating anything in the live Razorpay account. Co-Authored-By: Claude Sonnet 5 --- src/app/(app)/settings/page.tsx | 13 ++++ src/app/(marketing)/pricing/page.tsx | 39 +++++++--- src/app/(marketing)/terms/page.tsx | 2 +- src/app/api/billing/checkout/route.ts | 9 ++- .../api/billing/invoices/[id]/pdf/route.ts | 2 +- src/app/api/billing/invoices/route.ts | 2 +- src/app/api/billing/webhook/route.ts | 2 +- src/components/layout/app-sidebar.tsx | 25 ++++++- src/components/layout/nav-items.ts | 9 +++ src/components/layout/subscription-banner.tsx | 10 +++ src/components/marketing/landing/pricing.tsx | 6 +- src/components/providers/billing-provider.tsx | 2 +- src/components/settings/billing-card.tsx | 9 ++- src/lib/billing/usage.ts | 41 ++++++++++- src/lib/export/invoice.ts | 4 +- src/lib/marketing/plans.ts | 67 ++++++++++++++--- src/lib/openapi.ts | 6 +- src/proxy.ts | 73 ++++++++++++++++++- .../migrations/20260907120000_lite_plan.sql | 12 +++ 19 files changed, 285 insertions(+), 48 deletions(-) create mode 100644 supabase/migrations/20260907120000_lite_plan.sql diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 9d29cdf..d3fb26d 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -75,6 +75,7 @@ export default function SettingsPage() { const [maxPages, setMaxPages] = useState(300); const [savingWindow, setSavingWindow] = useState(false); const [authOutcome, setAuthOutcome] = useState(null); + const [upgradePrompt, setUpgradePrompt] = useState(null); const { stores, reload: reloadStores } = useConnectedStores(); const [busyStore, setBusyStore] = useState(null); const [section, setSection] = useState("store"); @@ -116,6 +117,7 @@ export default function SettingsPage() { const params = new URLSearchParams(window.location.search); // eslint-disable-next-line react-hooks/set-state-in-effect setAuthOutcome(params.get("auth")); + setUpgradePrompt(params.get("upgrade")); const requested = params.get("section"); if (SECTIONS.some((s) => s.id === requested)) { setSection(requested as SectionId); @@ -202,6 +204,17 @@ export default function SettingsPage() { ) : null} + {upgradePrompt === "whatsapp" ? ( + + + That page needs Go or Plus + + The Lite plan is analytics only. Upgrade below to use WhatsApp campaigns, flows, the + inbox and order confirmations. + + + ) : null} +
@@ -130,11 +131,29 @@ export default function PricingPage() {
- {INCLUDED.map((item, i) => ( + {ANALYTICS_MODULES.map((item, i) => ( + +
+ + {item} +
+
+ ))} +
+
+ +
+ +
+ {WHATSAPP_MODULES.map((item, i) => (
diff --git a/src/app/(marketing)/terms/page.tsx b/src/app/(marketing)/terms/page.tsx index 7321f82..1643874 100644 --- a/src/app/(marketing)/terms/page.tsx +++ b/src/app/(marketing)/terms/page.tsx @@ -59,7 +59,7 @@ export default function TermsPage() {

- Paid plans (Go and Plus) bill monthly via Razorpay UPI Autopay. New subscriptions + Paid plans (Lite, Go and Plus) bill monthly via Razorpay UPI Autopay. New subscriptions include a one-time 14-day free trial; your payment mandate is authorized immediately, and the first charge is deferred to the end of the trial. You can cancel from Settings → Billing at any time; cancelling stops future billing but doesn't refund the diff --git a/src/app/api/billing/checkout/route.ts b/src/app/api/billing/checkout/route.ts index ff07843..1de1e64 100644 --- a/src/app/api/billing/checkout/route.ts +++ b/src/app/api/billing/checkout/route.ts @@ -12,11 +12,12 @@ export const dynamic = "force-dynamic"; * (src/app/api/keys/route.ts), a leaked API key must not be able to change * what the account is billed, so this is not reachable with one. */ -const bodySchema = z.object({ plan: z.enum(["go", "plus"]) }); +const bodySchema = z.object({ plan: z.enum(["go", "plus", "lite"]) }); -const PLAN_ENV: Record<"go" | "plus", string | undefined> = { +const PLAN_ENV: Record<"go" | "plus" | "lite", string | undefined> = { go: process.env.RAZORPAY_PLAN_ID_GO, plus: process.env.RAZORPAY_PLAN_ID_PLUS, + lite: process.env.RAZORPAY_PLAN_ID_LITE, }; /** @@ -43,7 +44,7 @@ export async function POST(request: Request) { } const parsed = bodySchema.safeParse(body); if (!parsed.success) { - return NextResponse.json({ error: "A plan of \"go\" or \"plus\" is required." }, { status: 422 }); + return NextResponse.json({ error: "A plan of \"lite\", \"go\" or \"plus\" is required." }, { status: 422 }); } const planId = PLAN_ENV[parsed.data.plan]; @@ -58,7 +59,7 @@ export async function POST(request: Request) { { razorpay_customer_id: string | null; razorpay_subscription_id: string | null; - plan: "go" | "plus" | null; + plan: "go" | "plus" | "lite" | null; subscription_status: string; trial_used_at: Date | null; }[] diff --git a/src/app/api/billing/invoices/[id]/pdf/route.ts b/src/app/api/billing/invoices/[id]/pdf/route.ts index 63eca38..edad326 100644 --- a/src/app/api/billing/invoices/[id]/pdf/route.ts +++ b/src/app/api/billing/invoices/[id]/pdf/route.ts @@ -8,7 +8,7 @@ export const dynamic = "force-dynamic"; interface InvoiceRow { id: string; - plan: "go" | "plus"; + plan: "go" | "plus" | "lite"; amount_paise: number; currency: string; status: "paid" | "failed" | "refunded"; diff --git a/src/app/api/billing/invoices/route.ts b/src/app/api/billing/invoices/route.ts index 99f2349..46b2771 100644 --- a/src/app/api/billing/invoices/route.ts +++ b/src/app/api/billing/invoices/route.ts @@ -7,7 +7,7 @@ export const dynamic = "force-dynamic"; interface InvoiceRow { id: string; - plan: "go" | "plus"; + plan: "go" | "plus" | "lite"; amount_paise: number; currency: string; status: "paid" | "failed" | "refunded"; diff --git a/src/app/api/billing/webhook/route.ts b/src/app/api/billing/webhook/route.ts index 5399c36..801ced1 100644 --- a/src/app/api/billing/webhook/route.ts +++ b/src/app/api/billing/webhook/route.ts @@ -92,7 +92,7 @@ export async function POST(request: Request) { const payment = event.payload.payment?.entity; if (!sub || !payment) break; - const [profile] = await db()<{ id: string; plan: "go" | "plus" | null }[]>` + const [profile] = await db()<{ id: string; plan: "go" | "plus" | "lite" | null }[]>` select id, plan from profiles where razorpay_subscription_id = ${sub.id} `; if (!profile?.plan) break; diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index f1d9d67..23c9381 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -1,11 +1,12 @@ "use client"; -import { Activity, ExternalLink } from "lucide-react"; +import { Activity, ExternalLink, Lock } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { AccountMenu } from "@/components/auth/account-menu"; import { StoreSwitcher } from "@/components/layout/store-switcher"; import { useAnalytics } from "@/components/providers/analytics-provider"; +import { useBilling } from "@/components/providers/billing-provider"; import { Sidebar, SidebarContent, @@ -15,6 +16,7 @@ import { SidebarGroupLabel, SidebarHeader, SidebarMenu, + SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, SidebarRail, @@ -24,6 +26,17 @@ import { NAV_GROUPS } from "./nav-items"; export function AppSidebar() { const pathname = usePathname(); const { data } = useAnalytics(); + const { status } = useBilling(); + /* + * Lite still sees every item -- clicking one is how it finds out WhatsApp + * needs an upgrade, via src/proxy.ts's WHATSAPP_PAGES redirect to + * /settings?section=billing&upgrade=whatsapp, same destination this lock + * icon links toward. Hiding the item instead would hide the upsell along + * with it. Only true once the plan is actually known to be Lite -- while + * status is still loading (null) or on Go/Plus/legacy-unlimited, nothing + * is locked. + */ + const isLite = status !== null && !status.legacyUnlimited && status.plan === "lite"; return ( @@ -47,14 +60,20 @@ export function AppSidebar() { {group.items.map((item) => { const active = pathname === item.href || pathname.startsWith(`${item.href}/`); + const locked = isLite && item.whatsapp; return ( - - + + {item.label} + {locked ? ( + + + + ) : null} ); })} diff --git a/src/components/layout/nav-items.ts b/src/components/layout/nav-items.ts index ca52a26..01d79f7 100644 --- a/src/components/layout/nav-items.ts +++ b/src/components/layout/nav-items.ts @@ -23,6 +23,8 @@ export interface NavItem { label: string; icon: typeof LayoutDashboard; description: string; + /** Hidden for a Lite (analytics-only) plan — matches src/proxy.ts's WHATSAPP_PAGES. */ + whatsapp?: boolean; } export const NAV_GROUPS: { label: string; items: NavItem[] }[] = [ @@ -97,42 +99,49 @@ export const NAV_GROUPS: { label: string; items: NavItem[] }[] = [ label: "Assistant", icon: Sparkles, description: "Ask about the store, and have messages drafted for approval", + whatsapp: true, }, { href: "/campaigns", label: "Campaigns", icon: Megaphone, description: "Build audiences and measure campaign and coupon performance", + whatsapp: true, }, { href: "/flows", label: "Flows", icon: Workflow, description: "Multi-step campaigns that send themselves over days", + whatsapp: true, }, { href: "/abandoned-checkouts", label: "Abandoned checkouts", icon: ShoppingCart, description: "WhatsApp reminders for checkouts left pending", + whatsapp: true, }, { href: "/order-confirmations", label: "Order confirmations", icon: PackageCheck, description: "A WhatsApp thank-you, with a product photo, the moment an order comes in", + whatsapp: true, }, { href: "/menu", label: "Auto-reply", icon: MessageSquare, description: "The menu a customer gets when they message you first", + whatsapp: true, }, { href: "/inbox", label: "Inbox", icon: MessageCircle, description: "WhatsApp conversations, with the customer behind each number", + whatsapp: true, }, ], }, diff --git a/src/components/layout/subscription-banner.tsx b/src/components/layout/subscription-banner.tsx index d02d5f7..4143e30 100644 --- a/src/components/layout/subscription-banner.tsx +++ b/src/components/layout/subscription-banner.tsx @@ -25,11 +25,21 @@ import { Button } from "@/components/ui/button"; * while the real gate already refused it). `usage.limit` is null for * anything actually unlimited (legacy_unlimited, or an active/trialing * Plus), so it can never misfire for those. + * + * Lite is a third, deliberate exception: its `usage.limit` is also 0, but + * not because anything is broken or unpaid -- Lite simply doesn't include + * WhatsApp, on purpose, at any subscription status. Telling a Lite + * subscriber their subscription "isn't active" would be false, and "Add a + * subscription" would be telling someone who already has one to add another. + * WHATSAPP_PAGES in src/proxy.ts is what actually keeps a Lite account off + * the pages this would matter on; this banner has nothing useful to say on + * the analytics pages Lite does use. */ export function SubscriptionBanner() { const { status, initialising } = useBilling(); if (initialising || !status) return null; + if (status.plan === "lite") return null; const blocked = status.usage.limit === 0; if (!blocked) return null; diff --git a/src/components/marketing/landing/pricing.tsx b/src/components/marketing/landing/pricing.tsx index ed9b13c..be84d52 100644 --- a/src/components/marketing/landing/pricing.tsx +++ b/src/components/marketing/landing/pricing.tsx @@ -37,12 +37,12 @@ export function Pricing({ heading = true, footer = heading }: PricingProps = {}) ) : null} -

+
{PLANS.map((plan, i) => (
(null); - const [subscribing, setSubscribing] = useState<"go" | "plus" | null>(null); + const [subscribing, setSubscribing] = useState<"go" | "plus" | "lite" | null>(null); const [error, setError] = useState(null); const [checkoutReady, setCheckoutReady] = useState(false); @@ -79,7 +80,7 @@ export function BillingCard() { void loadInvoices(); }, [loadInvoices]); - const subscribe = async (plan: "go" | "plus") => { + const subscribe = async (plan: "go" | "plus" | "lite") => { setSubscribing(plan); setError(null); try { @@ -229,7 +230,7 @@ export function BillingCard() { {status.usage.limit ? : null}
-
+
{PLANS.map((plan) => { const isCurrent = status.plan === plan.id && diff --git a/src/lib/billing/usage.ts b/src/lib/billing/usage.ts index 7c2a67b..5894a4e 100644 --- a/src/lib/billing/usage.ts +++ b/src/lib/billing/usage.ts @@ -12,6 +12,9 @@ import { db } from "@/lib/db/client"; const GO_MONTHLY_LIMIT = 10_000; +/** "lite" is analytics only — see planIncludesWhatsApp below for the gate this drives. */ +export type PlanId = "go" | "plus" | "lite"; + export interface SendAllowance { allowed: boolean; /** null means unlimited (Plus, or a grandfathered legacy account). */ @@ -20,7 +23,7 @@ export interface SendAllowance { } export interface BillingProfile { - plan: "go" | "plus" | null; + plan: PlanId | null; subscription_status: string; grace_until: Date | null; legacy_unlimited: boolean; @@ -54,6 +57,11 @@ function currentPeriod(): string { */ export function planMessageLimit(profile: BillingProfile): number | null { if (profile.legacy_unlimited) return null; + // Lite has no WhatsApp allowance at all, regardless of subscription status + // -- there is no "usable Lite subscription that can still send," unlike Go + // past_due getting a grace window. See planIncludesWhatsApp for the same + // policy applied to page/route access rather than the send count. + if (profile.plan === "lite") return 0; const inGrace = profile.grace_until !== null && profile.grace_until.getTime() > Date.now(); // `authenticated` is Razorpay's own status for "mandate set up, first @@ -128,3 +136,34 @@ export async function recordSend(userId: string, count = 1): Promise { do update set sent_count = whatsapp_usage.sent_count + excluded.sent_count, updated_at = now() `; } + +/** + * Whether this plan includes WhatsApp at all — Go and Plus do, Lite doesn't, + * a grandfathered legacy account does regardless of its `plan` value. + * + * Deliberately independent of `subscription_status`: a `past_due` Go account + * still gets to see its Inbox and Campaigns pages during its grace window + * (planMessageLimit is what actually blocks the send once the grace period + * runs out) — Lite is different in kind, not in standing: there is no + * subscription state that makes it include WhatsApp, so this reads plan type + * alone. src/proxy.ts is the caller — see WHATSAPP_PAGES / WHATSAPP_API + * there for what this gates. + */ +export function planIncludesWhatsApp(profile: Pick): boolean { + if (profile.legacy_unlimited) return true; + return profile.plan === "go" || profile.plan === "plus"; +} + +/** + * Loads just enough of a profile to answer planIncludesWhatsApp, for a + * caller (src/proxy.ts) that only has a userId and needs a single fast + * lookup rather than the full BillingProfile most other callers assemble + * alongside a usage query. + */ +export async function hasWhatsAppPlan(userId: string): Promise { + const [profile] = await db()[]>` + select plan, legacy_unlimited from profiles where id = ${userId} + `; + if (!profile) return false; + return planIncludesWhatsApp(profile); +} diff --git a/src/lib/export/invoice.ts b/src/lib/export/invoice.ts index 759fa6e..ba0b129 100644 --- a/src/lib/export/invoice.ts +++ b/src/lib/export/invoice.ts @@ -14,7 +14,7 @@ import { BRAND, FONT, INK, MARGIN, MUTED, RULE, makeFormatter, registerFonts } f export interface Invoice { id: string; - plan: "go" | "plus"; + plan: "go" | "plus" | "lite"; amountPaise: number; currency: string; status: "paid" | "failed" | "refunded"; @@ -24,7 +24,7 @@ export interface Invoice { razorpayPaymentId: string | null; } -const PLAN_LABEL: Record = { go: "Go", plus: "Plus" }; +const PLAN_LABEL: Record = { go: "Go", plus: "Plus", lite: "Lite" }; export function buildInvoicePdf(invoice: Invoice, account: { email: string }): Buffer { const doc = new jsPDF({ unit: "pt", format: "a4" }); // portrait — a receipt, not a report diff --git a/src/lib/marketing/plans.ts b/src/lib/marketing/plans.ts index ca6336b..e0e47a8 100644 --- a/src/lib/marketing/plans.ts +++ b/src/lib/marketing/plans.ts @@ -1,3 +1,5 @@ +import type { PlanId } from "@/lib/billing/usage"; + /** * Pricing, in one place. * @@ -5,19 +7,24 @@ * means a price cannot be right on one page and stale on the other — which is * the specific failure that makes a visitor stop trusting the rest of the * numbers on the site. These are the real, billed prices — see - * src/lib/billing/usage.ts for how the message cap is enforced and - * supabase/migrations/20260828140000_billing.sql for the plan values these - * must stay in sync with (RAZORPAY_PLAN_ID_GO / RAZORPAY_PLAN_ID_PLUS). + * src/lib/billing/usage.ts for how the message cap and the WhatsApp gate + * (planIncludesWhatsApp) are enforced, and + * supabase/migrations/20260828140000_billing.sql / + * supabase/migrations/20260907120000_lite_plan.sql for the plan values these + * must stay in sync with (RAZORPAY_PLAN_ID_GO / _PLUS / _LITE). * - * Every tier includes every module — analytics, flows, the assistant, all of - * it. The only thing that differs between them is how many WhatsApp messages - * a month can go out, because that is the one thing that costs real money to - * carry (a gateway your customers' replies flow through) rather than compute - * this app already has to do regardless of who's asking. + * Go and Plus include every module — analytics, campaigns, flows, the + * assistant, all of it — and differ only in how many WhatsApp messages a + * month can go out, because that is the one thing that costs real money to + * carry (a gateway your customers' replies flow through). Lite is a + * different kind of tier, not a smaller Go: analytics only, no WhatsApp + * surface at all — see ANALYTICS_MODULES / WHATSAPP_MODULES below for the + * split, and src/proxy.ts's WHATSAPP_PAGES / WHATSAPP_API for how it's + * actually enforced, not just advertised. */ export interface Plan { - id: "go" | "plus"; + id: PlanId; name: string; price: string; cadence: string; @@ -26,9 +33,28 @@ export interface Plan { cta: string; href: string; limits: string[]; + /** Whether this tier includes WhatsApp campaigns, flows and the inbox. */ + whatsapp: boolean; } export const PLANS: Plan[] = [ + { + id: "lite", + name: "Lite", + price: "₹1,999", + cadence: "per month", + blurb: "For a store that wants the analytics, not the messaging.", + highlight: false, + cta: "Start free", + href: "/signup", + whatsapp: false, + limits: [ + "Every analytics module — RFM, cohorts, LTV, products, inventory", + "Report exports (PDF, Excel, CSV)", + "No WhatsApp campaigns, flows, inbox, order confirmations or assistant", + "UPI Autopay billing, cancel anytime", + ], + }, { id: "go", name: "Go", @@ -38,6 +64,7 @@ export const PLANS: Plan[] = [ highlight: false, cta: "Start free", href: "/signup", + whatsapp: true, limits: [ "10,000 WhatsApp messages per month", "Every feature included — analytics, campaigns, flows, the assistant", @@ -53,6 +80,7 @@ export const PLANS: Plan[] = [ highlight: true, cta: "Start free", href: "/signup", + whatsapp: true, limits: [ "Unlimited WhatsApp messages", "Every feature included — analytics, campaigns, flows, the assistant", @@ -61,8 +89,8 @@ export const PLANS: Plan[] = [ }, ]; -/** Every module, in every tier. The list exists to prove there is no catch. */ -export const INCLUDED = [ +/** In every tier, Lite included. */ +export const ANALYTICS_MODULES = [ "Revenue, orders and repeat rate", "RFM segmentation and value tiers", "Predicted lifetime value", @@ -72,8 +100,23 @@ export const INCLUDED = [ "Market-basket affinity", "Inventory cover and reorder points", "B2B account rollups", + "Report exports (PDF, Excel, CSV)", +]; + +/* + * Go and Plus only. The assistant is here, not in ANALYTICS_MODULES, even + * though it can answer plain analytics questions — its value is inseparable + * from the WhatsApp actions it proposes, and src/proxy.ts gates the whole + * page (and /api/ai/chat) rather than trying to split "answers questions" + * from "drafts a send" at the route level. + */ +export const WHATSAPP_MODULES = [ "WhatsApp campaigns", "Automated flows", + "Order confirmations & abandoned-checkout reminders", + "Shared inbox with customer history", "The assistant", - "Report exports (PDF, Excel, CSV)", ]; + +/** Every module, across every tier. Kept for the "no catch" framing where Go/Plus are shown alone. */ +export const INCLUDED = [...ANALYTICS_MODULES, ...WHATSAPP_MODULES]; diff --git a/src/lib/openapi.ts b/src/lib/openapi.ts index de20e2c..b795736 100644 --- a/src/lib/openapi.ts +++ b/src/lib/openapi.ts @@ -174,7 +174,7 @@ export const openApiDocument = { content: json({ type: "object", properties: { - plan: { type: "string", enum: ["go", "plus"], nullable: true }, + plan: { type: "string", enum: ["go", "plus", "lite"], nullable: true }, subscriptionStatus: { type: "string", enum: ["none", "created", "authenticated", "active", "past_due", "halted", "cancelled"], @@ -230,7 +230,7 @@ export const openApiDocument = { content: json({ type: "object", required: ["plan"], - properties: { plan: { type: "string", enum: ["go", "plus"] } }, + properties: { plan: { type: "string", enum: ["go", "plus", "lite"] } }, }), }, responses: { @@ -286,7 +286,7 @@ export const openApiDocument = { type: "object", properties: { id: { type: "string" }, - plan: { type: "string", enum: ["go", "plus"] }, + plan: { type: "string", enum: ["go", "plus", "lite"] }, amountPaise: { type: "integer" }, currency: { type: "string" }, status: { type: "string", enum: ["paid", "failed", "refunded"] }, diff --git a/src/proxy.ts b/src/proxy.ts index ee9429e..8142a68 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,6 +1,7 @@ import { createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; import { readPresentedKey, verifyApiKey, type Scope } from "@/lib/auth/api-key"; +import { hasWhatsAppPlan } from "@/lib/billing/usage"; import { databaseConfigured } from "@/lib/db/client"; import { USER_EMAIL_HEADER, USER_ID_HEADER } from "@/lib/auth/headers"; @@ -28,6 +29,19 @@ import { USER_EMAIL_HEADER, USER_ID_HEADER } from "@/lib/auth/headers"; * * Marketing pages, sign-in, sign-up and the API reference stay open. * + * A third layer, on top of those two: WHATSAPP_PAGES / WHATSAPP_API gate + * WhatsApp-specific pages and routes behind `plan !== "lite"` (see + * planIncludesWhatsApp in src/lib/billing/usage.ts). This is authorization, + * not authentication — a deliberate widening of what this file does, made + * here rather than at each of the dozen individual WhatsApp route handlers, + * for the same reason the identity check itself lives in one place: a route + * added later and not threaded through a per-handler check is a silent hole, + * where a route not added to a list here is at least a visible omission to + * grep for. The real backstop either way is planMessageLimit returning 0 for + * Lite (checkSendAllowance, checked at every actual send site) — this layer + * is about a Lite account never seeing or reaching the feature at all, not + * the only thing preventing a message from going out. + * * ─── Why it also touches cookies ─────────────────────────────────────────── * * Supabase access tokens are short-lived and refreshed against the refresh @@ -133,6 +147,44 @@ const PROTECTED_PAGES = [ "/cli-login", ]; +/** Pages a Lite account (analytics only) is redirected away from. */ +const WHATSAPP_PAGES = [ + "/campaigns", + "/flows", + "/abandoned-checkouts", + "/order-confirmations", + "/menu", + "/inbox", + "/whatsapp-cloud-pilot", + "/assistant", +]; + +/** + * API prefixes gated the same way, for both a session and an API key. + * /api/ai/chat is included alongside the /api/whatsapp* prefixes because + * /assistant is gated above — its own value (asking questions, drafting + * messages) is inseparable from the WhatsApp actions it proposes, so it's + * gated as a whole rather than trying to allow read-only questions through + * while blocking only the send proposals. + */ +const WHATSAPP_API_PREFIXES = ["/api/whatsapp", "/api/whatsapp-cloud", "/api/ai/chat"]; + +function matchesPrefix(pathname: string, prefixes: string[]): boolean { + return prefixes.some((p) => pathname === p || pathname.startsWith(`${p}/`)); +} + +/** Null when the request may proceed; a ready-to-return response otherwise. */ +async function whatsappPlanGate(userId: string): Promise { + if (await hasWhatsAppPlan(userId)) return null; + return NextResponse.json( + { + error: "This plan does not include WhatsApp.", + hint: "Upgrade to Go or Plus in Settings → Billing to use WhatsApp features.", + }, + { status: 402 }, + ); +} + export async function proxy(request: NextRequest) { // Carries any refreshed auth cookies, whatever the outcome below. const response = NextResponse.next({ request }); @@ -175,6 +227,14 @@ export async function proxy(request: NextRequest) { const isProtected = PROTECTED_PAGES.some( (p) => pathname === p || pathname.startsWith(`${p}/`), ); + + if (isProtected && user && databaseConfigured() && matchesPrefix(pathname, WHATSAPP_PAGES)) { + const included = await hasWhatsAppPlan(user.id); + if (!included) { + return NextResponse.redirect(new URL("/settings?section=billing&upgrade=whatsapp", request.url)); + } + } + if (!isProtected || user) return response; /* @@ -214,7 +274,13 @@ export async function proxy(request: NextRequest) { ); } - if (user) return forward(request, user.id, user.email ?? ""); + if (user) { + if (matchesPrefix(pathname, WHATSAPP_API_PREFIXES)) { + const deniedForSession = await whatsappPlanGate(user.id); + if (deniedForSession) return deniedForSession; + } + return forward(request, user.id, user.email ?? ""); + } const presented = readPresentedKey(request.headers); if (!presented) { @@ -243,6 +309,11 @@ export async function proxy(request: NextRequest) { ); } + if (matchesPrefix(pathname, WHATSAPP_API_PREFIXES)) { + const denied = await whatsappPlanGate(key.userId); + if (denied) return denied; + } + return forward(request, key.userId, key.ownerEmail ?? "", key.scopes); } diff --git a/supabase/migrations/20260907120000_lite_plan.sql b/supabase/migrations/20260907120000_lite_plan.sql new file mode 100644 index 0000000..160dbe8 --- /dev/null +++ b/supabase/migrations/20260907120000_lite_plan.sql @@ -0,0 +1,12 @@ +-- Adds "lite" as a valid plan: analytics only, no WhatsApp. Widens both +-- check constraints that currently only allow ('go', 'plus') -- profiles.plan +-- (the account's current plan) and billing_invoices.plan (the plan an +-- invoice was billed under, which must keep accepting whatever value +-- profiles.plan can hold). +alter table public.profiles + drop constraint profiles_plan_check, + add constraint profiles_plan_check check (plan in ('go', 'plus', 'lite')); + +alter table public.billing_invoices + drop constraint billing_invoices_plan_check, + add constraint billing_invoices_plan_check check (plan in ('go', 'plus', 'lite'));