"use server"; /** * Centralized Billing Overview * * Single source of truth for everything the billing page + dashboard usage * bars need to render. Replaces ad-hoc calls to `getBrandPlanInfo`, * `getEnabledAddons`, brand-row subscription columns, and hard-coded invoice * data with one consistent, denormalized payload. * * Why a single action: * - Before this, the billing page combined `getBrandPlanInfo` + * `getEnabledAddons` + a brands-table read of subscription fields + * hard-coded mock invoices. The dashboard's product count came from * `getDashboardStats` which used a different filter than `get_brand_plan_info` * (`active=true` vs `deleted_at IS NULL`), causing "Active Products 1" vs * "Products 0/25" in the same UI. * - Add-ons could be "Active" in `brand_features` while no Stripe * subscription existed, with a Remove button that would fail at Stripe. * * What this returns: * - planTier + planCycle (we default to "monthly" because the brand row * doesn't store a cycle — the UI toggle controls display, but a real * cycle would be detected from a `stripe_subscription.items.data[0].price * .recurring.interval` when fetched) * - hasActiveSubscription + subscriptionStatus (so the UI can hide * misleading "Active" add-on badges / Remove buttons) * - usage: { users, stops_this_month, products } — products counted * consistently with the dashboard (`active=true AND deleted_at IS NULL`). * - enabledAddons with a derived `removable` flag — only true when * there is an active subscription AND the feature is enabled AND the * current user can manage settings. * - displayedInvoiceAmount — monthly or annual equivalent matching the * billingCycle toggle, summed across plan + active add-ons. Used by the * invoice table so amounts always match the displayed plan. */ import { getAdminUser } from "@/lib/admin-permissions"; import { pool } from "@/lib/db"; import { withBrand } from "@/db/client"; import { products } from "@/db/schema"; import { and, eq, count } from "drizzle-orm"; import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing"; export type BillingSubscriptionStatus = | "active" | "trialing" | "past_due" | "canceled" | "incomplete" | "unpaid" | "inactive" | "none"; export type BillingOverview = { brandId: string; brandName: string | null; planTier: PlanTierKey; planCycle: "monthly" | "annual"; planMonthlyPrice: number; planAnnualPrice: number; hasStripeCustomer: boolean; hasActiveSubscription: boolean; subscriptionStatus: BillingSubscriptionStatus | null; currentPeriodEnd: string | null; limits: { max_users: number; max_stops_monthly: number; max_products: number }; usage: { users: number; stops_this_month: number; products: number }; enabledAddons: Record; // Denormalized add-on list (for direct UI rendering) addons: Array<{ key: AddonKey; label: string; icon: string; description: string; enabled: boolean; removable: boolean; monthlyPrice: number; annualPrice: number; }>; // Pre-computed totals so the billing page never disagrees with itself displayedInvoiceAmount: number; // current cycle total monthlyTotal: number; // always monthly equivalent annualTotal: number; // always annual equivalent isPlatformAdmin: boolean; canManageBilling: boolean; }; /** * Fetch a single, consistent billing summary for a brand. * Safe to call from a server component (uses service-role key). */ export async function getBillingOverview( brandId: string, options?: { planCycle?: "monthly" | "annual" } ): Promise<{ success: boolean; data?: BillingOverview; error?: string }> { try { if (!brandId) return { success: false, error: "brandId required" }; // 1) Plan info (plan_tier + limits + usage via get_brand_plan_info) // Replicate the RPC inline via raw SQL on tenants + plans. const planRes = await pool.query<{ plan_tier: string; plan_name: string | null; max_users: number; max_stops_monthly: number; max_products: number; usage: { users: number; stops_this_month: number; products: number } | null; }>( `SELECT t.plan_tier, t.name AS plan_name, COALESCE(t.max_users, 1) AS max_users, COALESCE(t.max_stops_monthly, 10) AS max_stops_monthly, COALESCE(t.max_products, 25) AS max_products, jsonb_build_object( 'users', (SELECT count(*)::int FROM admin_user_brands tu WHERE tu.brand_id = t.id), 'stops_this_month', (SELECT count(*)::int FROM stops s WHERE s.brand_id = t.id AND s.created_at >= date_trunc('month', now())), 'products', (SELECT count(*)::int FROM products p WHERE p.brand_id = t.id AND p.active = true AND p.deleted_at IS NULL) ) AS usage FROM brands t WHERE t.id = $1`, [brandId] ); const planData = planRes.rows[0] ?? null; // 2) Brand row: subscription state, name, stripe_customer_id const brandRes = await pool.query<{ name: string; stripe_customer_id: string | null; stripe_subscription_id: string | null; stripe_subscription_status: string | null; stripe_current_period_end: string | null; }>( `SELECT name, stripe_customer_id, NULL::text AS stripe_subscription_id, NULL::text AS stripe_subscription_status, NULL::text AS stripe_current_period_end FROM brands WHERE id = $1`, [brandId] ); const brand = brandRes.rows[0] ?? null; // 3) Enabled add-ons (feature flags from brand_settings) const addonsRes = await pool.query<{ feature_flags: Record | null }>( `SELECT feature_flags FROM brand_settings WHERE brand_id = $1`, [brandId] ); const flags = addonsRes.rows[0]?.feature_flags ?? {}; const enabledAddons: Record = {}; for (const [k, v] of Object.entries(flags ?? {})) { enabledAddons[k] = v === true || v === "true" || v === 1 || v === "1"; } // 4) Active product count — same semantics as the dashboard // (active=true). Keeps the billing page in sync with /admin // "Active Products" stat. const productCountRows = await withBrand(brandId, (db) => db .select({ value: count() }) .from(products) .where( and( eq(products.brandId, brandId), eq(products.active, true) ) ) ); const activeProductCount = Number(productCountRows[0]?.value ?? 0); // 5) Admin context (so we know if the user can manage / is platform) const adminUser = await getAdminUser(); const isPlatformAdmin = adminUser?.role === "platform_admin"; const canManageBilling = isPlatformAdmin || !!adminUser?.can_manage_settings; // ── Normalize plan data ────────────────────────────────────────────────── const planTier = ((planData?.plan_tier as PlanTierKey | undefined) ?? "starter"); const limits = { max_users: planData?.max_users ?? 1, max_stops_monthly: planData?.max_stops_monthly ?? 10, max_products: planData?.max_products ?? 25, }; const planInfo = PLAN_TIERS[planTier] ?? PLAN_TIERS.starter; const planMonthlyPrice = planInfo.monthlyPrice; const planAnnualPrice = planInfo.annualPrice; // ── Normalize subscription state ───────────────────────────────────────── const rawStatus = (brand?.stripe_subscription_status ?? null) as BillingSubscriptionStatus | null; const hasStripeCustomer = !!brand?.stripe_customer_id; const hasActiveSubscription = rawStatus === "active" || rawStatus === "trialing"; // ── Build add-on list with derived `removable` ────────────────────────── const addons: BillingOverview["addons"] = (Object.keys(ADDONS) as AddonKey[]).map((key) => { const cfg = ADDONS[key]; const enabled = !!enabledAddons?.[key]; return { key, label: cfg.label, icon: cfg.icon, description: cfg.description, enabled, removable: enabled && hasActiveSubscription && canManageBilling, monthlyPrice: cfg.monthlyPrice, annualPrice: cfg.annualPrice, }; }); // ── Usage (products override from active=true+not-deleted count) ─────── const usage = { users: planData?.usage?.users ?? 0, stops_this_month: planData?.usage?.stops_this_month ?? 0, // Prefer the active+non-deleted count for cross-UI consistency. products: activeProductCount || (planData?.usage?.products ?? 0), }; // ── Totals ─────────────────────────────────────────────────────────────── const addonsMonthlyTotal = addons .filter((a) => a.enabled && hasActiveSubscription) .reduce((s, a) => s + a.monthlyPrice, 0); const addonsAnnualTotal = addons .filter((a) => a.enabled && hasActiveSubscription) .reduce((s, a) => s + a.annualPrice, 0); const monthlyTotal = planMonthlyPrice + addonsMonthlyTotal; const annualTotal = planAnnualPrice + addonsAnnualTotal; const planCycle: "monthly" | "annual" = options?.planCycle ?? "monthly"; // safer default — see header comment const displayedInvoiceAmount = planCycle === "annual" ? planAnnualPrice + addonsAnnualTotal : planMonthlyPrice + addonsMonthlyTotal; return { success: true, data: { brandId, brandName: brand?.name ?? planData?.plan_name ?? null, planTier, planCycle, planMonthlyPrice, planAnnualPrice, hasStripeCustomer, hasActiveSubscription, subscriptionStatus: rawStatus, currentPeriodEnd: brand?.stripe_current_period_end ?? null, limits, usage, enabledAddons, addons, displayedInvoiceAmount, monthlyTotal, annualTotal, isPlatformAdmin, canManageBilling, }, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { success: false, error: `Failed to build billing overview: ${message}` }; } }