-
- {item.name}
-
+
{item.name}
{item.sku && (
SKU {item.sku}
diff --git a/apps/portal/src/features/orders/views/OrdersList.tsx b/apps/portal/src/features/orders/views/OrdersList.tsx
index 9feb6a4f..7d47d1ed 100644
--- a/apps/portal/src/features/orders/views/OrdersList.tsx
+++ b/apps/portal/src/features/orders/views/OrdersList.tsx
@@ -36,14 +36,7 @@ function OrdersSuccessBanner() {
export function OrdersListContainer() {
const router = useRouter();
- const {
- data: orders = [],
- isLoading,
- isError,
- error,
- refetch,
- isFetching,
- } = useOrdersList();
+ const { data: orders = [], isLoading, isError, error, refetch, isFetching } = useOrdersList();
const { errorMessage, showRetry } = useMemo(() => {
if (!isError) {
@@ -82,7 +75,7 @@ export function OrdersListContainer() {
{showRetry && (
diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx
index 778d8d6c..28c0cd8a 100644
--- a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx
+++ b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx
@@ -9,11 +9,7 @@ import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFi
import { LoadingTable } from "@/components/atoms/loading-skeleton";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { SubscriptionTable } from "@/features/subscriptions/components/SubscriptionTable";
-import {
- ServerIcon,
- CheckCircleIcon,
- XCircleIcon,
-} from "@heroicons/react/24/outline";
+import { ServerIcon, CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/outline";
import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks";
import type { Subscription } from "@customer-portal/domain/subscriptions";
diff --git a/packages/domain/auth/helpers.ts b/packages/domain/auth/helpers.ts
new file mode 100644
index 00000000..23cc0351
--- /dev/null
+++ b/packages/domain/auth/helpers.ts
@@ -0,0 +1,14 @@
+import type { z } from "zod";
+
+import { signupRequestSchema } from "./schema";
+
+type SignupRequestInput = z.input;
+
+/**
+ * Convert signup form input into the canonical API payload.
+ * Reuses domain schema transformation to avoid frontend duplication.
+ */
+export function buildSignupRequest(input: SignupRequestInput) {
+ return signupRequestSchema.parse(input);
+}
+
diff --git a/packages/domain/auth/index.ts b/packages/domain/auth/index.ts
index 652b1cf8..734a7825 100644
--- a/packages/domain/auth/index.ts
+++ b/packages/domain/auth/index.ts
@@ -82,3 +82,5 @@ export {
ssoLinkResponseSchema,
checkPasswordNeededResponseSchema,
} from "./schema";
+
+export { buildSignupRequest } from "./helpers";
diff --git a/packages/domain/customer/schema.ts b/packages/domain/customer/schema.ts
index 8ff6024c..711fef23 100644
--- a/packages/domain/customer/schema.ts
+++ b/packages/domain/customer/schema.ts
@@ -13,6 +13,7 @@
import { z } from "zod";
import { countryCodeSchema } from "../common/schema";
+import { whmcsClientSchema as whmcsRawClientSchema, whmcsCustomFieldSchema } from "./providers/whmcs/raw.types";
// ============================================================================
// Helper Schemas
@@ -166,16 +167,7 @@ const statsSchema = z.record(
z.union([z.string(), z.number(), z.boolean()])
).optional();
-const whmcsRawCustomFieldSchema = z
- .object({
- id: numberLike.optional(),
- value: z.string().optional().nullable(),
- name: z.string().optional(),
- type: z.string().optional(),
- })
- .passthrough();
-
-const whmcsRawCustomFieldsArraySchema = z.array(whmcsRawCustomFieldSchema);
+const whmcsRawCustomFieldsArraySchema = z.array(whmcsCustomFieldSchema);
const whmcsCustomFieldsSchema = z
.union([
@@ -184,7 +176,7 @@ const whmcsCustomFieldsSchema = z
z
.object({
customfield: z
- .union([whmcsRawCustomFieldSchema, whmcsRawCustomFieldsArraySchema])
+ .union([whmcsCustomFieldSchema, whmcsRawCustomFieldsArraySchema])
.optional(),
})
.passthrough(),
@@ -214,54 +206,69 @@ const whmcsUsersSchema = z
* - Preferences (email_preferences, allowSingleSignOn)
* - Relations (users, stats, customfields)
*/
-export const whmcsClientSchema = z.object({
- id: numberLike,
- email: z.string(),
-
- // Profile (raw WHMCS field names)
- firstname: z.string().nullable().optional(),
- lastname: z.string().nullable().optional(),
- fullname: z.string().nullable().optional(),
- companyname: z.string().nullable().optional(),
- phonenumber: z.string().nullable().optional(),
- phonenumberformatted: z.string().nullable().optional(),
- telephoneNumber: z.string().nullable().optional(),
-
- // Billing & Payment (raw WHMCS field names)
- status: z.string().nullable().optional(),
- language: z.string().nullable().optional(),
- defaultgateway: z.string().nullable().optional(),
- defaultpaymethodid: numberLike.nullable().optional(),
- currency: numberLike.nullable().optional(),
- currency_code: z.string().nullable().optional(), // snake_case from WHMCS
- tax_id: z.string().nullable().optional(),
-
- // Preferences (raw WHMCS field names)
- allowSingleSignOn: booleanLike.nullable().optional(),
- email_verified: booleanLike.nullable().optional(), // snake_case from WHMCS
- marketing_emails_opt_in: booleanLike.nullable().optional(), // snake_case from WHMCS
-
- // Metadata (raw WHMCS field names)
- notes: z.string().nullable().optional(),
- datecreated: z.string().nullable().optional(),
- lastlogin: z.string().nullable().optional(),
-
- // Relations
- address: addressSchema.nullable().optional(),
- email_preferences: emailPreferencesSchema.nullable().optional(), // snake_case from WHMCS
- customfields: whmcsCustomFieldsSchema,
- users: whmcsUsersSchema,
- stats: statsSchema.optional(),
-}).transform(data => ({
- ...data,
- // Normalize types only, keep field names as-is
- id: typeof data.id === 'number' ? data.id : Number(data.id),
- allowSingleSignOn: normalizeBoolean(data.allowSingleSignOn),
- email_verified: normalizeBoolean(data.email_verified),
- marketing_emails_opt_in: normalizeBoolean(data.marketing_emails_opt_in),
- defaultpaymethodid: data.defaultpaymethodid ? Number(data.defaultpaymethodid) : null,
- currency: data.currency ? Number(data.currency) : null,
-}));
+const nullableProfileFields = [
+ "firstname",
+ "lastname",
+ "fullname",
+ "companyname",
+ "phonenumber",
+ "phonenumberformatted",
+ "telephoneNumber",
+ "status",
+ "language",
+ "defaultgateway",
+ "currency_code",
+ "tax_id",
+ "notes",
+ "datecreated",
+ "lastlogin",
+] as const;
+
+type NullableProfileKey = (typeof nullableProfileFields)[number];
+
+const nullableProfileOverrides = nullableProfileFields.reduce>(
+ (acc, field) => {
+ acc[field] = z.string().nullable().optional();
+ return acc;
+ },
+ {}
+);
+
+export const whmcsClientSchema = whmcsRawClientSchema
+ .extend({
+ ...nullableProfileOverrides,
+ // Allow nullable numeric strings
+ defaultpaymethodid: numberLike.nullable().optional(),
+ currency: numberLike.nullable().optional(),
+ allowSingleSignOn: booleanLike.nullable().optional(),
+ email_verified: booleanLike.nullable().optional(),
+ marketing_emails_opt_in: booleanLike.nullable().optional(),
+ address: addressSchema.nullable().optional(),
+ email_preferences: emailPreferencesSchema.nullable().optional(),
+ customfields: whmcsCustomFieldsSchema,
+ users: whmcsUsersSchema,
+ stats: statsSchema.optional(),
+ })
+ .transform(raw => {
+ const coerceNumber = (value: unknown) =>
+ value === null || value === undefined ? null : Number(value);
+
+ const coerceOptionalNumber = (value: unknown) =>
+ value === null || value === undefined ? undefined : Number(value);
+
+ return {
+ ...raw,
+ id: Number(raw.id),
+ client_id: coerceOptionalNumber((raw as Record).client_id),
+ owner_user_id: coerceOptionalNumber((raw as Record).owner_user_id),
+ userid: coerceOptionalNumber((raw as Record).userid),
+ allowSingleSignOn: normalizeBoolean(raw.allowSingleSignOn),
+ email_verified: normalizeBoolean(raw.email_verified),
+ marketing_emails_opt_in: normalizeBoolean(raw.marketing_emails_opt_in),
+ defaultpaymethodid: coerceNumber(raw.defaultpaymethodid),
+ currency: coerceNumber(raw.currency),
+ };
+ });
// ============================================================================
// User Schema (API Response - Normalized camelCase)
diff --git a/packages/domain/orders/helpers.ts b/packages/domain/orders/helpers.ts
index 3a2b0d2e..ab30a2e6 100644
--- a/packages/domain/orders/helpers.ts
+++ b/packages/domain/orders/helpers.ts
@@ -5,6 +5,7 @@ import {
type OrderSelections,
} from "./schema";
import type { SimConfigureFormData } from "../sim";
+import type { WhmcsOrderItem } from "./providers/whmcs/raw.types";
export interface BuildSimOrderConfigurationsOptions {
/**
@@ -26,6 +27,65 @@ const normalizeDate = (value: unknown): string | 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.
diff --git a/packages/domain/orders/index.ts b/packages/domain/orders/index.ts
index a030d18a..5c25a53c 100644
--- a/packages/domain/orders/index.ts
+++ b/packages/domain/orders/index.ts
@@ -42,6 +42,7 @@ export * from "./validation";
export * from "./utils";
export {
buildSimOrderConfigurations,
+ normalizeBillingCycle,
normalizeOrderSelections,
type BuildSimOrderConfigurationsOptions,
} from "./helpers";
diff --git a/packages/domain/orders/providers/salesforce/mapper.ts b/packages/domain/orders/providers/salesforce/mapper.ts
index f8ab535d..3bdf8e7e 100644
--- a/packages/domain/orders/providers/salesforce/mapper.ts
+++ b/packages/domain/orders/providers/salesforce/mapper.ts
@@ -10,6 +10,7 @@ import type {
OrderItemSummary,
OrderSummary,
} from "../../contract";
+import { normalizeBillingCycle } from "../../helpers";
import { orderDetailsSchema, orderSummarySchema, orderItemDetailsSchema } from "../../schema";
import type {
SalesforceOrderItemRecord,
@@ -26,7 +27,10 @@ export function transformSalesforceOrderItem(
const pricebookEntry = record.PricebookEntry as Record | null | undefined;
const product = pricebookEntry?.Product2 as Record | undefined;
const productBillingCycle = product?.Billing_Cycle__c ?? undefined;
- const billingCycle = record.Billing_Cycle__c ?? productBillingCycle ?? undefined;
+ const billingCycleRaw = record.Billing_Cycle__c ?? productBillingCycle ?? undefined;
+ const billingCycle = billingCycleRaw
+ ? normalizeBillingCycle(billingCycleRaw)
+ : undefined;
const details = orderItemDetailsSchema.parse({
id: record.Id,
diff --git a/packages/domain/orders/providers/whmcs/mapper.ts b/packages/domain/orders/providers/whmcs/mapper.ts
index 4157034a..1eb5b4f1 100644
--- a/packages/domain/orders/providers/whmcs/mapper.ts
+++ b/packages/domain/orders/providers/whmcs/mapper.ts
@@ -5,6 +5,7 @@
*/
import type { OrderDetails, OrderItemDetails } from "../../contract";
+import { normalizeBillingCycle } from "../../helpers";
import {
type WhmcsOrderItem,
type WhmcsAddOrderParams,
@@ -20,18 +21,6 @@ export interface OrderItemMappingResult {
};
}
-function normalizeBillingCycle(cycle: string | undefined): WhmcsOrderItem["billingCycle"] {
- if (!cycle) return "monthly"; // Default
-
- const normalized = cycle.trim().toLowerCase();
- if (normalized.includes("monthly")) return "monthly";
- if (normalized.includes("one")) return "onetime";
- if (normalized.includes("annual")) return "annually";
- if (normalized.includes("quarter")) return "quarterly";
- // Default to monthly if unrecognized
- return "monthly";
-}
-
/**
* Map a single order item to WHMCS format
*/