Refactor WHMCS client handling and improve type consistency across services
- Updated WHMCS service methods to return WhmcsClient type instead of NormalizedWhmcsClient for better alignment with domain types. - Refactored caching logic in WhmcsCacheService to utilize WhmcsClient, enhancing type safety and consistency. - Simplified client detail retrieval in WhmcsClientService by directly using the transformed WhmcsClient response. - Removed deprecated utility functions and streamlined custom field normalization logic in whmcs-client.utils.ts. - Enhanced user profile mapping in UsersService to utilize combineToUser for improved clarity and maintainability. - Cleaned up unused imports and optimized address handling in various components for better performance.
This commit is contained in:
parent
05765d3513
commit
26b2112fbb
@ -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<NormalizedWhmcsClient | null> {
|
||||
async getClientData(clientId: number): Promise<WhmcsClient | null> {
|
||||
const key = this.buildClientKey(clientId);
|
||||
return this.get<NormalizedWhmcsClient>(key, "client");
|
||||
return this.get<WhmcsClient>(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}`]);
|
||||
}
|
||||
|
||||
@ -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<NormalizedWhmcsClient> {
|
||||
async getClientDetails(clientId: number): Promise<WhmcsClient> {
|
||||
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<NormalizedWhmcsClient> {
|
||||
async getClientDetailsByEmail(email: string): Promise<WhmcsClient> {
|
||||
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),
|
||||
|
||||
@ -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<string, string>;
|
||||
|
||||
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<string, string> =>
|
||||
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<string, unknown> =>
|
||||
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<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 => {
|
||||
const normalizeCustomFields = (raw: WhmcsClient["customfields"]): CustomFieldValueMap | 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 map = Object.entries(raw).reduce<CustomFieldValueMap>((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<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,
|
||||
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<CustomFieldValueMap>((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;
|
||||
|
||||
@ -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<NormalizedWhmcsClient> {
|
||||
async getClientDetails(clientId: number): Promise<WhmcsClient> {
|
||||
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<NormalizedWhmcsClient> {
|
||||
async getClientDetailsByEmail(email: string): Promise<WhmcsClient> {
|
||||
return this.clientService.getClientDetailsByEmail(email);
|
||||
}
|
||||
|
||||
@ -151,7 +150,7 @@ export class WhmcsService {
|
||||
*/
|
||||
async getClientAddress(clientId: number): Promise<Address> {
|
||||
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<Address>): Promise<void> {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 || "",
|
||||
};
|
||||
|
||||
@ -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<typeof queryKeys.billing.invoices>;
|
||||
type InvoiceQueryKey = ReturnType<typeof queryKeys.billing.invoice>;
|
||||
@ -121,8 +96,7 @@ async function fetchInvoices(params?: InvoiceQueryParams): Promise<InvoiceList>
|
||||
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<Invoice> {
|
||||
@ -130,8 +104,7 @@ async function fetchInvoice(id: string): Promise<Invoice> {
|
||||
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<PaymentMethodList> {
|
||||
|
||||
@ -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 [];
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
// ============================================================================
|
||||
|
||||
61
packages/domain/providers/whmcs/utils.ts
Normal file
61
packages/domain/providers/whmcs/utils.ts
Normal file
@ -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<T extends string>(
|
||||
status: string | undefined,
|
||||
statusMap: Record<string, T>,
|
||||
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<T extends string>(
|
||||
cycle: string | undefined,
|
||||
cycleMap: Record<string, T>,
|
||||
defaultCycle: T
|
||||
): T {
|
||||
if (!cycle) return defaultCycle;
|
||||
const normalized = cycle.trim().toLowerCase().replace(/[_\s-]+/g, " ");
|
||||
return cycleMap[normalized] ?? defaultCycle;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user