Files
warmbly/web/src/hooks/useFeatureAccess.ts
T

156 lines
6.4 KiB
TypeScript

// useFeatureAccess — single source of truth for "can this org do X".
//
// Plan ladder lifted from warmbly-web/src/pages/pricing.astro:
//
// free → no active subscription
// warmup → $15/mo, premium pool and no mailbox cap; no sending
// starter → $29/mo, 150 sends/day
// grow → $89/mo, 3k sends/day
// business → $329/mo, 15k sends/day + isolated sending (featured)
// enterprise → custom, 15k+ sends/day + isolated sending
//
// Gates here decide which dashboard features show up in the sidebar
// + which surfaces render the LockedSurface overlay. The minimum
// unlock plan should always match what we promise on the pricing
// page.
//
// A deployment with billing disabled (BILLING_PROVIDER=none, the self-host
// default) unlocks everything server-side, so it is unlocked here too and the
// subscription row is ignored: it would otherwise report a free trial that
// nothing enforces.
import type Subscription from "@/lib/api/models/app/subscription/Subscription";
import useSubscription from "@/lib/api/hooks/app/subscription/useSubscription";
import useAuthConfig from "@/lib/api/hooks/auth/useAuthConfig";
import { useAppStore } from "@/stores";
import { PERMISSION_BITS, hasPermission } from "@/lib/permissions";
import {
WARMUP_PLAN_ID,
getPlan,
isAtLeast,
type PlanID,
} from "@/lib/plans";
export type Plan = PlanID;
export interface FeatureAccess {
loading: boolean;
status?: Subscription["status"];
plan: PlanID;
/** False on a deployment running without a billing provider: every gate
* below is open and billing/referral surfaces do not apply. */
billing: boolean;
/** Active subscription on any paid tier, the Warmup plan included. */
paid: boolean;
/** On the Warmup plan: the premium pool and no mailbox cap, nothing else.
* Every surface a free workspace cannot use stays locked. */
warmupOnly: boolean;
/** Hosted workspace without a plan that sends (free, or the Warmup plan):
* only mailboxes, the Warmbly Cloud link and settings are open; everything
* else waits for a plan. */
locked: boolean;
/** Unified inbox — free trial and Starter+. */
hasInbox: boolean;
/** Advanced outreach (AB tests, custom rules) — Business+. */
hasAdvanced: boolean;
/** Sending on infrastructure bound to this org alone — Business+. */
hasIsolatedSending: boolean;
/** Realtime websocket events — every tier, baseline. */
hasRealtime: boolean;
/** Bulk import/edit on contacts — Starter+. */
hasBulkOps: boolean;
/** Team invitations — Starter+. */
hasTeam: boolean;
/** Webhook endpoints — Business+. */
hasWebhooks: boolean;
/** Convenience: viewer is the current org's owner. */
isOwner: boolean;
/** Owner OR admin. */
canManage: boolean;
}
export default function useFeatureAccess(): FeatureAccess {
const sub = useSubscription();
const authConfig = useAuthConfig();
const currentOrg = useAppStore((s) => s.currentOrganization);
const isOwner = currentOrg?.role === "owner";
// Permission-aware: a custom role carrying MANAGE_TEAM unlocks the
// same management surfaces as the built-in admin role.
const canManage =
currentOrg?.role === "owner" ||
currentOrg?.role === "admin" ||
hasPermission(currentOrg?.permissions, PERMISSION_BITS.MANAGE_TEAM);
// Only the confirmed answer counts; the fallback keeps billing on so an
// unreachable backend never reads as an unlocked one.
const billingOff = !!authConfig.data && authConfig.data.billing_enabled === false;
if (billingOff) {
return {
loading: false,
status: "active",
plan: "enterprise",
billing: false,
paid: true,
warmupOnly: false,
locked: false,
hasInbox: true,
hasAdvanced: true,
hasIsolatedSending: true,
hasRealtime: true,
hasBulkOps: true,
hasTeam: true,
hasWebhooks: true,
isOwner,
canManage,
};
}
const planId = sub.data?.plan?.id === WARMUP_PLAN_ID
? "warmup"
: ((sub.data?.plan?.name ?? currentOrg?.plan ?? "free").toLowerCase()) as PlanID;
const plan = getPlan(planId).id;
const status = sub.data?.status;
// Real paid status comes from Stripe via /subscription. While
// that's in flight, fall back to the org row's plan field so a
// paying customer doesn't see "Locked" for a beat on first load.
const subSaysPaid = status === "active" || status === "trialing";
// A plan an operator granted never touches Stripe, so `status` stays
// whatever it was ("incomplete" on a workspace that never subscribed).
// Deciding paid from status alone locks a workspace that is entitled to
// everything, which is what the plan pill and the credit balance already
// show correctly. The server resolves expiry, so this is just a read.
const managed = sub.data?.managed === true;
const orgImpliesPaid =
sub.isPending && !!currentOrg?.plan && currentOrg.plan.toLowerCase() !== "free";
const isPaid = subSaysPaid || managed || orgImpliesPaid;
// The Warmup plan is paid, but pays for warming only; the server refuses
// sending, the inbox and AI to it exactly as it does to a free workspace.
const warmupOnly = isPaid && plan === "warmup";
const productPaid = isPaid && !warmupOnly;
return {
loading: sub.isPending || authConfig.isLoading,
status,
plan,
billing: true,
paid: isPaid,
warmupOnly,
locked: !sub.isPending && !authConfig.isLoading && !productPaid,
// Unified inbox is included on every plan that sends, so gate it on
// the subscription (productPaid) rather than the plan-name → catalog
// map, which doesn't recognise server plan names like "Pro" / "Free
// Trial" and would wrongly lock paid orgs.
hasInbox: productPaid,
hasAdvanced: productPaid && isAtLeast(plan, "business"),
hasIsolatedSending: productPaid && isAtLeast(plan, "business"),
hasRealtime: true,
hasBulkOps: productPaid && isAtLeast(plan, "starter"),
hasTeam: productPaid && isAtLeast(plan, "starter"),
hasWebhooks: productPaid && isAtLeast(plan, "business"),
isOwner,
canManage,
};
}