import { orderConfigurationsSchema, orderSelectionsSchema, type CheckoutTotals, type OrderConfigurations, type OrderSelections, type OrderItemSummary, type OrderDisplayItem, type OrderDisplayItemCategory, type OrderDisplayItemCharge, type OrderDisplayItemChargeKind, } from "./schema.js"; import { ORDER_TYPE } from "./contract.js"; import type { SimConfigureFormData } from "../sim/index.js"; import type { WhmcsOrderItem } from "./providers/whmcs/raw.types.js"; export interface BuildSimOrderConfigurationsOptions { /** * Optional fallback phone number when the SIM form does not include it directly. * Useful for flows where the phone number is collected separately. */ phoneNumber?: string | null | undefined; } const normalizeString = (value: unknown): string | undefined => { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; }; const normalizeDate = (value: unknown): string | undefined => { const str = normalizeString(value); if (!str) return undefined; return str.replace(/-/g, ""); }; type BillingCycle = WhmcsOrderItem["billingCycle"]; const BILLING_CYCLE_ALIASES: Record = { monthly: "monthly", month: "monthly", onetime: "onetime", once: "onetime", singlepayment: "onetime", annual: "annually", annually: "annually", yearly: "annually", year: "annually", quarterly: "quarterly", quarter: "quarterly", qtr: "quarterly", semiannual: "semiannually", semiannually: "semiannually", semiannualy: "semiannually", semiannualpayment: "semiannually", semiannualbilling: "semiannually", biannual: "semiannually", biannually: "semiannually", biennial: "biennially", biennially: "biennially", triennial: "triennially", triennially: "triennially", free: "free", }; const normalizeBillingCycleKey = (value: string): string => value .trim() .toLowerCase() .replace(/[\s_-]+/g, ""); const DEFAULT_BILLING_CYCLE: BillingCycle = "monthly"; export interface NormalizeBillingCycleOptions { defaultValue?: BillingCycle; } /** * Normalize arbitrary billing cycle strings to the canonical WHMCS values. * Keeps mapping logic in the domain so both BFF and UI stay in sync. */ export function normalizeBillingCycle( value: unknown, options: NormalizeBillingCycleOptions = {} ): BillingCycle { if (typeof value !== "string") { return options.defaultValue ?? DEFAULT_BILLING_CYCLE; } const directKey = normalizeBillingCycleKey(value); const matched = BILLING_CYCLE_ALIASES[directKey]; if (matched) { return matched; } return options.defaultValue ?? DEFAULT_BILLING_CYCLE; } /** * Build an OrderConfigurations object for SIM orders from the shared SimConfigureFormData. * Ensures the resulting payload conforms to the domain schema before it is sent to the BFF. */ export function buildSimOrderConfigurations( formData: SimConfigureFormData, options: BuildSimOrderConfigurationsOptions = {} ): OrderConfigurations { const base: Record = { simType: formData.simType, activationType: formData.activationType, }; const eid = normalizeString(formData.eid); if (formData.simType === "eSIM" && eid) { base["eid"] = eid; } const scheduledDate = normalizeDate(formData.scheduledActivationDate); if (formData.activationType === "Scheduled" && scheduledDate) { base["scheduledAt"] = scheduledDate; } const phoneCandidate = normalizeString(formData.mnpData?.phoneNumber) ?? normalizeString(options.phoneNumber); if (phoneCandidate) { base["mnpPhone"] = phoneCandidate; } if (formData.wantsMnp && formData.mnpData) { const mnp = formData.mnpData; base["isMnp"] = "true"; base["mnpNumber"] = normalizeString(mnp.reservationNumber); base["mnpExpiry"] = normalizeDate(mnp.expiryDate); base["mvnoAccountNumber"] = normalizeString(mnp.mvnoAccountNumber); base["portingLastName"] = normalizeString(mnp.portingLastName); base["portingFirstName"] = normalizeString(mnp.portingFirstName); base["portingLastNameKatakana"] = normalizeString(mnp.portingLastNameKatakana); base["portingFirstNameKatakana"] = normalizeString(mnp.portingFirstNameKatakana); base["portingGender"] = normalizeString(mnp.portingGender); base["portingDateOfBirth"] = normalizeDate(mnp.portingDateOfBirth); } else if (formData.wantsMnp) { // When wantsMnp is true but data is missing, mark the flag so validation fails clearly downstream. base["isMnp"] = "true"; } return orderConfigurationsSchema.parse(base); } export function normalizeOrderSelections(value: unknown): OrderSelections { return orderSelectionsSchema.parse(value); } export type OrderStatusTone = "success" | "info" | "warning" | "neutral"; export type OrderStatusState = "active" | "review" | "scheduled" | "activating" | "processing"; export interface OrderStatusDescriptor { label: string; state: OrderStatusState; tone: OrderStatusTone; description: string; nextAction?: string | undefined; timeline?: string | undefined; scheduledDate?: string | undefined; } export interface OrderStatusInput { status?: string; activationStatus?: string; scheduledAt?: string; } /** * Convert backend activation status into a UI-ready descriptor so messaging stays consistent. */ export function deriveOrderStatusDescriptor({ status, activationStatus, scheduledAt, }: OrderStatusInput): OrderStatusDescriptor { if (activationStatus === "Activated") { return { label: "Service Active", state: "active", tone: "success", description: "Your service is active and ready to use", timeline: "Service activated successfully", }; } if (status === "Draft" || status === "Pending Review") { return { label: "Under Review", state: "review", tone: "info", description: "Our team is reviewing your order details", nextAction: "We will contact you within 1 business day with next steps", timeline: "Review typically takes 1 business day", }; } if (activationStatus === "Scheduled") { const scheduledDate = formatScheduledDate(scheduledAt); return { label: "Installation Scheduled", state: "scheduled", tone: "warning", description: "Your installation has been scheduled", nextAction: scheduledDate ? `Installation scheduled for ${scheduledDate}` : "Installation will be scheduled shortly", timeline: "Please be available during the scheduled time", scheduledDate, }; } if (activationStatus === "Activating") { return { label: "Setting Up Service", state: "activating", tone: "info", description: "We're configuring your service", nextAction: "Installation team will contact you to schedule", timeline: "Setup typically takes 3-5 business days", }; } return { label: status || "Processing", state: "processing", tone: "neutral", description: "Your order is being processed", timeline: "We will update you as progress is made", }; } export type OrderServiceCategory = "internet" | "sim" | "vpn" | "default"; /** * Normalize order type into a UI category identifier. */ export function getOrderServiceCategory(orderType?: string): OrderServiceCategory { switch (orderType) { case ORDER_TYPE.INTERNET: return "internet"; case ORDER_TYPE.SIM: return "sim"; case ORDER_TYPE.VPN: return "vpn"; default: return "default"; } } export interface OrderTotalsInputItem { totalPrice?: number | null | undefined; billingCycle?: string | null | undefined; } /** * Aggregate order item totals by billing cadence using shared normalization rules. */ export function calculateOrderTotals( items?: OrderTotalsInputItem[] | null, fallbackTotal?: number | null ): CheckoutTotals { let monthlyTotal = 0; let oneTimeTotal = 0; if (Array.isArray(items) && items.length > 0) { for (const item of items) { const total = typeof item?.totalPrice === "number" ? item.totalPrice : 0; const billingCycle = normalizeBillingCycle(item?.billingCycle); switch (billingCycle) { case "monthly": monthlyTotal += total; break; case "onetime": case "free": oneTimeTotal += total; break; default: monthlyTotal += total; } } } else if (typeof fallbackTotal === "number") { monthlyTotal = fallbackTotal; } return { monthlyTotal, oneTimeTotal, }; } /** * Format scheduled activation dates consistently for display. */ export function formatScheduledDate(scheduledAt?: string | null): string | undefined { if (!scheduledAt) return undefined; const date = new Date(scheduledAt); if (Number.isNaN(date.getTime())) return undefined; return date.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", }); } /** * Categorize order item by itemClass */ export function categorizeOrderItem(itemClass?: string | null): OrderDisplayItemCategory { const normalized = (itemClass ?? "").toLowerCase(); if (normalized.includes("service")) return "service"; if (normalized.includes("installation")) return "installation"; if (normalized.includes("activation")) return "activation"; if (normalized.includes("add-on") || normalized.includes("addon")) return "addon"; return "other"; } /** * Build display items from order item summaries */ export function buildOrderDisplayItems( items: OrderItemSummary[] | null | undefined ): OrderDisplayItem[] { if (!Array.isArray(items) || items.length === 0) { return []; } return items.map((item, index) => { const category = categorizeOrderItem(item.itemClass); const charges = aggregateCharges([item]); return { id: item.productId || item.sku || `order-item-${index}`, name: item.productName || item.name || "Service item", quantity: item.quantity ?? undefined, status: item.status ?? undefined, primaryCategory: category, categories: [category], charges, included: charges.every(charge => charge.amount <= 0), sourceItems: [item], isBundle: Boolean(item.isBundledAddon), }; }); } function aggregateCharges(items: OrderItemSummary[]): OrderDisplayItemCharge[] { const accumulator = new Map< string, OrderDisplayItemCharge & { kind: OrderDisplayItemChargeKind } >(); const CHARGE_ORDER: Record = { monthly: 0, "one-time": 1, other: 2, }; for (const item of items) { const amount = Number(item.totalPrice ?? item.unitPrice ?? 0); const normalizedCycle = normalizeBillingCycle(item.billingCycle ?? undefined); let kind: OrderDisplayItemChargeKind = "other"; let key = "other"; let label = item.billingCycle?.trim() || "Billing"; let suffix: string | undefined; if (normalizedCycle === "monthly") { kind = "monthly"; key = "monthly"; label = "Monthly"; suffix = "/ month"; } else if (normalizedCycle === "onetime" || normalizedCycle === "free") { kind = "one-time"; key = "one-time"; label = "One-time"; } else if (typeof item.billingCycle === "string" && item.billingCycle.length > 0) { key = `other:${item.billingCycle.toLowerCase()}`; } const existing = accumulator.get(key); if (existing) { existing.amount += amount; } else { accumulator.set(key, { kind, amount, label, suffix }); } } return [...accumulator.values()] .map(({ kind, amount, label, suffix }) => ({ kind, amount, label, suffix })) .sort((a, b) => { const orderDiff = CHARGE_ORDER[a.kind] - CHARGE_ORDER[b.kind]; if (orderDiff !== 0) return orderDiff; return a.label.localeCompare(b.label); }); }