import { i18n } from "$lib/i18n"; import { formatCredits } from "$lib/utils"; /** Matches apps/api/internal/billing.EnterpriseUnlimitedCredits. */ export const ENTERPRISE_UNLIMITED_CREDITS = 1_000_000; export type PlanLike = { name?: string | null; is_custom?: boolean | null; is_legacy?: boolean | null; is_trial?: boolean | null; monthly_credits?: number | null; max_products?: number | null; next_billing_date?: string | null; subscription_status?: string | null; }; /** Subset of CreditsOverview / auth/me credits — prefer API entitlement fields. */ export type CreditsLike = { total_credits?: number; used_credits?: number; remaining?: number; remaining_credits?: number; can_use_ai?: boolean; can_use_eprel?: boolean; is_free_plan?: boolean; is_paid_plan?: boolean; has_active_plan?: boolean; low_credits?: boolean; at_product_limit?: boolean; plan?: PlanLike | Record | null; /** Effective feature map from ResolveFeatures (additive; may be absent pre-cutover). */ features?: Record; /** Global section master switches (platform_feature_gates). */ sections?: Record; disabled_features?: string[]; feature_etag?: string; }; export type UpgradeCta = { primaryHref: string; primaryLabel: string; showSales: boolean; /** Extra copy for members who cannot open Checkout (API requires company admin). */ memberHint: string | null; }; export type BillingRecoveryKind = "missing_plan" | "past_due"; export type BillingRecovery = { kind: BillingRecoveryKind; tone: "warning" | "danger"; title: string; message: string; primaryHref: string; primaryLabel: string; /** When true, billing page should open Customer Portal instead of navigating. */ openPortal: boolean; showSales: boolean; }; type CreditUsageItem = { burns: boolean; labelKey: string; detailKey: string; }; /** What burns (or does not burn) AI credits — aligned with Free-plan gates + DebitAmount. */ export const CREDIT_USAGE_ITEMS: readonly CreditUsageItem[] = [ { burns: false, labelKey: "billing.creditUsage.normalize.label", detailKey: "billing.creditUsage.normalize.detail" }, { burns: true, labelKey: "billing.creditUsage.ai.label", detailKey: "billing.creditUsage.ai.detail" }, { burns: false, labelKey: "billing.creditUsage.eprel.label", detailKey: "billing.creditUsage.eprel.detail" }, { burns: true, labelKey: "billing.creditUsage.campaign.label", detailKey: "billing.creditUsage.campaign.detail" } ]; export function planNameOf(plan: PlanLike | null | undefined, fallback?: string): string { const name = (plan?.name ?? "").trim(); return name || (fallback ?? i18n.t("billing.plan.free")); } /** Localize known catalog plan names for display (API still stores English names). */ export function localizePlanName(name: string | null | undefined): string { const raw = (name ?? "").trim(); if (!raw) return ""; switch (raw.toLowerCase()) { case "free": return i18n.t("billing.plan.free"); case "enterprise": return i18n.t("billing.plan.enterprise"); default: return raw; } } function payAsYouGoLabel(): string { return i18n.t("billing.payAsYouGo"); } function unlimitedLabel(): string { return i18n.t("billing.unlimited"); } /** True only when API reports an active company_plans row (or plan payload is present). */ export function hasActivePlan(credits?: CreditsLike | null, plan?: PlanLike | null): boolean { if (typeof credits?.has_active_plan === "boolean") return credits.has_active_plan; return Boolean(plan?.name?.trim()); } /** Display label — never invent Free/Unlimited when the company has no assigned plan. */ export function planDisplayName( plan: PlanLike | null | undefined, credits?: CreditsLike | null ): string { if (!hasActivePlan(credits, plan ?? undefined)) return i18n.t("billing.noPlanAssigned"); const raw = (plan?.name ?? "").trim(); if (!raw) return i18n.t("billing.plan.free"); return localizePlanName(raw); } export function isEnterprisePlan(plan: PlanLike | null | undefined): boolean { if (!plan?.name?.trim() && !plan?.is_custom) return false; const name = planNameOf(plan, "").toLowerCase(); if (name === "enterprise") return true; // Custom deals with null SKU cap + large pack read as unlimited in the UI. if (plan?.is_custom && plan.max_products == null) { const monthly = plan.monthly_credits ?? 0; if (monthly >= ENTERPRISE_UNLIMITED_CREDITS) return true; } return false; } /** Migrated A1 / Legacy limited-nav package (not public ladder; not enable-all custom). */ export function isLegacyPlan(plan: PlanLike | null | undefined): boolean { if (!plan) return false; // Prefer explicit API flag (A1 PAYG is seeded is_legacy=false). if (plan.is_legacy === true) return true; if (plan.is_legacy === false) return false; const name = planNameOf(plan, "").toLowerCase(); if (!name) return false; if (name === "legacy" || name.includes("legacy")) return true; if (name === "a1" || name.startsWith("a1 ") || name.startsWith("a1-") || name.startsWith("a1_")) { return true; } return name.includes("a1 slovenija"); } /** * Pay-as-you-go / custom wallet plans: monthly allotment is 0 (not Free, not Enterprise). * Credits come from the wallet — never present remaining as a prepaid monthly pack. */ export function isPayAsYouGoPlan( plan: PlanLike | null | undefined, credits?: CreditsLike | null ): boolean { if (!hasActivePlan(credits, plan ?? undefined)) return false; if (isFreePlan(plan, credits)) return false; if (isEnterprisePlan(plan)) return false; const monthly = plan?.monthly_credits; return monthly === 0; } export function isFreePlan( plan: PlanLike | null | undefined, credits?: CreditsLike | null ): boolean { if (!hasActivePlan(credits, plan ?? undefined)) return false; if (credits?.is_free_plan) return true; return planNameOf(plan, "").toLowerCase() === "free"; } export function subscriptionStatusOf( plan?: PlanLike | null, stripeStatus?: string | null ): string { const fromPlan = (plan?.subscription_status ?? "").trim().toLowerCase(); if (fromPlan) return fromPlan; return (stripeStatus ?? "").trim().toLowerCase(); } export function isPastDueStatus(status: string | null | undefined): boolean { return (status ?? "").trim().toLowerCase() === "past_due"; } /** Recovery CTAs for missing/skipped company_plans or Stripe past_due (grace, not hard-lock). */ export function billingRecovery(options: { credits?: CreditsLike | null; plan?: PlanLike | null; subscriptionStatus?: string | null; canManageBilling: boolean; }): BillingRecovery | null { const { credits, plan, subscriptionStatus, canManageBilling } = options; const status = subscriptionStatusOf(plan, subscriptionStatus); if (isPastDueStatus(status)) { if (canManageBilling) { return { kind: "past_due", tone: "warning", title: i18n.t("billing.recovery.pastDueTitle"), message: i18n.t("billing.recovery.pastDueAdmin"), primaryHref: "/billing", primaryLabel: i18n.t("billing.recovery.openPortal"), openPortal: true, showSales: false }; } return { kind: "past_due", tone: "warning", title: i18n.t("billing.recovery.pastDueTitle"), message: i18n.t("billing.recovery.pastDueMember"), primaryHref: "/settings?tab=team", primaryLabel: i18n.t("billing.recovery.contactAdmin"), openPortal: false, showSales: false }; } if (!hasActivePlan(credits, plan ?? undefined)) { const cta = upgradeCtaForRole(canManageBilling); return { kind: "missing_plan", tone: "warning", title: i18n.t("billing.recovery.missingPlanTitle"), message: withUpgradeHint(i18n.t("billing.recovery.missingPlanMessage"), cta), primaryHref: canManageBilling ? "/plans" : cta.primaryHref, primaryLabel: canManageBilling ? i18n.t("billing.recovery.choosePlan") : cta.primaryLabel, openPortal: false, showSales: cta.showSales }; } return null; } /** * Remaining AI credits from CreditsOverview /auth/me. * Prefers remaining_credits (API-clamped), then remaining, then total-used clamped at 0. */ export function remainingCreditsOf(credits: CreditsLike | null | undefined): number | null { if (!credits) return null; if (typeof credits.remaining_credits === "number") { return Math.max(0, credits.remaining_credits); } if (typeof credits.remaining === "number") { return Math.max(0, credits.remaining); } if (typeof credits.total_credits === "number" && typeof credits.used_credits === "number") { return Math.max(0, credits.total_credits - credits.used_credits); } return null; } /** * Matches ComputeEntitlements / CreditsOverview.can_use_ai: * remaining > 0 OR paid plan (not Free). */ export function canUseAIFromCredits(credits: CreditsLike | null | undefined): boolean { if (!credits) return false; if (typeof credits.can_use_ai === "boolean") return credits.can_use_ai; const rem = remainingCreditsOf(credits) ?? 0; if (credits.is_paid_plan) return true; if (credits.is_free_plan) return rem > 0; return rem > 0; } /** Company admins may open Checkout / billing portal (POST /api/billing/checkout). */ export function upgradeCtaForRole(canManageBilling: boolean): UpgradeCta { if (canManageBilling) { return { primaryHref: "/plans", primaryLabel: i18n.t("billing.upgrade"), showSales: true, memberHint: null }; } return { primaryHref: "/settings?tab=team", primaryLabel: i18n.t("billing.askCompanyAdmin"), showSales: false, memberHint: i18n.t("billing.memberHint") }; } export function withUpgradeHint(message: string, cta: UpgradeCta): string { if (!cta.memberHint) return message; return `${message} ${cta.memberHint}`; } /** * AI credit remaining label for cards and summaries. * Enterprise plans say Unlimited for the plan entitlement; when the wallet still * exposes a finite remaining balance (below the unlimited sentinel), surface both * so operators are not confused by "Unlimited" alone. * PAYG never heroes a wallet number — returns pay-as-you-go (same as status labels). */ export function formatCreditsRemaining( remaining: number | null | undefined, plan?: PlanLike | null, credits?: CreditsLike | null ): string { if (isPayAsYouGoPlan(plan, credits)) return payAsYouGoLabel(); if (!plan?.name?.trim() && !isEnterprisePlan(plan)) { return formatCredits(remaining); } if (isEnterprisePlan(plan)) { if ( typeof remaining === "number" && Number.isFinite(remaining) && remaining < ENTERPRISE_UNLIMITED_CREDITS ) { return i18n.t("billing.unlimitedPlanWallet", { wallet: formatCredits(remaining) }); } return unlimitedLabel(); } return formatCredits(remaining); } /** * Plan/billing status for dashboard headers and welcome copy. * PAYG → pay-as-you-go (never "N credits ready" / fake monthly allotment language). */ export function formatCreditsStatusLabel( remaining: number | null | undefined, plan?: PlanLike | null, credits?: CreditsLike | null ): string { if (isPayAsYouGoPlan(plan, credits)) return payAsYouGoLabel(); return formatCreditsRemaining(remaining, plan); } /** Header line fragment — appends "credits" only for prepaid monthly wallets. */ export function formatCreditsStatusLine( remaining: number | null | undefined, plan?: PlanLike | null, credits?: CreditsLike | null ): string { const label = formatCreditsStatusLabel(remaining, plan, credits); if (isPayAsYouGoPlan(plan, credits)) return label; if (isEnterprisePlan(plan)) return label; if (!hasActivePlan(credits, plan ?? undefined)) return label; return i18n.t("billing.creditsSuffix", { label }); } export function formatMonthlyCredits(plan: PlanLike | null | undefined): string { if (!plan?.name?.trim() && !plan?.is_custom) return i18n.t("status.emDash"); if (isEnterprisePlan(plan)) return unlimitedLabel(); const monthly = plan?.monthly_credits; if (monthly == null) return i18n.t("status.emDash"); if (monthly === 0) { if (planNameOf(plan, "").toLowerCase() === "free") return i18n.t("billing.zeroPerMonth"); return payAsYouGoLabel(); } return i18n.t("billing.perMonth", { amount: formatCredits(monthly) }); } /** SKU cap — null on an assigned plan means unlimited (Enterprise / custom). Missing plan → em dash. */ export function formatSkuCap( maxProducts: number | null | undefined, plan?: PlanLike | null ): string { if (!plan?.name?.trim() && !plan?.is_custom) { return maxProducts == null ? i18n.t("status.emDash") : i18n.t("billing.upTo", { count: formatCredits(maxProducts) }); } if (isEnterprisePlan(plan)) return unlimitedLabel(); if (maxProducts == null) { return plan ? unlimitedLabel() : i18n.t("status.emDash"); } return i18n.t("billing.upTo", { count: formatCredits(maxProducts) }); } export function formatSkuUsage( productCount: number | null | undefined, maxProducts: number | null | undefined, plan?: PlanLike | null ): string { const used = formatCredits(productCount ?? 0); const assigned = Boolean(plan?.name?.trim() || plan?.is_custom); if (isEnterprisePlan(plan) || (assigned && maxProducts == null)) { return i18n.t("billing.skusUnlimited", { used }); } if (maxProducts == null) { return i18n.t("billing.skusOnly", { used }); } return i18n.t("billing.skusOf", { used, max: formatCredits(maxProducts) }); } export function planKindLabel(plan: PlanLike | null | undefined): string { if (!plan?.name) return i18n.t("billing.planKind.none"); if (isEnterprisePlan(plan) || plan.is_custom) return i18n.t("billing.planKind.enterprise"); if (plan.is_trial) return i18n.t("billing.planKind.trial"); return i18n.t("billing.planKind.standard"); } /** Self-serve Stripe checkout ladder (not Free / Enterprise). */ export const SELF_SERVE_CHECKOUT_PLANS = ["starter", "plus", "growth", "business", "scale"] as const; /** Self-serve Stripe plans only (not Free / Enterprise). */ export function isSelfServeCheckoutPlan(name: string | null | undefined): boolean { const key = (name ?? "").trim().toLowerCase(); return (SELF_SERVE_CHECKOUT_PLANS as readonly string[]).includes(key); } /** Next paid ladder step for Billing quick-upgrade (Free → Starter … → Scale). */ export function nextSelfServeUpgradePlan(currentPlanName: string | null | undefined): string | null { const key = (currentPlanName ?? "").trim().toLowerCase(); if (!key || key === "free") return "starter"; const idx = (SELF_SERVE_CHECKOUT_PLANS as readonly string[]).indexOf(key); if (idx < 0 || idx >= SELF_SERVE_CHECKOUT_PLANS.length - 1) return null; return SELF_SERVE_CHECKOUT_PLANS[idx + 1] ?? null; } /** Title-case checkout plan key for CTA labels (starter → Starter). */ export function planCheckoutDisplayName(planKey: string | null | undefined): string { const key = (planKey ?? "").trim().toLowerCase(); if (!key) return ""; return key.charAt(0).toUpperCase() + key.slice(1); }