Refactor WHMCS client handling to improve type safety and address management. Updated methods in WhmcsService and WhmcsClientService to return NormalizedWhmcsClient type, enhancing consistency. Simplified address retrieval logic and improved caching mechanisms in WhmcsCacheService. Additionally, introduced utility functions for custom field handling in WHMCS integration workflows, ensuring better data management and clarity.

This commit is contained in:
barsa 2025-10-20 16:26:47 +09:00
parent d041831434
commit 8a90003075
8 changed files with 402 additions and 44 deletions

View File

@ -5,7 +5,7 @@ import { CacheService } from "@bff/infra/cache/cache.service";
import { Invoice, InvoiceList } from "@customer-portal/domain/billing"; import { Invoice, InvoiceList } from "@customer-portal/domain/billing";
import { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions"; import { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
import { PaymentMethodList, PaymentGatewayList } from "@customer-portal/domain/payments"; import { PaymentMethodList, PaymentGatewayList } from "@customer-portal/domain/payments";
import { Providers as CustomerProviders } from "@customer-portal/domain/customer"; import type { NormalizedWhmcsClient } from "../utils/whmcs-client.utils";
export interface CacheOptions { export interface CacheOptions {
ttl?: number; ttl?: number;
@ -148,15 +148,15 @@ export class WhmcsCacheService {
* Get cached client data * Get cached client data
* Returns WhmcsClient (type inferred from domain) * Returns WhmcsClient (type inferred from domain)
*/ */
async getClientData(clientId: number) { async getClientData(clientId: number): Promise<NormalizedWhmcsClient | null> {
const key = this.buildClientKey(clientId); const key = this.buildClientKey(clientId);
return this.get(key, "client"); return this.get<NormalizedWhmcsClient>(key, "client");
} }
/** /**
* Cache client data * Cache client data
*/ */
async setClientData(clientId: number, data: ReturnType<typeof CustomerProviders.Whmcs.transformWhmcsClientResponse>) { async setClientData(clientId: number, data: NormalizedWhmcsClient) {
const key = this.buildClientKey(clientId); const key = this.buildClientKey(clientId);
await this.set(key, data, "client", [`client:${clientId}`]); await this.set(key, data, "client", [`client:${clientId}`]);
} }

View File

@ -13,9 +13,9 @@ import type {
WhmcsValidateLoginResponse, WhmcsValidateLoginResponse,
} from "@customer-portal/domain/customer"; } from "@customer-portal/domain/customer";
import { import {
Providers as CustomerProviders, normalizeWhmcsClientResponse,
type Address, type NormalizedWhmcsClient,
} from "@customer-portal/domain/customer"; } from "../utils/whmcs-client.utils";
@Injectable() @Injectable()
export class WhmcsClientService { export class WhmcsClientService {
@ -57,7 +57,7 @@ export class WhmcsClientService {
* Get client details by ID * Get client details by ID
* Returns WhmcsClient (type inferred from domain mapper) * Returns WhmcsClient (type inferred from domain mapper)
*/ */
async getClientDetails(clientId: number) { async getClientDetails(clientId: number): Promise<NormalizedWhmcsClient> {
try { try {
// Try cache first // Try cache first
const cached = await this.cacheService.getClientData(clientId); const cached = await this.cacheService.getClientData(clientId);
@ -76,12 +76,11 @@ export class WhmcsClientService {
throw new NotFoundException(`Client ${clientId} not found`); throw new NotFoundException(`Client ${clientId} not found`);
} }
const customer = CustomerProviders.Whmcs.transformWhmcsClientResponse(response); const normalized = normalizeWhmcsClientResponse(response);
await this.cacheService.setClientData(normalized.id, normalized);
await this.cacheService.setClientData(clientId, customer);
this.logger.log(`Fetched client details for client ${clientId}`); this.logger.log(`Fetched client details for client ${clientId}`);
return customer; return normalized;
} catch (error) { } catch (error) {
this.logger.error(`Failed to fetch client details for client ${clientId}`, { this.logger.error(`Failed to fetch client details for client ${clientId}`, {
error: getErrorMessage(error), error: getErrorMessage(error),
@ -94,7 +93,7 @@ export class WhmcsClientService {
* Get client details by email * Get client details by email
* Returns WhmcsClient (type inferred from domain mapper) * Returns WhmcsClient (type inferred from domain mapper)
*/ */
async getClientDetailsByEmail(email: string) { async getClientDetailsByEmail(email: string): Promise<NormalizedWhmcsClient> {
try { try {
const response = await this.connectionService.getClientDetailsByEmail(email); const response = await this.connectionService.getClientDetailsByEmail(email);
@ -106,12 +105,11 @@ export class WhmcsClientService {
throw new NotFoundException(`Client with email ${email} not found`); throw new NotFoundException(`Client with email ${email} not found`);
} }
const customer = CustomerProviders.Whmcs.transformWhmcsClientResponse(response); const normalized = normalizeWhmcsClientResponse(response);
await this.cacheService.setClientData(normalized.id, normalized);
await this.cacheService.setClientData(customer.id, customer);
this.logger.log(`Fetched client details by email: ${email}`); this.logger.log(`Fetched client details by email: ${email}`);
return customer; return normalized;
} catch (error) { } catch (error) {
this.logger.error(`Failed to fetch client details by email: ${email}`, { this.logger.error(`Failed to fetch client details by email: ${email}`, {
error: getErrorMessage(error), error: getErrorMessage(error),

View File

@ -0,0 +1,286 @@
import {
addressSchema,
userSchema,
type Address,
type User,
type UserAuth,
} from "@customer-portal/domain/customer";
import type {
WhmcsClient,
WhmcsClientResponse,
WhmcsCustomField,
WhmcsUser,
} from "@customer-portal/domain/customer/providers/whmcs/raw.types";
type RawCustomFields = WhmcsClient["customfields"];
type RawUsers = WhmcsClient["users"];
const truthyStrings = new Set(["1", "true", "yes", "y", "on"]);
const falsyStrings = new Set(["0", "false", "no", "n", "off"]);
const toNumber = (value: unknown): number | null => {
if (value === undefined || value === null) return null;
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
};
const toOptionalString = (value: unknown): string | undefined => {
if (value === undefined || value === null) return undefined;
if (typeof value === "string") return value;
return String(value);
};
const toNullableBoolean = (value: unknown): boolean | null | undefined => {
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value === "boolean") return value;
if (typeof value === "number") return value === 0 ? false : true;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (truthyStrings.has(normalized)) return true;
if (falsyStrings.has(normalized)) return false;
}
return undefined;
};
const isCustomFieldObject = (value: unknown): value is WhmcsCustomField =>
typeof value === "object" && value !== null && !Array.isArray(value);
const isRecordOfStrings = (value: unknown): value is Record<string, string> =>
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.values(value).every(v => typeof v === "string");
const normalizeCustomFields = (
raw: RawCustomFields
): Record<string, string> | undefined => {
if (!raw) return undefined;
if (Array.isArray(raw)) {
const map = raw.reduce<Record<string, string>>((acc, field) => {
if (!field) return acc;
const idKey = toOptionalString(field.id)?.trim();
const nameKey = toOptionalString(field.name)?.trim();
const value =
field.value === undefined || field.value === null
? ""
: String(field.value);
if (idKey) acc[idKey] = value;
if (nameKey) acc[nameKey] = value;
return acc;
}, {});
return Object.keys(map).length ? map : undefined;
}
if (isRecordOfStrings(raw)) {
const stringRecord = raw as Record<string, string>;
const map = Object.entries(stringRecord).reduce<Record<string, string>>(
(acc, [key, value]) => {
const trimmedKey = key.trim();
if (!trimmedKey) return acc;
acc[trimmedKey] = value;
return acc;
},
{}
);
return Object.keys(map).length ? map : undefined;
}
if (isCustomFieldObject(raw) && "customfield" in raw) {
const nested = raw.customfield;
if (!nested) return undefined;
if (Array.isArray(nested)) return normalizeCustomFields(nested);
if (isCustomFieldObject(nested)) return normalizeCustomFields([nested]);
}
return undefined;
};
const normalizeUsers = (raw: RawUsers): NormalizedWhmcsUser[] | undefined => {
if (!raw) return undefined;
const rawUsers: WhmcsUser[] = Array.isArray(raw)
? raw
: Array.isArray(raw.user)
? raw.user
: raw.user
? [raw.user]
: [];
if (rawUsers.length === 0) return undefined;
const normalized = rawUsers
.map<NormalizedWhmcsUser | null>(user => {
if (!user) return null;
const id = toNumber(user.id);
const name = toOptionalString(user.name);
const email = toOptionalString(user.email);
if (id === null || !name || !email) return null;
return {
id,
name,
email,
is_owner: toNullableBoolean(user.is_owner) ?? undefined,
};
})
.filter((user): user is NormalizedWhmcsUser => user !== null);
return normalized.length ? normalized : undefined;
};
export interface NormalizedWhmcsUser {
id: number;
name: string;
email: string;
is_owner?: boolean;
}
export interface NormalizedWhmcsClient
extends Omit<
WhmcsClient,
| "id"
| "client_id"
| "owner_user_id"
| "userid"
| "customfields"
| "users"
| "allowSingleSignOn"
| "email_verified"
| "marketing_emails_opt_in"
| "defaultpaymethodid"
| "currency"
> {
id: number;
client_id?: number | null;
owner_user_id?: number | null;
userid?: number | null;
customfields?: Record<string, string>;
users?: NormalizedWhmcsUser[];
allowSingleSignOn?: boolean | null;
email_verified?: boolean | null;
marketing_emails_opt_in?: boolean | null;
defaultpaymethodid: number | null;
currency: number | null;
address?: Address;
raw: WhmcsClient;
}
export const deriveAddressFromClient = (
client: WhmcsClient
): Address | undefined => {
const address = addressSchema.parse({
address1: client.address1 ?? null,
address2: client.address2 ?? null,
city: client.city ?? null,
state: client.fullstate ?? client.state ?? null,
postcode: client.postcode ?? null,
country: client.country ?? null,
countryCode: client.countrycode ?? null,
phoneNumber:
client.phonenumberformatted ?? client.phonenumber ?? client.telephoneNumber ?? null,
phoneCountryCode: client.phonecc ?? null,
});
const hasValues = Object.values(address).some(
value => value !== undefined && value !== null && value !== ""
);
return hasValues ? address : undefined;
};
export const normalizeWhmcsClient = (
rawClient: WhmcsClient
): NormalizedWhmcsClient => {
const id = toNumber(rawClient.id);
if (id === null) {
throw new Error("WHMCS client ID missing or invalid.");
}
const clientId = toNumber(rawClient.client_id);
const ownerUserId = toNumber(rawClient.owner_user_id);
const userId = toNumber(rawClient.userid);
return {
...rawClient,
id,
client_id: clientId,
owner_user_id: ownerUserId,
userid: userId,
customfields: normalizeCustomFields(rawClient.customfields),
users: normalizeUsers(rawClient.users),
allowSingleSignOn: toNullableBoolean(rawClient.allowSingleSignOn) ?? null,
email_verified: toNullableBoolean(rawClient.email_verified) ?? null,
marketing_emails_opt_in:
toNullableBoolean(rawClient.marketing_emails_opt_in) ?? null,
defaultpaymethodid: toNumber(rawClient.defaultpaymethodid),
currency: toNumber(rawClient.currency),
address: deriveAddressFromClient(rawClient),
raw: rawClient,
};
};
export const normalizeWhmcsClientResponse = (
response: WhmcsClientResponse
): NormalizedWhmcsClient => normalizeWhmcsClient(response.client);
export const getCustomFieldValue = (
client: NormalizedWhmcsClient,
key: string | number
): string | undefined => {
if (!client.customfields) return undefined;
const lookupKey = typeof key === "number" ? String(key) : key;
const value = client.customfields[lookupKey];
if (value !== undefined) return value;
if (typeof key === "string") {
const numeric = Number.parseInt(key, 10);
if (!Number.isNaN(numeric)) {
return client.customfields[String(numeric)];
}
}
return undefined;
};
export const buildUserProfile = (
userAuth: UserAuth,
client: NormalizedWhmcsClient
): User => {
const payload = {
id: userAuth.id,
email: userAuth.email,
role: userAuth.role,
emailVerified: userAuth.emailVerified,
mfaEnabled: userAuth.mfaEnabled,
lastLoginAt: userAuth.lastLoginAt,
createdAt: userAuth.createdAt,
updatedAt: userAuth.updatedAt,
firstname: client.firstname ?? null,
lastname: client.lastname ?? null,
fullname: client.fullname ?? null,
companyname: client.companyname ?? null,
phonenumber:
client.phonenumberformatted ??
client.phonenumber ??
client.telephoneNumber ??
null,
language: client.language ?? null,
currency_code: client.currency_code ?? null,
address: client.address ?? undefined,
};
return userSchema.parse(payload);
};
export const getNumericClientId = (
client: NormalizedWhmcsClient
): number => client.id;

View File

@ -26,6 +26,7 @@ import type {
WhmcsCatalogProductListResponse, WhmcsCatalogProductListResponse,
} from "@customer-portal/domain/catalog"; } from "@customer-portal/domain/catalog";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { deriveAddressFromClient, type NormalizedWhmcsClient } from "./utils/whmcs-client.utils";
@Injectable() @Injectable()
export class WhmcsService { export class WhmcsService {
@ -131,7 +132,7 @@ export class WhmcsService {
* Get client details by ID * Get client details by ID
* Returns internal WhmcsClient (type inferred) * Returns internal WhmcsClient (type inferred)
*/ */
async getClientDetails(clientId: number) { async getClientDetails(clientId: number): Promise<NormalizedWhmcsClient> {
return this.clientService.getClientDetails(clientId); return this.clientService.getClientDetails(clientId);
} }
@ -139,7 +140,7 @@ export class WhmcsService {
* Get client details by email * Get client details by email
* Returns internal WhmcsClient (type inferred) * Returns internal WhmcsClient (type inferred)
*/ */
async getClientDetailsByEmail(email: string) { async getClientDetailsByEmail(email: string): Promise<NormalizedWhmcsClient> {
return this.clientService.getClientDetailsByEmail(email); return this.clientService.getClientDetailsByEmail(email);
} }
@ -158,11 +159,7 @@ export class WhmcsService {
*/ */
async getClientAddress(clientId: number): Promise<Address> { async getClientAddress(clientId: number): Promise<Address> {
const customer = await this.clientService.getClientDetails(clientId); const customer = await this.clientService.getClientDetails(clientId);
if (!customer || typeof customer !== 'object') { return customer.address ?? deriveAddressFromClient(customer.raw) ?? ({} as Address);
return {} as Address;
}
const custWithAddress = customer as any;
return (custWithAddress.address || {}) as Address;
} }
async updateClientAddress(clientId: number, address: Partial<Address>): Promise<void> { async updateClientAddress(clientId: number, address: Partial<Address>): Promise<void> {

View File

@ -13,6 +13,7 @@ import { SalesforceService } from "@bff/integrations/salesforce/salesforce.servi
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import { mapPrismaUserToDomain } from "@bff/infra/mappers"; import { mapPrismaUserToDomain } from "@bff/infra/mappers";
import type { User } from "@customer-portal/domain/customer"; import type { User } from "@customer-portal/domain/customer";
import { getCustomFieldValue, getNumericClientId } from "@bff/integrations/whmcs/utils/whmcs-client.utils";
// No direct Customer import - use inferred type from WHMCS service // No direct Customer import - use inferred type from WHMCS service
@Injectable() @Injectable()
@ -63,8 +64,10 @@ export class WhmcsLinkWorkflowService {
throw new UnauthorizedException("Unable to verify account. Please try again later."); throw new UnauthorizedException("Unable to verify account. Please try again later.");
} }
const clientNumericId = getNumericClientId(clientDetails);
try { try {
const existingMapping = await this.mappingsService.findByWhmcsClientId(clientDetails.id); const existingMapping = await this.mappingsService.findByWhmcsClientId(clientNumericId);
if (existingMapping) { if (existingMapping) {
throw new ConflictException("This billing account is already linked. Please sign in."); throw new ConflictException("This billing account is already linked. Please sign in.");
} }
@ -103,8 +106,9 @@ export class WhmcsLinkWorkflowService {
throw new UnauthorizedException("Unable to verify credentials. Please try again later."); throw new UnauthorizedException("Unable to verify credentials. Please try again later.");
} }
const customFields = clientDetails.customfields ?? {}; // Raw WHMCS field name const customerNumber =
const customerNumber = customFields["198"]?.trim() ?? customFields["Customer Number"]?.trim(); getCustomFieldValue(clientDetails, "198")?.trim() ??
getCustomFieldValue(clientDetails, "Customer Number")?.trim();
if (!customerNumber) { if (!customerNumber) {
throw new BadRequestException( throw new BadRequestException(
@ -113,7 +117,7 @@ export class WhmcsLinkWorkflowService {
} }
this.logger.log("Found Customer Number for WHMCS client", { this.logger.log("Found Customer Number for WHMCS client", {
whmcsClientId: clientDetails.id, whmcsClientId: clientNumericId,
hasCustomerNumber: !!customerNumber, hasCustomerNumber: !!customerNumber,
}); });
@ -134,16 +138,22 @@ export class WhmcsLinkWorkflowService {
const createdUser = await this.usersService.create({ const createdUser = await this.usersService.create({
email, email,
passwordHash: null, passwordHash: null,
firstName: clientDetails.firstname || "", firstName: String(clientDetails.firstname ?? ""),
lastName: clientDetails.lastname || "", lastName: String(clientDetails.lastname ?? ""),
company: clientDetails.companyname || "", // Raw WHMCS field name company: String(clientDetails.companyname ?? ""), // Raw WHMCS field name
phone: clientDetails.phonenumberformatted || clientDetails.phonenumber || clientDetails.telephoneNumber || "", // Raw WHMCS field names phone:
String(
clientDetails.phonenumberformatted ??
clientDetails.phonenumber ??
clientDetails.telephoneNumber ??
""
), // Raw WHMCS field names
emailVerified: true, emailVerified: true,
}); });
await this.mappingsService.createMapping({ await this.mappingsService.createMapping({
userId: createdUser.id, userId: createdUser.id,
whmcsClientId: clientDetails.id, whmcsClientId: clientNumericId,
sfAccountId: sfAccount.id, sfAccountId: sfAccount.id,
}); });

View File

@ -9,7 +9,6 @@ import {
type UpdateCustomerProfileRequest, type UpdateCustomerProfileRequest,
} from "@customer-portal/domain/auth"; } from "@customer-portal/domain/auth";
import { import {
combineToUser,
Providers as CustomerProviders, Providers as CustomerProviders,
addressSchema, addressSchema,
type Address, type Address,
@ -21,6 +20,7 @@ import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/d
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { buildUserProfile } from "@bff/integrations/whmcs/utils/whmcs-client.utils";
// Use a subset of PrismaUser for auth-related updates only // Use a subset of PrismaUser for auth-related updates only
type UserUpdateData = Partial< type UserUpdateData = Partial<
@ -137,8 +137,7 @@ export class UsersService {
// Map Prisma user to UserAuth // Map Prisma user to UserAuth
const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user); const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user);
// Domain combines UserAuth + WhmcsClient → User return buildUserProfile(userAuth, whmcsClient);
return combineToUser(userAuth, whmcsClient as CustomerProviders.Whmcs.WhmcsClient);
} catch (error) { } catch (error) {
this.logger.error("Failed to fetch client profile from WHMCS", { this.logger.error("Failed to fetch client profile from WHMCS", {
error: getErrorMessage(error), error: getErrorMessage(error),

View File

@ -5,16 +5,22 @@ const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === "true", enabled: process.env.ANALYZE === "true",
}); });
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
// Enable standalone output only for production deployment // Enable standalone output only for production deployment
output: process.env.NODE_ENV === "production" ? "standalone" : undefined, output: process.env.NODE_ENV === "production" ? "standalone" : undefined,
// Ensure workspace package resolves/transpiles correctly in monorepo // Ensure workspace packages are transpiled correctly
transpilePackages: ["@customer-portal/domain"], transpilePackages: [
experimental: { "@customer-portal/domain",
externalDir: true, "@customer-portal/logging",
}, "@customer-portal/validation",
],
// Tell Next to NOT bundle these server-only libs // Tell Next to NOT bundle these server-only libs
serverExternalPackages: [ serverExternalPackages: [
@ -102,11 +108,37 @@ const nextConfig = {
removeConsole: process.env.NODE_ENV === "production", removeConsole: process.env.NODE_ENV === "production",
}, },
// Simple bundle optimization // Experimental flags
experimental: { experimental: {
externalDir: true,
optimizePackageImports: ["@heroicons/react", "lucide-react", "@tanstack/react-query"], optimizePackageImports: ["@heroicons/react", "lucide-react", "@tanstack/react-query"],
}, },
webpack(config) {
const workspaceRoot = path.resolve(__dirname, "..", "..");
config.resolve.alias = {
...config.resolve.alias,
"@customer-portal/domain": path.join(workspaceRoot, "packages/domain"),
"@customer-portal/logging": path.join(workspaceRoot, "packages/logging/src"),
"@customer-portal/validation": path.join(workspaceRoot, "packages/validation/src"),
};
const preferredExtensions = [".ts", ".tsx", ".mts", ".cts"];
const existingExtensions = config.resolve.extensions || [];
config.resolve.extensions = [
...new Set([...preferredExtensions, ...existingExtensions]),
];
config.resolve.extensionAlias = {
...(config.resolve.extensionAlias || {}),
".js": [".ts", ".tsx", ".js"],
".mjs": [".mts", ".ts", ".tsx", ".mjs"],
};
config.module.rules.push({
test: /packages\/domain\/.*\.js$/,
type: "javascript/esm",
});
return config;
},
// Keep type checking enabled; monorepo paths provide types // Keep type checking enabled; monorepo paths provide types
typescript: { ignoreBuildErrors: false }, typescript: { ignoreBuildErrors: false },

View File

@ -166,6 +166,42 @@ const statsSchema = z.record(
z.union([z.string(), z.number(), z.boolean()]) z.union([z.string(), z.number(), z.boolean()])
).optional(); ).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 whmcsCustomFieldsSchema = z
.union([
z.record(z.string(), z.string()),
whmcsRawCustomFieldsArraySchema,
z
.object({
customfield: z
.union([whmcsRawCustomFieldSchema, whmcsRawCustomFieldsArraySchema])
.optional(),
})
.passthrough(),
])
.optional();
const whmcsUsersSchema = z
.union([
z.array(subUserSchema),
z
.object({
user: z.union([subUserSchema, z.array(subUserSchema)]).optional(),
})
.passthrough(),
])
.optional();
/** /**
* WhmcsClient - Full WHMCS client data * WhmcsClient - Full WHMCS client data
* *
@ -213,8 +249,8 @@ export const whmcsClientSchema = z.object({
// Relations // Relations
address: addressSchema.nullable().optional(), address: addressSchema.nullable().optional(),
email_preferences: emailPreferencesSchema.nullable().optional(), // snake_case from WHMCS email_preferences: emailPreferencesSchema.nullable().optional(), // snake_case from WHMCS
customfields: z.record(z.string(), z.string()).optional(), customfields: whmcsCustomFieldsSchema,
users: z.array(subUserSchema).optional(), users: whmcsUsersSchema,
stats: statsSchema.optional(), stats: statsSchema.optional(),
}).transform(data => ({ }).transform(data => ({
...data, ...data,