AkurAI Build
Menu

Public repository / 7c2481f3029b7b2f8b01818af3d8e5fa55a3ea0b

AkurAI-Platform / payment/core.ts

Latest change d11e160f26a6dfe754f18d1060107de9d6b6b898 - feat: complete managed platform control plane (AKURAI-PLATFORM-10, AKURAI-PLATFORM-11, AKURAI-PLATFORM-12, AKURAI-PLATFORM-13, AKURAI-PLATFORM-14, AKURAI-PLATFORM-16, AKURAI-PLATFORM-18) by Ólafur Búi Ólafsson

export type ProductVisibility = "visible" | "dev" | "hidden";
export type FulfillmentTarget = "provisioned" | "external" | "manual";

export interface ManagedWorkspacePlan {
  price: { monthlyCents: number; currency: "EUR"; annualDiscountPercent: number };
  includedSeats: number;
  storageGb: number;
  limits: { workspaces: number; apps: number; customDomains: number };
  supportLevel: "standard";
  hosting: "akurai-managed";
  isolation: "organization-isolated";
  fulfillment: "provisioned";
  migration: string;
}

export interface Product {
  id: string;
  nameKey: string;
  kindKey: string;
  descriptionKey: string;
  /** English display text for the kind/tagline */
  kind: string;
  /** English display text for the value proposition */
  description: string;
  category: string;
  visibility: ProductVisibility;
  purchasable: boolean;
  name: string;
  available: boolean;
  url: string | null;
  buildRepo: string;
  /** Optional second AkurAI Build repository */
  secondaryBuildRepo: string | null;
  /** Locale key for action button label */
  actionKey: string | null;
  bundle: string[];
  fulfillment: FulfillmentTarget | null;
  managedWorkspacePlan?: ManagedWorkspacePlan;
}

export interface Catalog {
  currency: "EUR";
  monthly_unit_cents: number;
  annual_discount_percent: number;
  products: Product[];
}
export type BillingInterval = "monthly" | "annual";

export interface CustomerInput {
  name: string;
  email: string;
  company?: string;
}

export interface CheckoutInput {
  productIds: string[];
  interval: BillingInterval;
  customer: CustomerInput;
}

export interface CheckoutQuote {
  productIds: string[];
  products: Product[];
  interval: BillingInterval;
  unitCents: number;
  amountCents: number;
  currency: "EUR";
  customer: CustomerInput;
}

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export function validateCatalog(value: unknown): Catalog {
  if (!value || typeof value !== "object") throw new Error("Invalid subscription catalog");
  const raw = value as Partial<Catalog>;
  if (raw.currency !== "EUR") throw new Error("Subscription currency must be EUR");
  if (!Number.isInteger(raw.monthly_unit_cents) || Number(raw.monthly_unit_cents) <= 0) {
    throw new Error("Invalid monthly unit price");
  }
  if (!Number.isInteger(raw.annual_discount_percent) || Number(raw.annual_discount_percent) < 0 || Number(raw.annual_discount_percent) >= 100) {
    throw new Error("Invalid annual discount");
  }
  if (!Array.isArray(raw.products) || raw.products.length === 0) throw new Error("Subscription catalog is empty");
  const ids = new Set<string>();
  for (const product of raw.products) {
    if (!product || typeof product.id !== "string" || !/^[a-z][a-z0-9-]*$/.test(product.id)) throw new Error("Invalid product id");
    if (ids.has(product.id)) throw new Error(`Duplicate product id: ${product.id}`);
    if (product.id === "managed-workspace") {
      const plan = product.managedWorkspacePlan;
      if (!plan || plan.price.currency !== raw.currency || plan.price.monthlyCents !== raw.monthly_unit_cents
        || plan.price.annualDiscountPercent !== raw.annual_discount_percent || plan.fulfillment !== product.fulfillment
        || !Number.isInteger(plan.price.monthlyCents) || !Number.isInteger(plan.price.annualDiscountPercent)
        || !Number.isInteger(plan.includedSeats) || !Number.isInteger(plan.storageGb) || !plan.migration.trim()) {
      }
    }
    if (typeof product.name !== "string" || !product.name.trim() || typeof product.category !== "string" || !product.category.trim()) {
      throw new Error(`Invalid product metadata: ${product.id}`);
    }
    ids.add(product.id);
  }
  return raw as Catalog;
}

export function quoteCheckout(catalog: Catalog, value: unknown): CheckoutQuote {
  if (!value || typeof value !== "object") throw new Error("Checkout details are required");
  const input = value as Partial<CheckoutInput>;
  if (input.interval !== "monthly" && input.interval !== "annual") throw new Error("Choose monthly or annual billing");
  if (!Array.isArray(input.productIds) || input.productIds.length === 0) throw new Error("Choose at least one app");
  if (input.productIds.length > catalog.products.length) throw new Error("Too many selected apps");

  const productById = new Map(catalog.products.map((product) => [product.id, product]));
  const productIds = [...new Set(input.productIds)];
  if (productIds.length !== input.productIds.length || productIds.some((id) => typeof id !== "string")) {
    throw new Error("Selected apps must be unique");
  }
  const products = productIds.map((id) => productById.get(id));
  if (products.some((product) => !product || !product.purchasable || product.available === false)) throw new Error("One or more selected apps are unavailable");

  const customer = input.customer;
  if (!customer || typeof customer !== "object") throw new Error("Customer details are required");
  const name = clean(customer.name, 120, "Name");
  const email = clean(customer.email, 254, "Email").toLowerCase();
  if (!EMAIL.test(email)) throw new Error("Enter a valid email address");
  const company = typeof customer.company === "string" ? customer.company.trim() : "";
  if (company.length > 160) throw new Error("Company name is too long");

  const unitCents = input.interval === "monthly"
    ? catalog.monthly_unit_cents
    : Math.round(catalog.monthly_unit_cents * 12 * (100 - catalog.annual_discount_percent) / 100);

  const fulfillmentProductIds = [...new Set(products.flatMap((product) => product!.bundle.length ? product!.bundle : [product!.id]))];
  return {
    productIds: fulfillmentProductIds,
    products: products as Product[],
    interval: input.interval,
    unitCents,
    amountCents: unitCents * productIds.length,
    currency: catalog.currency,
    customer: { name, email, ...(company ? { company } : {}) },
  };
}

export function nextBillingDate(from: Date, interval: BillingInterval, anchorDay = from.getUTCDate()): Date {
  if (!Number.isFinite(from.getTime()) || !Number.isInteger(anchorDay) || anchorDay < 1 || anchorDay > 31) throw new Error("Invalid billing date");
  const year = from.getUTCFullYear();
  const month = from.getUTCMonth();
  const targetMonth = interval === "monthly" ? month + 1 : month + 12;
  const lastDay = new Date(Date.UTC(year, targetMonth + 1, 0)).getUTCDate();
  const next = new Date(from);
  next.setUTCFullYear(year, targetMonth, Math.min(anchorDay, lastDay));
  return next;
}

export function money(cents: number, currency = "EUR"): string {
  return new Intl.NumberFormat("en", { style: "currency", currency }).format(cents / 100);
}

export function maskEmail(email: string): string {
  const at = email.indexOf("@");
  if (at < 1) return "***";
  return `${email[0]}***${email.slice(at)}`;
}

function clean(value: unknown, max: number, label: string): string {
  if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required`);
  const cleaned = value.trim();
  if (cleaned.length > max) throw new Error(`${label} is too long`);
  return cleaned;
}