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.
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user