diff --git a/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts b/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts index 51d081bc..5bc26706 100644 --- a/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts +++ b/apps/bff/src/integrations/whmcs/cache/whmcs-cache.service.ts @@ -5,7 +5,7 @@ import { CacheService } from "@bff/infra/cache/cache.service"; import { Invoice, InvoiceList } from "@customer-portal/domain/billing"; import { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions"; import { PaymentMethodList, PaymentGatewayList } from "@customer-portal/domain/payments"; -import type { NormalizedWhmcsClient } from "../utils/whmcs-client.utils"; +import type { WhmcsClient } from "@customer-portal/domain/customer"; export interface CacheOptions { ttl?: number; @@ -148,15 +148,15 @@ export class WhmcsCacheService { * Get cached client data * Returns WhmcsClient (type inferred from domain) */ - async getClientData(clientId: number): Promise { + async getClientData(clientId: number): Promise { const key = this.buildClientKey(clientId); - return this.get(key, "client"); + return this.get(key, "client"); } /** * Cache client data */ - async setClientData(clientId: number, data: NormalizedWhmcsClient) { + async setClientData(clientId: number, data: WhmcsClient) { const key = this.buildClientKey(clientId); await this.set(key, data, "client", [`client:${clientId}`]); } diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts index 99b1f830..74b5fe80 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts @@ -11,9 +11,9 @@ import type { WhmcsValidateLoginResponse, } from "@customer-portal/domain/customer"; import { - normalizeWhmcsClientResponse, - type NormalizedWhmcsClient, -} from "../utils/whmcs-client.utils"; + Providers as CustomerProviders, + type WhmcsClient, +} from "@customer-portal/domain/customer"; @Injectable() export class WhmcsClientService { @@ -56,7 +56,7 @@ export class WhmcsClientService { * Get client details by ID * Returns WhmcsClient (type inferred from domain mapper) */ - async getClientDetails(clientId: number): Promise { + async getClientDetails(clientId: number): Promise { try { // Try cache first const cached = await this.cacheService.getClientData(clientId); @@ -75,11 +75,11 @@ export class WhmcsClientService { throw new NotFoundException(`Client ${clientId} not found`); } - const normalized = normalizeWhmcsClientResponse(response); - await this.cacheService.setClientData(normalized.id, normalized); + const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response); + await this.cacheService.setClientData(client.id, client); this.logger.log(`Fetched client details for client ${clientId}`); - return normalized; + return client; } catch (error) { this.logger.error(`Failed to fetch client details for client ${clientId}`, { error: getErrorMessage(error), @@ -92,7 +92,7 @@ export class WhmcsClientService { * Get client details by email * Returns WhmcsClient (type inferred from domain mapper) */ - async getClientDetailsByEmail(email: string): Promise { + async getClientDetailsByEmail(email: string): Promise { try { const response = await this.connectionService.getClientDetailsByEmail(email); @@ -104,11 +104,11 @@ export class WhmcsClientService { throw new NotFoundException(`Client with email ${email} not found`); } - const normalized = normalizeWhmcsClientResponse(response); - await this.cacheService.setClientData(normalized.id, normalized); + const client = CustomerProviders.Whmcs.transformWhmcsClientResponse(response); + await this.cacheService.setClientData(client.id, client); this.logger.log(`Fetched client details by email: ${email}`); - return normalized; + return client; } catch (error) { this.logger.error(`Failed to fetch client details by email: ${email}`, { error: getErrorMessage(error), diff --git a/apps/bff/src/integrations/whmcs/utils/whmcs-client.utils.ts b/apps/bff/src/integrations/whmcs/utils/whmcs-client.utils.ts index 03bc4d5a..89c9a050 100644 --- a/apps/bff/src/integrations/whmcs/utils/whmcs-client.utils.ts +++ b/apps/bff/src/integrations/whmcs/utils/whmcs-client.utils.ts @@ -1,32 +1,15 @@ -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"; +import type { WhmcsClient } from "@customer-portal/domain/customer"; -type RawCustomFields = WhmcsClient["customfields"]; -type RawUsers = WhmcsClient["users"]; +type CustomFieldValueMap = Record; -const truthyStrings = new Set(["1", "true", "yes", "y", "on"]); -const falsyStrings = new Set(["0", "false", "no", "n", "off"]); +const isRecordOfStrings = (value: unknown): value is Record => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).every(v => typeof v === "string"); -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 isPlainObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); const toOptionalString = (value: unknown): string | undefined => { if (value === undefined || value === null) return undefined; @@ -37,232 +20,72 @@ const toOptionalString = (value: unknown): string | undefined => { return undefined; }; -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 => - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.values(value).every(v => typeof v === "string"); - -const normalizeCustomFields = (raw: RawCustomFields): Record | undefined => { +const normalizeCustomFields = (raw: WhmcsClient["customfields"]): CustomFieldValueMap | undefined => { if (!raw) return undefined; - if (Array.isArray(raw)) { - const map = raw.reduce>((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; - const map = Object.entries(stringRecord).reduce>((acc, [key, value]) => { + const map = Object.entries(raw).reduce((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]); - } + const addFieldToMap = (field: unknown, acc: CustomFieldValueMap) => { + if (!isPlainObject(field)) return; + const idKey = toOptionalString(field.id)?.trim(); + const nameKey = toOptionalString(field.name)?.trim(); + const value = + field.value === undefined || field.value === null ? "" : String(field.value); - 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(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; - 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, + if (idKey) acc[idKey] = value; + if (nameKey) acc[nameKey] = value; }; -}; -export const normalizeWhmcsClientResponse = ( - response: WhmcsClientResponse -): NormalizedWhmcsClient => normalizeWhmcsClient(response.client); + if (Array.isArray(raw)) { + const map = raw.reduce((acc, field) => { + addFieldToMap(field, acc); + return acc; + }, {}); + return Object.keys(map).length ? map : undefined; + } -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)]; + if (isPlainObject(raw) && "customfield" in raw) { + const nested = raw.customfield; + if (Array.isArray(nested)) { + return normalizeCustomFields(nested); + } + if (nested) { + return normalizeCustomFields([nested]); } } 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, - }; +/** + * Safely read a WHMCS custom field value by id or name without duplicating + * normalization logic at call sites. + */ +export const getCustomFieldValue = ( + client: WhmcsClient, + key: string | number +): string | undefined => { + const map = normalizeCustomFields(client.customfields); + if (!map) return undefined; - return userSchema.parse(payload); + const lookupKey = typeof key === "number" ? String(key) : key; + const direct = map[lookupKey]; + if (direct !== undefined) return direct; + + if (typeof key === "string") { + const numeric = Number.parseInt(key, 10); + if (!Number.isNaN(numeric)) { + return map[String(numeric)]; + } + } + + return undefined; }; - -export const getNumericClientId = (client: NormalizedWhmcsClient): number => client.id; diff --git a/apps/bff/src/integrations/whmcs/whmcs.service.ts b/apps/bff/src/integrations/whmcs/whmcs.service.ts index 772baca8..c2d16a9b 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.service.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.service.ts @@ -2,7 +2,7 @@ import { Injectable, Inject } from "@nestjs/common"; import type { Invoice, InvoiceList } from "@customer-portal/domain/billing"; import type { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions"; import type { PaymentMethodList, PaymentGatewayList } from "@customer-portal/domain/payments"; -import { Providers as CustomerProviders, type Address } from "@customer-portal/domain/customer"; +import { Providers as CustomerProviders, type Address, type WhmcsClient } from "@customer-portal/domain/customer"; import { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service"; import { WhmcsInvoiceService, InvoiceFilters } from "./services/whmcs-invoice.service"; import { @@ -18,7 +18,6 @@ import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subs import type { WhmcsProductListResponse } from "@customer-portal/domain/subscriptions"; import type { WhmcsCatalogProductListResponse } from "@customer-portal/domain/catalog"; import { Logger } from "nestjs-pino"; -import { deriveAddressFromClient, type NormalizedWhmcsClient } from "./utils/whmcs-client.utils"; @Injectable() export class WhmcsService { @@ -124,7 +123,7 @@ export class WhmcsService { * Get client details by ID * Returns internal WhmcsClient (type inferred) */ - async getClientDetails(clientId: number): Promise { + async getClientDetails(clientId: number): Promise { return this.clientService.getClientDetails(clientId); } @@ -132,7 +131,7 @@ export class WhmcsService { * Get client details by email * Returns internal WhmcsClient (type inferred) */ - async getClientDetailsByEmail(email: string): Promise { + async getClientDetailsByEmail(email: string): Promise { return this.clientService.getClientDetailsByEmail(email); } @@ -151,7 +150,7 @@ export class WhmcsService { */ async getClientAddress(clientId: number): Promise
{ const customer = await this.clientService.getClientDetails(clientId); - return customer.address ?? deriveAddressFromClient(customer.raw) ?? ({} as Address); + return (customer.address ?? {}) as Address; } async updateClientAddress(clientId: number, address: Partial
): Promise { diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts index 55d7dc6f..28c33292 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts @@ -13,10 +13,7 @@ import { SalesforceService } from "@bff/integrations/salesforce/salesforce.servi import { getErrorMessage } from "@bff/core/utils/error.util"; import { mapPrismaUserToDomain } from "@bff/infra/mappers"; import type { User } from "@customer-portal/domain/customer"; -import { - getCustomFieldValue, - getNumericClientId, -} from "@bff/integrations/whmcs/utils/whmcs-client.utils"; +import { getCustomFieldValue } from "@bff/integrations/whmcs/utils/whmcs-client.utils"; // No direct Customer import - use inferred type from WHMCS service @Injectable() @@ -67,7 +64,7 @@ export class WhmcsLinkWorkflowService { throw new UnauthorizedException("Unable to verify account. Please try again later."); } - const clientNumericId = getNumericClientId(clientDetails); + const clientNumericId = clientDetails.id; try { const existingMapping = await this.mappingsService.findByWhmcsClientId(clientNumericId); diff --git a/apps/bff/src/modules/users/users.service.ts b/apps/bff/src/modules/users/users.service.ts index 3fdb51d2..ab65fc5e 100644 --- a/apps/bff/src/modules/users/users.service.ts +++ b/apps/bff/src/modules/users/users.service.ts @@ -11,6 +11,7 @@ import { import { Providers as CustomerProviders, addressSchema, + combineToUser, type Address, type User, } from "@customer-portal/domain/customer"; @@ -21,7 +22,6 @@ import { dashboardSummarySchema } from "@customer-portal/domain/dashboard"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.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 type UserUpdateData = Partial< @@ -132,7 +132,7 @@ export class UsersService { // Map Prisma user to UserAuth const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user); - return buildUserProfile(userAuth, whmcsClient); + return combineToUser(userAuth, whmcsClient); } catch (error) { this.logger.error("Failed to fetch client profile from WHMCS", { error: getErrorMessage(error), @@ -530,15 +530,10 @@ export class UsersService { let currency = "JPY"; // Default try { const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId); - const currencyCodeFromClient = + const resolvedCurrency = typeof client.currency_code === "string" && client.currency_code.trim().length > 0 ? client.currency_code - : undefined; - const currencyCodeFromRaw = - typeof client.raw.currency_code === "string" && client.raw.currency_code.trim().length > 0 - ? client.raw.currency_code - : undefined; - const resolvedCurrency = currencyCodeFromClient ?? currencyCodeFromRaw ?? null; + : null; if (resolvedCurrency) { currency = resolvedCurrency; } diff --git a/apps/portal/src/features/account/hooks/useProfileData.ts b/apps/portal/src/features/account/hooks/useProfileData.ts index 5863b17e..3144de97 100644 --- a/apps/portal/src/features/account/hooks/useProfileData.ts +++ b/apps/portal/src/features/account/hooks/useProfileData.ts @@ -4,7 +4,6 @@ import { useEffect, useState, useCallback } from "react"; import { useAuthStore } from "@/features/auth/services/auth.store"; import { accountService } from "@/features/account/services/account.service"; import { logger } from "@customer-portal/logging"; -import { getCountryCodeByName } from "@/lib/constants/countries"; // Use centralized profile types import type { ProfileEditFormData, Address } from "@customer-portal/domain/customer"; @@ -40,21 +39,14 @@ export function useProfileData() { setLoading(true); const address = await accountService.getAddress().catch(() => null); if (address) { - const normalizeCountry = (value?: string | null) => { - if (!value) return ""; - if (value.length === 2) return value.toUpperCase(); - return getCountryCodeByName(value) ?? value; - }; - const normalizedCountry = normalizeCountry(address.country); - const normalizedCountryCode = normalizeCountry(address.countryCode ?? address.country); const normalizedAddress: Address = { address1: address.address1 || "", address2: address.address2 || "", city: address.city || "", state: address.state || "", postcode: address.postcode || "", - country: normalizedCountry, - countryCode: normalizedCountryCode, + country: address.country || "", + countryCode: address.countryCode || "", phoneNumber: address.phoneNumber || "", phoneCountryCode: address.phoneCountryCode || "", }; diff --git a/apps/portal/src/features/billing/hooks/useBilling.ts b/apps/portal/src/features/billing/hooks/useBilling.ts index ec2a0d4a..2c6eaed6 100644 --- a/apps/portal/src/features/billing/hooks/useBilling.ts +++ b/apps/portal/src/features/billing/hooks/useBilling.ts @@ -25,12 +25,9 @@ import { type InvoiceList, type InvoiceSsoLink, type InvoiceQueryParams, - type InvoiceStatus, // Schemas invoiceSchema, invoiceListSchema, - // Constants - INVOICE_STATUS, } from "@customer-portal/domain/billing"; import { type PaymentMethodList } from "@customer-portal/domain/payments"; @@ -51,28 +48,6 @@ const EMPTY_PAYMENT_METHODS: PaymentMethodList = { totalCount: 0, }; -const FALLBACK_STATUS: InvoiceStatus = INVOICE_STATUS.DRAFT; - -function ensureInvoiceStatus(invoice: Invoice): Invoice { - return { - ...invoice, - status: (invoice.status as InvoiceStatus | undefined) ?? FALLBACK_STATUS, - }; -} - -function normalizeInvoiceList(list: InvoiceList): InvoiceList { - return { - ...list, - invoices: list.invoices.map(ensureInvoiceStatus), - pagination: { - page: list.pagination?.page ?? 1, - totalItems: list.pagination?.totalItems ?? 0, - totalPages: list.pagination?.totalPages ?? 0, - nextCursor: list.pagination?.nextCursor, - }, - }; -} - // Type helpers for React Query type InvoicesQueryKey = ReturnType; type InvoiceQueryKey = ReturnType; @@ -121,8 +96,7 @@ async function fetchInvoices(params?: InvoiceQueryParams): Promise query ? { params: { query } } : undefined ); const data = getDataOrDefault(response, EMPTY_INVOICE_LIST); - const parsed = invoiceListSchema.parse(data); - return normalizeInvoiceList(parsed); + return invoiceListSchema.parse(data); } async function fetchInvoice(id: string): Promise { @@ -130,8 +104,7 @@ async function fetchInvoice(id: string): Promise { params: { path: { id } }, }); const invoice = getDataOrThrow(response, "Invoice not found"); - const parsed = invoiceSchema.parse(invoice); - return ensureInvoiceStatus(parsed); + return invoiceSchema.parse(invoice); } async function fetchPaymentMethods(): Promise { diff --git a/packages/domain/billing/providers/whmcs/mapper.ts b/packages/domain/billing/providers/whmcs/mapper.ts index c74f8572..219248fd 100644 --- a/packages/domain/billing/providers/whmcs/mapper.ts +++ b/packages/domain/billing/providers/whmcs/mapper.ts @@ -14,6 +14,7 @@ import { type WhmcsInvoiceItemsRaw, whmcsInvoiceItemsRawSchema, } from "./raw.types"; +import { parseAmount, formatDate } from "../../providers/whmcs/utils"; export interface TransformInvoiceOptions { defaultCurrencyCode?: string; @@ -47,32 +48,6 @@ function mapStatus(status: string): Invoice["status"] { return mapped; } -function parseAmount(amount: string | number | undefined): number { - if (typeof amount === "number") { - return amount; - } - if (!amount) { - return 0; - } - - const cleaned = String(amount).replace(/[^\d.-]/g, ""); - const parsed = Number.parseFloat(cleaned); - return Number.isNaN(parsed) ? 0 : parsed; -} - -function formatDate(input?: string): string | undefined { - if (!input) { - return undefined; - } - - const date = new Date(input); - if (Number.isNaN(date.getTime())) { - return undefined; - } - - return date.toISOString(); -} - function mapItems(rawItems: unknown): InvoiceItem[] { if (!rawItems) return []; diff --git a/packages/domain/customer/index.ts b/packages/domain/customer/index.ts index 08a0e071..481b748e 100644 --- a/packages/domain/customer/index.ts +++ b/packages/domain/customer/index.ts @@ -31,6 +31,7 @@ export type { ProfileDisplayData, // Profile display data (alias) UserProfile, // Alias for User AuthenticatedUser, // Alias for authenticated user + WhmcsClient, // Provider-normalized WHMCS client shape } from "./schema"; // ============================================================================ diff --git a/packages/domain/providers/whmcs/utils.ts b/packages/domain/providers/whmcs/utils.ts new file mode 100644 index 00000000..302d5d07 --- /dev/null +++ b/packages/domain/providers/whmcs/utils.ts @@ -0,0 +1,61 @@ +/** + * Shared WHMCS Provider Utilities + * Single source of truth for WHMCS data parsing + * + * Raw API types are source of truth - no fallbacks or variations expected. + */ + +/** + * Parse amount from WHMCS API response + * WHMCS returns amounts as strings or numbers + */ +export function parseAmount(amount: string | number | undefined): number { + if (typeof amount === "number") return amount; + if (!amount) return 0; + + const cleaned = String(amount).replace(/[^\d.-]/g, ""); + const parsed = Number.parseFloat(cleaned); + return Number.isNaN(parsed) ? 0 : parsed; +} + +/** + * Format date from WHMCS API to ISO string + * Returns undefined if input is invalid + */ +export function formatDate(input?: string | null): string | undefined { + if (!input) return undefined; + + const date = new Date(input); + if (Number.isNaN(date.getTime())) return undefined; + + return date.toISOString(); +} + +/** + * Normalize status using provided status map + * Generic helper for consistent status mapping + */ +export function normalizeStatus( + status: string | undefined, + statusMap: Record, + defaultStatus: T +): T { + if (!status) return defaultStatus; + const mapped = statusMap[status.trim().toLowerCase()]; + return mapped ?? defaultStatus; +} + +/** + * Normalize billing cycle using provided cycle map + * Generic helper for consistent cycle mapping + */ +export function normalizeCycle( + cycle: string | undefined, + cycleMap: Record, + defaultCycle: T +): T { + if (!cycle) return defaultCycle; + const normalized = cycle.trim().toLowerCase().replace(/[_\s-]+/g, " "); + return cycleMap[normalized] ?? defaultCycle; +} +