mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-09 08:02:14 +00:00
156 lines
6.4 KiB
TypeScript
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,
|
|
};
|
|
}
|