Files
descrybe/apps/web/src/lib/admin-billing-plans.ts
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

315 lines
10 KiB
TypeScript

/**
* 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<AdminBillingPlan[]> | null = null;
/** Shared in-flight GET so billing cold load / remount races do not double-fetch companies. */
let adminCompaniesListInflight: Promise<AdminBillingCompany[]> | 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<string, boolean>;
resolved_features?: Record<string, boolean>;
};
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<AdminBillingPlan[]> {
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<AdminBillingCompany[]> {
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<AdminBillingPlan, "name" | "is_custom"> | 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<AdminPlanFilter, number> {
const counts: Record<AdminPlanFilter, number> = {
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<AdminBillingPlan, "max_products">): 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<AdminBillingPlan> {
const body: Record<string, unknown> = {
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<AdminBillingPlan>(ADMIN_PLANS_PATH, { method: "POST", body });
}
export async function assignAdminPlan(opts: {
company_id: string;
plan_id: number;
is_trial?: boolean;
trial_credits?: number;
}): Promise<void> {
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)
}
});
}