Menu
Public repository / c892c7f938287f874e707a87eb4295a02e86247e
AkurAI-Platform / payment/core.ts
Latest change c892c7f938287f874e707a87eb4295a02e86247e - feat: generate landing page cards from single product catalog (AKURAI-PLATFORM-27) by Ólafur Búi Ólafsson
export type ProductVisibility = "visible" | "dev" | "hidden";
export type FulfillmentTarget = "provisioned" | "external" | "manual";
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;
}
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 (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.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);
return {
productIds,
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): Date {
if (!Number.isFinite(from.getTime())) throw new Error("Invalid billing date");
const year = from.getUTCFullYear();
const month = from.getUTCMonth();
const day = from.getUTCDate();
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(day, 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;
}