/** * Admin billing plans helpers — list filters, visibility badges, upsert/assign API. * Mirrors apps/api/internal/billing IsPublicProductPlan + migrated client deals (A1, …). */ import { api } from "$lib/api"; import { i18n } from "$lib/i18n"; import { isDefaultPublicPlanName } from "$lib/plan-feature-catalog"; import { formatCredits } from "$lib/utils"; export const ADMIN_PLANS_PATH = "/api/admin/plans"; export const ADMIN_ASSIGN_PLAN_PATH = "/api/admin/plans/assign"; /** Shared in-flight GET so keep-mounted billing tabs do not double-fetch the plans list. */ let adminPlansListInflight: Promise | null = null; /** Shared in-flight GET so billing cold load / remount races do not double-fetch companies. */ let adminCompaniesListInflight: Promise | null = null; /** Brief resolved caches — covers sequential remount after a fast GET completes (~9ms). */ let adminPlansListCache: { at: number; plans: AdminBillingPlan[] } | null = null; let adminCompaniesListCache: { at: number; companies: AdminBillingCompany[] } | null = null; const ADMIN_LIST_CACHE_MS = 1000; export type AdminBillingPlan = { id: number | string; name: string; description?: string | null; monthly_credits?: number; yearly_credits?: number | null; max_products?: number | null; is_custom?: boolean; term?: string; features?: Record; resolved_features?: Record; }; export type AdminBillingCompany = { id: string; name: string; language?: string; created_at?: string; total_credits?: number; used_credits?: number; has_active_plan?: boolean; }; /** * GET /api/admin/plans with in-flight dedupe (no AbortSignal). * Billing page + PlanPermissionsPanel may request the list concurrently on cold load. */ export async function fetchAdminPlansList(signal?: AbortSignal): Promise { if (!signal) { if (adminPlansListInflight) return adminPlansListInflight; if (adminPlansListCache && Date.now() - adminPlansListCache.at < ADMIN_LIST_CACHE_MS) { return adminPlansListCache.plans; } } const run = (async () => { const body = await api<{ plans: AdminBillingPlan[] }>(ADMIN_PLANS_PATH, { signal }); const plans = Array.isArray(body?.plans) ? body.plans : []; if (!signal) adminPlansListCache = { at: Date.now(), plans }; return plans; })(); if (!signal) { adminPlansListInflight = run; void run.finally(() => { if (adminPlansListInflight === run) adminPlansListInflight = null; }); } return run; } export const ADMIN_COMPANIES_PATH = "/api/admin/companies"; /** * GET /api/admin/companies with in-flight dedupe (no AbortSignal). * Billing summary cards + Companies tab share one cold-load fetch. * Short resolved cache absorbs remount storms after the fast companies GET settles * while the slower plans GET is still in flight. */ export async function fetchAdminCompaniesList( signal?: AbortSignal ): Promise { if (!signal) { if (adminCompaniesListInflight) return adminCompaniesListInflight; if ( adminCompaniesListCache && Date.now() - adminCompaniesListCache.at < ADMIN_LIST_CACHE_MS ) { return adminCompaniesListCache.companies; } } const run = (async () => { const body = await api<{ companies: AdminBillingCompany[] }>(ADMIN_COMPANIES_PATH, { signal }); const companies = Array.isArray(body?.companies) ? body.companies : []; if (!signal) adminCompaniesListCache = { at: Date.now(), companies }; return companies; })(); if (!signal) { adminCompaniesListInflight = run; void run.finally(() => { if (adminCompaniesListInflight === run) adminCompaniesListInflight = null; }); } return run; } /** Drop list caches after billing mutations so reload() sees fresh rows. */ export function invalidateAdminBillingLists(): void { adminPlansListCache = null; adminCompaniesListCache = null; } /** Public ladder / legacy deal / custom client package / retained catalog / junk. */ export type AdminPlanVisibility = "public" | "legacy" | "custom" | "hidden"; export type AdminPlanFilter = "all" | "catalog" | AdminPlanVisibility; export function isPublicAdminPlanName(name: string | null | undefined): boolean { return isDefaultPublicPlanName(name); } /** * Ephemeral integration-test plan rows (consume-contention-*, claim-test-plan-*, multi-plan-*). * Keep in DB if assigned, but hide from the default admin catalog filter. */ export function isEphemeralTestPlanName(name: string | null | undefined): boolean { const n = (name ?? "").trim().toLowerCase(); if (!n) return false; return ( n.startsWith("consume-contention-") || n.startsWith("claim-test-plan-") || n.startsWith("multi-plan-") ); } /** Pre-v2 ladder leftovers that must never appear on Choose your plan. */ export function isObsoleteLadderPlanName(name: string | null | undefined): boolean { const n = (name ?? "").trim().toLowerCase(); return ( n === "basic" || n === "professional" || n === "mini" || n === "merkur" || n === "meur" || n === "merkur trial" ); } /** * Plans kept for product/ops: public ladder + A1 + Legacy + Platform Demo. * Everything else (obsolete ladder, ephemeral tests) is "hidden" in the catalog filter. */ export function isRetainedCatalogPlanName(name: string | null | undefined): boolean { if (isPublicAdminPlanName(name)) return true; if (isLegacyPlanName(name)) return true; const n = (name ?? "").trim().toLowerCase(); return n === "platform demo"; } /** * Migrated / pre-v2 package names treated as Legacy (limited nav matrix). * Aligned with Go billing.IsLegacyPlanName: exact "legacy", A1*, or "a1 slovenija". * Broader hidden ladder names (Basic, Merkur, …) stay public/custom via is_custom / * public name checks — not forced into Legacy badges. */ export function isLegacyPlanName(name: string | null | undefined): boolean { const n = (name ?? "").trim().toLowerCase(); if (!n) return false; if (n === "legacy") return true; if (n.includes("a1 slovenija")) return true; if (n === "a1" || n.startsWith("a1 ") || n.startsWith("a1-") || n.startsWith("a1_")) return true; return false; } /** * Badge kind for admin plans table. * Priority: public ladder → legacy migrated names → custom deals (incl. is_custom non-ladder). */ export function classifyAdminPlanVisibility( plan: Pick | null | undefined ): AdminPlanVisibility { if (!plan?.name?.trim()) return "custom"; if (isEphemeralTestPlanName(plan.name) || isObsoleteLadderPlanName(plan.name)) { return "hidden"; } if (isPublicAdminPlanName(plan.name)) return "public"; // A1 PAYG (is_custom) is a client deal matrix, not restricted Legacy. if (isLegacyPlanName(plan.name)) return plan.is_custom ? "custom" : "legacy"; return "custom"; } export function adminPlanVisibilityLabel(kind: AdminPlanVisibility): string { switch (kind) { case "public": return i18n.t("admin.plans.visibility.public"); case "legacy": return i18n.t("admin.plans.visibility.legacy"); case "hidden": return i18n.t("admin.plans.visibility.hidden"); default: return i18n.t("admin.plans.visibility.custom"); } } export function adminPlanVisibilityBadgeVariant( kind: AdminPlanVisibility ): "outline" | "warning" | "secondary" { switch (kind) { case "public": return "outline"; case "legacy": return "warning"; default: return "secondary"; } } export function filterAdminPlans( plans: AdminBillingPlan[], opts: { filter?: AdminPlanFilter; search?: string } ): AdminBillingPlan[] { const filter = opts.filter ?? "catalog"; const q = (opts.search ?? "").trim().toLowerCase(); return plans.filter((p) => { const kind = classifyAdminPlanVisibility(p); if (filter === "catalog") { if (kind === "hidden") return false; } else if (filter !== "all" && kind !== filter) { return false; } if (!q) return true; const hay = `${p.name} ${p.description ?? ""} ${p.term ?? ""}`.toLowerCase(); return hay.includes(q); }); } export function countAdminPlansByVisibility(plans: AdminBillingPlan[]): Record { const counts: Record = { all: plans.length, catalog: 0, public: 0, legacy: 0, custom: 0, hidden: 0 }; for (const p of plans) { const kind = classifyAdminPlanVisibility(p); counts[kind] += 1; if (kind !== "hidden") counts.catalog += 1; } return counts; } export function maxProductsLabel(plan: Pick): string { if (plan.max_products == null) return i18n.t("admin.plans.unlimited"); return formatCredits(Number(plan.max_products)); } export function planOptionLabel(plan: AdminBillingPlan): string { const credits = formatCredits(Number(plan.monthly_credits ?? 0)); const kind = adminPlanVisibilityLabel(classifyAdminPlanVisibility(plan)); return i18n.t("admin.plans.optionLabel", { name: plan.name, credits, kind }); } export type UpsertAdminPlanInput = { id?: number; name: string; description?: string | null; monthly_credits: number; yearly_credits?: number | null; max_products?: number | null; is_custom: boolean; term?: string; }; export async function upsertAdminPlan(input: UpsertAdminPlanInput): Promise { const body: Record = { name: input.name.trim(), monthly_credits: Number(input.monthly_credits), is_custom: Boolean(input.is_custom), term: (input.term || "monthly").trim() || "monthly" }; if (input.id != null && input.id > 0) body.id = input.id; const desc = input.description == null ? "" : String(input.description).trim(); body.description = desc === "" ? null : desc; // Always send nullable caps so edits can clear yearly / max_products back to unlimited. body.yearly_credits = input.yearly_credits != null && Number.isFinite(Number(input.yearly_credits)) ? Number(input.yearly_credits) : null; body.max_products = input.max_products != null && Number.isFinite(Number(input.max_products)) ? Number(input.max_products) : null; return api(ADMIN_PLANS_PATH, { method: "POST", body }); } export async function assignAdminPlan(opts: { company_id: string; plan_id: number; is_trial?: boolean; trial_credits?: number; }): Promise { await api(ADMIN_ASSIGN_PLAN_PATH, { method: "POST", body: { company_id: opts.company_id, plan_id: opts.plan_id, is_trial: Boolean(opts.is_trial), trial_credits: Number(opts.trial_credits ?? 0) } }); }