0245aa29cc
Tuxedo buyer path (subagent 2): - src/app/tuxedo/page.tsx: remove duplicate CinematicShowcase render - src/components/storefront/CinematicShowcase.tsx: wire up useCart, Add to Cart button, brand-aware fulfillment - src/app/tuxedo/stops/TuxedoStopsList.tsx: improved empty state with calendar icon + CTAs - src/app/cart/CartClient.tsx: guard against empty cart checkout; 'Cart is Empty' state Billing reconciliation (subagent 3): - src/actions/billing/billing-overview.ts: NEW — single source of truth - src/app/admin/settings/billing/page.tsx: use getBillingOverview - src/app/admin/settings/billing/BillingClientPage.tsx: rewritten to consume BillingOverview (status pill, addons state, removable flags, derived invoice amounts, usage footer) - src/app/admin/page.tsx: use getBillingOverview (aligns dashboard with billing) - src/components/admin/DashboardClient.tsx: 'Active Products' now reads from getBillingOverview - supabase/migrations/203_plan_usage_active_products.sql: get_brand_plan_info counts products as active=true AND deleted_at IS NULL Harvest Reach dedup + audience preview (manual, subagent 4 didn't complete): - src/components/admin/CommunicationsPage.tsx: add initialTab prop - src/app/admin/communications/compose/page.tsx: now renders with initialTab='compose' (single compose experience, no duplicate edit panel) - src/components/admin/HarvestReach/CampaignComposerPage.tsx: always-visible audience preview panel (count + sample emails), loads via previewCampaignAudience action Layout/content consistency + a11y sweep (subagent 5): - src/components/layout/SiteHeader.tsx: Admin link only shows for authenticated admin users - src/components/Providers.tsx: suppress public SiteHeader/Footer for /admin, /cart, /checkout, /wholesale, /water (fixes duplicate headers) - src/app/contact/ContactClientPage.tsx: Phone/Email now use tel:/mailto: links; dynamic year - src/app/blog/page.tsx, changelog, privacy-policy, roadmap, security, terms-and-conditions, waitlist: dynamic year - src/app/admin/wholesale/WholesaleClient.tsx: proper htmlFor/id, type=email/tel, autoComplete - src/app/admin/settings/ai/AIClient.tsx: proper htmlFor/id, required + aria-required - src/app/admin/settings/integrations/IntegrationsClient.tsx: only mask secret fields; add required + aria-required + CredentialField.required type - src/app/admin/water-log/headgates/HeadgatesManager.tsx: htmlFor/id, aria-required - src/components/admin/CreateUserModal.tsx: htmlFor/id, required, aria-required, aria-describedby, autoComplete - src/app/admin/me/AdminMeClient.tsx, products/import, sales/import, water-log/settings, login, brands, tuxedo: a11y polish (ids/required/aria)
237 lines
9.6 KiB
TypeScript
237 lines
9.6 KiB
TypeScript
"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 { svcHeaders } from "@/lib/svc-headers";
|
|
import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing";
|
|
|
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
|
|
|
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<string, boolean>;
|
|
// 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)
|
|
const planRes = await fetch(
|
|
`${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`,
|
|
{
|
|
method: "POST",
|
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
body: JSON.stringify({ p_brand_id: brandId }),
|
|
}
|
|
);
|
|
const planData = planRes.ok ? await planRes.json() : null;
|
|
|
|
// 2) Brand row: subscription state, name, stripe_customer_id
|
|
const brandRes = await fetch(
|
|
`${supabaseUrl}/rest/v1/brands?id=eq.${brandId}&select=name,stripe_customer_id,stripe_subscription_id,stripe_subscription_status,stripe_current_period_end`,
|
|
{ headers: svcHeaders(supabaseKey) }
|
|
);
|
|
const brandRows = brandRes.ok ? await brandRes.json() : [];
|
|
const brand = Array.isArray(brandRows) ? brandRows[0] : null;
|
|
|
|
// 3) Enabled add-ons (feature flags)
|
|
const addonsRes = await fetch(
|
|
`${supabaseUrl}/rest/v1/rpc/get_brand_features`,
|
|
{
|
|
method: "POST",
|
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
body: JSON.stringify({ p_brand_id: brandId }),
|
|
}
|
|
);
|
|
const enabledAddons = addonsRes.ok ? await addonsRes.json() : {};
|
|
|
|
// 4) Active product count — same semantics as the dashboard
|
|
// (active=true AND deleted_at IS NULL). Keeps the billing page in
|
|
// sync with /admin "Active Products" stat.
|
|
const productRes = await fetch(
|
|
`${supabaseUrl}/rest/v1/products?select=id&brand_id=eq.${brandId}&active=eq.true&deleted_at=is.null&limit=1000`,
|
|
{ headers: { ...svcHeaders(supabaseKey), Prefer: "count=exact" } }
|
|
);
|
|
const productCountHeader = productRes.headers.get("Content-Range");
|
|
let activeProductCount = 0;
|
|
if (productCountHeader && productCountHeader.includes("/")) {
|
|
const total = productCountHeader.split("/").pop();
|
|
activeProductCount = parseInt(total ?? "0", 10) || 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 ?? "starter") as PlanTierKey;
|
|
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?.brand_name ?? null,
|
|
planTier,
|
|
planCycle,
|
|
planMonthlyPrice,
|
|
planAnnualPrice,
|
|
hasStripeCustomer,
|
|
hasActiveSubscription,
|
|
subscriptionStatus: rawStatus,
|
|
currentPeriodEnd: brand?.stripe_current_period_end ?? null,
|
|
limits,
|
|
usage,
|
|
enabledAddons: (enabledAddons ?? {}) as Record<string, boolean>,
|
|
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}` };
|
|
}
|
|
}
|