441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
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<string, unknown> | null;
|
||
|
|
/** Effective feature map from ResolveFeatures (additive; may be absent pre-cutover). */
|
||
|
|
features?: Record<string, boolean>;
|
||
|
|
/** Global section master switches (platform_feature_gates). */
|
||
|
|
sections?: Record<string, boolean>;
|
||
|
|
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);
|
||
|
|
}
|