2025-10-27 15:47:50 +09:00
|
|
|
import {
|
|
|
|
|
orderConfigurationsSchema,
|
|
|
|
|
orderSelectionsSchema,
|
|
|
|
|
type OrderConfigurations,
|
|
|
|
|
type OrderSelections,
|
2025-11-18 11:14:05 +09:00
|
|
|
type OrderItemSummary,
|
|
|
|
|
type OrderDisplayItem,
|
|
|
|
|
type OrderDisplayItemCategory,
|
|
|
|
|
type OrderDisplayItemCharge,
|
|
|
|
|
type OrderDisplayItemChargeKind,
|
2025-12-10 15:22:10 +09:00
|
|
|
} from "./schema.js";
|
|
|
|
|
import { ORDER_TYPE } from "./contract.js";
|
|
|
|
|
import type { CheckoutTotals } from "./contract.js";
|
|
|
|
|
import type { SimConfigureFormData } from "../sim/index.js";
|
|
|
|
|
import type { WhmcsOrderItem } from "./providers/whmcs/raw.types.js";
|
2025-10-21 11:44:06 +09:00
|
|
|
|
|
|
|
|
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, "");
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-04 11:14:26 +09:00
|
|
|
type BillingCycle = WhmcsOrderItem["billingCycle"];
|
|
|
|
|
|
|
|
|
|
const BILLING_CYCLE_ALIASES: Record<string, BillingCycle> = {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-21 11:44:06 +09:00
|
|
|
/**
|
|
|
|
|
* 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<string, unknown> = {
|
|
|
|
|
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);
|
|
|
|
|
}
|
2025-10-27 15:47:50 +09:00
|
|
|
|
|
|
|
|
export function normalizeOrderSelections(value: unknown): OrderSelections {
|
|
|
|
|
return orderSelectionsSchema.parse(value);
|
|
|
|
|
}
|
2025-11-04 11:26:21 +09:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
timeline?: string;
|
|
|
|
|
scheduledDate?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-11-18 11:14:05 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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<OrderDisplayItemChargeKind, number> = {
|
|
|
|
|
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 Array.from(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);
|
|
|
|
|
});
|
|
|
|
|
}
|