Files
descrybe/apps/web/src/lib/billing-display.ts
T

441 lines
14 KiB
TypeScript
Raw Normal View History

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);
}