import { EDITION, type Edition } from "./edition"; export type PlanId = "free" | "pro" | "business"; /** * Three Business-only switches, and that is the whole matrix. * * v2 has two paid plans and cancelling removes every feature rather than * demoting you, so "is this person paying" is answered by the middleware * paywall in src/proxy.ts, not by a boolean per feature. What is left is the * short list a solo operator genuinely does not need and a team cannot work * without. Agent count is a quota, not a flag: see agentQuotaFor(). */ export interface PlanFeatures { abTesting: boolean; teamCollaboration: boolean; advancedAnalytics: boolean; } export interface PlanLimits { postsPerMonth: number; // -1 = unlimited scheduledPosts: number; // -1 = unlimited imagesPerMonth: number; // -1 = unlimited /** LinkedIn agents included, each with its own account, proxy and warm-up. */ agents: number; features: PlanFeatures; } export interface PlanInfo { id: PlanId; name: string; description: string; price: number; // Monthly price in USD // Yearly total in USD (pay 10 months, get 12) limits: PlanLimits; popular?: boolean; } export const PLANS: Record = { // Not a tier. This is the state an account lands in when the trial ends or // a subscription is cancelled, and it grants nothing. free: { id: "free", name: "No plan", description: "Your trial ended. Pick a plan to start again.", price: 0, limits: { postsPerMonth: 0, scheduledPosts: 0, imagesPerMonth: 0, agents: 0, features: { abTesting: false, teamCollaboration: false, advancedAnalytics: false, }, }, }, pro: { id: "pro", name: "Pro", description: "Two agents finding and messaging your leads every day", price: 59, popular: true, limits: { postsPerMonth: -1, scheduledPosts: -1, imagesPerMonth: -1, agents: 2, features: { abTesting: false, teamCollaboration: false, advancedAnalytics: false, }, }, }, business: { id: "business", name: "Business", description: "Three agents, your whole team, and the reporting behind it", price: 89, limits: { postsPerMonth: -1, scheduledPosts: -1, imagesPerMonth: -1, agents: 3, features: { abTesting: true, teamCollaboration: true, advancedAnalytics: true, }, }, }, }; /** * How long the trial runs. * * Stripe owns the clock: the length is passed to Checkout as * `trial_period_days` and the dates on the user row are a mirror the webhook * writes. Nothing else may set them, because two clocks disagree the first time * somebody upgrades mid-trial. */ export const TRIAL_DAYS = 7; /** * How long a failed payment has before the agents stop. * * Pausing rather than deleting: it stops the LinkedIn activity and therefore * our per-agent cost, while the leads, the sequences and the history stay where * they are so a recovered card resumes instantly. */ export const DUNNING_GRACE_DAYS = 2; /** * How long an account may sit with no card before it is deleted. * * Signup creates a row that can reach nothing. Keeping those for ever fills the * table with people who never finished, so they go after 2 weeks with a warning * email first. */ export const UNCARDED_DELETE_DAYS = 14; /** Price per extra agent, monthly, on either plan and either billing period. */ export const EXTRA_AGENT_PRICE = 39; /** * The ceiling on add-on agents. * * Each one is a real LinkedIn account behind its own residential address, so a * runaway quantity is a bill we pay before the customer does. */ export const MAX_EXTRA_AGENTS = 10; export const FEATURE_INFO: Record = { abTesting: { name: "A/B testing", description: "Run two versions of a post and keep the one that performs", icon: "split", }, teamCollaboration: { name: "Team", description: "Invite people to the workspace and set what they can do", icon: "users-round", }, advancedAnalytics: { name: "Advanced analytics", description: "Posting heatmap, demographics and exportable reports", icon: "trending-up", }, }; /** * The plan a gate should actually be evaluated against. * * Admins run on the top plan so they can open every screen to support * customers on it. That rule lives in the session (src/lib/auth.ts) and it has * to hold on the server too: a route re-reading users.plan from the database * would answer "pro" while the UI showed Business, which is how A/B testing * 403'd for an admin on 2026-07-26. */ export function effectivePlanFor( edition: Edition, user: { plan?: string | null; isAdmin?: boolean | null } ): PlanId { if (edition === "self-hosted") return "business"; if (user.isAdmin) return "business"; const plan = user.plan; return plan === "pro" || plan === "business" ? plan : "free"; } export function effectivePlan(user: { plan?: string | null; isAdmin?: boolean | null; }): PlanId { return effectivePlanFor(EDITION, user); } export function canAccessFeatureFor( edition: Edition, userPlan: PlanId, feature: keyof PlanFeatures ): boolean { if (edition === "self-hosted") return true; return PLANS[userPlan].limits.features[feature]; } export function canAccessFeature( userPlan: PlanId, feature: keyof PlanFeatures ): boolean { return canAccessFeatureFor(EDITION, userPlan, feature); } export function getRequiredPlanForFeature( feature: keyof PlanFeatures ): PlanId { if (PLANS.pro.limits.features[feature]) return "pro"; return "business"; } /** Self hosted has no monthly caps at all; the cloud keeps the plan table's numbers. */ export function isWithinLimitFor( edition: Edition, userPlan: PlanId, limitType: "postsPerMonth" | "scheduledPosts" | "imagesPerMonth", currentUsage: number ): boolean { if (edition === "self-hosted") return true; const limit = PLANS[userPlan].limits[limitType]; if (limit === -1) return true; // Unlimited return currentUsage < limit; } export function isWithinLimit( userPlan: PlanId, limitType: "postsPerMonth" | "scheduledPosts" | "imagesPerMonth", currentUsage: number ): boolean { return isWithinLimitFor(EDITION, userPlan, limitType, currentUsage); } /** A self hosted quota is Number.MAX_SAFE_INTEGER; screens hide the fraction rather than print it. */ export function isUnlimitedQuota(n: number): boolean { return n >= Number.MAX_SAFE_INTEGER; } /** Agents included before the extra-agent add-on. Self hosted has no ceiling. */ export function agentQuotaForEdition(edition: Edition, plan: PlanId): number { if (edition === "self-hosted") return Number.MAX_SAFE_INTEGER; return PLANS[plan].limits.agents; } export function agentQuotaFor(plan: PlanId): number { return agentQuotaForEdition(EDITION, plan); } /** * Agents are gated on a live Stripe subscription, never on the plan column * alone. v1 wrote plan values without any card behind them (every LTD holder * carries 'business'), and an agent costs real money the moment its LinkedIn * account connects: a dedicated IP is bought. So testing agents starts at the * checkout, card first, for everyone who has never subscribed, LTD included * (their permanent 50% coupon applies there). The posting features stay open * to them exactly as before; this gate guards the agent surface only. * Admins bypass: they run the house accounts. */ export function hasAgentSubscriptionFor( edition: Edition, user: { stripeSubscriptionId?: string | null; isAdmin?: boolean | null } ): boolean { if (edition === "self-hosted") return true; if (user.isAdmin) return true; return typeof user.stripeSubscriptionId === "string" && user.stripeSubscriptionId.length > 0; } export function hasAgentSubscription(user: { stripeSubscriptionId?: string | null; isAdmin?: boolean | null; }): boolean { return hasAgentSubscriptionFor(EDITION, user); } /** How many agents may run in total: the plan, plus whatever was bought on top. */ export function effectiveAgentQuotaFor( edition: Edition, plan: PlanId, extraAgents = 0 ): number { if (edition === "self-hosted") return Number.MAX_SAFE_INTEGER; // A cancelled account keeps nothing, whatever it once paid for as an add-on: // the add-on item dies with the subscription it hangs off. if (plan === "free") return 0; const extra = Number.isFinite(extraAgents) ? Math.max(0, Math.trunc(extraAgents)) : 0; return agentQuotaForEdition(edition, plan) + Math.min(extra, MAX_EXTRA_AGENTS); } export function effectiveAgentQuota(plan: PlanId, extraAgents = 0): number { return effectiveAgentQuotaFor(EDITION, plan, extraAgents); } /** * Where "upgrade" leads. The plan picker exists on the cloud only; a self * hosted instance has every plan gate unlocked, so nothing that links here * ever renders there and the path points at the settings page rather than * at a 404. */ export const UPGRADE_PATH = EDITION === "cloud" ? "/dashboard/upgrade" : "/dashboard/settings"; export function getUpgradePath(currentPlan: PlanId): PlanId | null { const order: PlanId[] = ["free", "pro", "business"]; const currentIndex = order.indexOf(currentPlan); if (currentIndex < order.length - 1) { return order[currentIndex + 1]; } return null; // Already on highest plan } export function getMissingFeatures( currentPlan: PlanId, targetPlan: PlanId ): (keyof PlanFeatures)[] { const currentFeatures = PLANS[currentPlan].limits.features; const targetFeatures = PLANS[targetPlan].limits.features; return (Object.keys(targetFeatures) as (keyof PlanFeatures)[]).filter( (feature) => targetFeatures[feature] && !currentFeatures[feature] ); }