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:
barsa 2025-10-29 18:36:25 +09:00
parent 05765d3513
commit 26b2112fbb
11 changed files with 147 additions and 331 deletions

View File

@ -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}`]);
}

View File

@ -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),

View File

@ -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 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);
if (idKey) acc[idKey] = value;
if (nameKey) acc[nameKey] = value;
};
if (Array.isArray(raw)) {
const map = raw.reduce<CustomFieldValueMap>((acc, field) => {
addFieldToMap(field, acc);
return acc;
}, {});
return Object.keys(map).length ? map : undefined;
}
if (isPlainObject(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]);
if (Array.isArray(nested)) {
return normalizeCustomFields(nested);
}
if (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);
/**
* Safely read a WHMCS custom field value by id or name without duplicating
* normalization logic at call sites.
*/
export const getCustomFieldValue = (
client: NormalizedWhmcsClient,
client: WhmcsClient,
key: string | number
): string | undefined => {
if (!client.customfields) return undefined;
const map = normalizeCustomFields(client.customfields);
if (!map) return undefined;
const lookupKey = typeof key === "number" ? String(key) : key;
const value = client.customfields[lookupKey];
if (value !== undefined) return value;
const direct = map[lookupKey];
if (direct !== undefined) return direct;
if (typeof key === "string") {
const numeric = Number.parseInt(key, 10);
if (!Number.isNaN(numeric)) {
return client.customfields[String(numeric)];
return map[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

@ -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> {

View File

@ -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);

View File

@ -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;
}

View File

@ -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 || "",
};

View File

@ -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> {

View File

@ -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 [];

View File

@ -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";
// ============================================================================

View 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;
}