Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
315 lines
10 KiB
TypeScript
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)
|
|
}
|
|
});
|
|
}
|