From 5c329bbe96ce7ad05eb1fd455fb7e91dc626501c Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 24 Feb 2026 13:56:02 +0900 Subject: [PATCH] refactor: update WHMCS mappers and schemas to use numberLike for type safety - Refactor various mappers in billing, payments, services, and subscriptions to ensure IDs are consistently converted to numbers. - Update raw types schemas to utilize whmcsNumberLike and whmcsString for improved validation and type safety. - Enhance the whmcs-utils to include schema exports for better modularity. --- .../whmcs/services/whmcs-invoice.service.ts | 4 +- .../domain/billing/providers/whmcs/mapper.ts | 8 +- .../billing/providers/whmcs/raw.types.ts | 132 +++++++++--------- .../common/providers/whmcs-utils/index.ts | 1 + .../common/providers/whmcs-utils/schema.ts | 28 ++++ .../domain/payments/providers/whmcs/mapper.ts | 2 +- .../domain/services/providers/whmcs/mapper.ts | 2 +- .../services/providers/whmcs/raw.types.ts | 46 +++--- .../providers/whmcs/raw.types.ts | 73 +++++----- 9 files changed, 169 insertions(+), 127 deletions(-) create mode 100644 packages/domain/common/providers/whmcs-utils/schema.ts diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index ace3e95d..b4c0eeae 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -276,7 +276,7 @@ export class WhmcsInvoiceService { }, }); - const totalItems = response.totalresults || 0; + const totalItems = Number(response.totalresults) || 0; const totalPages = Math.ceil(totalItems / limit); return { @@ -353,7 +353,7 @@ export class WhmcsInvoiceService { }); return { - id: response.invoiceid, + id: Number(response.invoiceid), number: `INV-${response.invoiceid}`, total: params.amount, status: response.status, diff --git a/packages/domain/billing/providers/whmcs/mapper.ts b/packages/domain/billing/providers/whmcs/mapper.ts index 162c4b27..70f022d4 100644 --- a/packages/domain/billing/providers/whmcs/mapper.ts +++ b/packages/domain/billing/providers/whmcs/mapper.ts @@ -54,12 +54,12 @@ function mapItems(rawItems: unknown): InvoiceItem[] { const itemArray = Array.isArray(parsed.item) ? parsed.item : [parsed.item]; return itemArray.map(item => ({ - id: item.id, + id: Number(item.id), description: item.description, amount: parseAmount(item.amount), quantity: 1, type: item.type, - serviceId: typeof item.relid === "number" && item.relid > 0 ? item.relid : undefined, + serviceId: Number(item.relid) > 0 ? Number(item.relid) : undefined, })); } @@ -86,7 +86,7 @@ export function transformWhmcsInvoice( // Transform to domain model const invoice: Invoice = { - id: whmcsInvoice.invoiceid ?? 0, + id: Number(whmcsInvoice.invoiceid ?? 0), number: whmcsInvoice.invoicenum || `INV-${whmcsInvoice.invoiceid}`, status: mapStatus(whmcsInvoice.status), currency, @@ -115,7 +115,7 @@ export function transformWhmcsInvoices( return rawInvoices.map(raw => transformWhmcsInvoice(raw, options)); } -function normalizeListInvoice(rawInvoice: unknown): WhmcsInvoiceRaw & { id?: number } { +function normalizeListInvoice(rawInvoice: unknown): WhmcsInvoiceRaw & { id?: string | number } { const listItem: WhmcsInvoiceListItem = whmcsInvoiceListItemSchema.parse(rawInvoice); const invoiceid = listItem.invoiceid ?? listItem.id; return { diff --git a/packages/domain/billing/providers/whmcs/raw.types.ts b/packages/domain/billing/providers/whmcs/raw.types.ts index ac98b323..f646dec6 100644 --- a/packages/domain/billing/providers/whmcs/raw.types.ts +++ b/packages/domain/billing/providers/whmcs/raw.types.ts @@ -9,6 +9,10 @@ */ import { z } from "zod"; +import { + whmcsString as s, + whmcsNumberLike as numberLike, +} from "../../../common/providers/whmcs-utils/index.js"; // ============================================================================ // Request Parameter Types @@ -102,12 +106,12 @@ export interface WhmcsCapturePaymentParams { // Raw WHMCS Invoice Item export const whmcsInvoiceItemRawSchema = z.object({ - id: z.number(), - type: z.string(), - relid: z.number(), - description: z.string(), - amount: z.union([z.string(), z.number()]), - taxed: z.number().optional(), + id: numberLike, + type: s, + relid: numberLike, + description: s, + amount: numberLike, + taxed: numberLike.optional(), }); export type WhmcsInvoiceItemRaw = z.infer; @@ -121,53 +125,53 @@ export type WhmcsInvoiceItemsRaw = z.infer; const whmcsInvoiceCommonSchema = z .object({ - invoicenum: z.string().optional(), - userid: z.number(), - date: z.string(), - duedate: z.string(), - subtotal: z.string(), - credit: z.string(), - tax: z.string(), - tax2: z.string(), - total: z.string(), - balance: z.string().optional(), - status: z.string(), - paymentmethod: z.string(), - notes: z.string().optional(), + invoicenum: s.optional(), + userid: numberLike, + date: s, + duedate: s, + subtotal: s, + credit: s, + tax: s, + tax2: s, + total: s, + balance: s.optional(), + status: s, + paymentmethod: s, + notes: s.optional(), ccgateway: z.boolean().optional(), items: whmcsInvoiceItemsRawSchema.optional(), transactions: z.unknown().optional(), - clientid: z.number().optional(), - datecreated: z.string().optional(), - paymentmethodname: z.string().optional(), - currencyprefix: z.string().optional(), - currencysuffix: z.string().optional(), - lastcaptureattempt: z.string().optional(), - last_capture_attempt: z.string().optional(), - datepaid: z.string().optional(), - date_refunded: z.string().optional(), - date_cancelled: z.string().optional(), - created_at: z.string().optional(), - updated_at: z.string().optional(), - taxrate: z.string().optional(), - taxrate2: z.string().optional(), - firstname: z.string().optional(), - lastname: z.string().optional(), - companyname: z.string().optional(), - currencycode: z.string().optional(), + clientid: numberLike.optional(), + datecreated: s.optional(), + paymentmethodname: s.optional(), + currencyprefix: s.optional(), + currencysuffix: s.optional(), + lastcaptureattempt: s.optional(), + last_capture_attempt: s.optional(), + datepaid: s.optional(), + date_refunded: s.optional(), + date_cancelled: s.optional(), + created_at: s.optional(), + updated_at: s.optional(), + taxrate: s.optional(), + taxrate2: s.optional(), + firstname: s.optional(), + lastname: s.optional(), + companyname: s.optional(), + currencycode: s.optional(), }) .passthrough(); export const whmcsInvoiceListItemSchema = whmcsInvoiceCommonSchema.extend({ - id: z.number(), - invoiceid: z.number().optional(), + id: numberLike, + invoiceid: numberLike.optional(), }); // Raw WHMCS Invoice (detailed GetInvoice response) export const whmcsInvoiceRawSchema = whmcsInvoiceCommonSchema.extend({ - invoiceid: z.number(), - id: z.number().optional(), - balance: z.string().optional(), + invoiceid: numberLike, + id: numberLike.optional(), + balance: s.optional(), }); export type WhmcsInvoiceRaw = z.infer; @@ -184,9 +188,9 @@ export const whmcsInvoiceListResponseSchema = z.object({ invoices: z.object({ invoice: z.array(whmcsInvoiceListItemSchema), }), - totalresults: z.number(), - numreturned: z.number(), - startnumber: z.number(), + totalresults: numberLike, + numreturned: numberLike, + startnumber: numberLike, }); export type WhmcsInvoiceListResponse = z.infer; @@ -214,9 +218,9 @@ export type WhmcsInvoiceResponse = z.infer; */ export const whmcsCreateInvoiceResponseSchema = z.object({ result: z.enum(["success", "error"]), - invoiceid: z.number(), - status: z.string(), - message: z.string().optional(), + invoiceid: numberLike, + status: s, + message: s.optional(), }); export type WhmcsCreateInvoiceResponse = z.infer; @@ -230,9 +234,9 @@ export type WhmcsCreateInvoiceResponse = z.infer; @@ -246,13 +250,13 @@ export type WhmcsUpdateInvoiceResponse = z.infer; @@ -265,12 +269,12 @@ export type WhmcsCapturePaymentResponse = z.infer; diff --git a/packages/domain/common/providers/whmcs-utils/index.ts b/packages/domain/common/providers/whmcs-utils/index.ts index 33db5401..8d4980cf 100644 --- a/packages/domain/common/providers/whmcs-utils/index.ts +++ b/packages/domain/common/providers/whmcs-utils/index.ts @@ -10,3 +10,4 @@ export * from "./parsing.js"; export * from "./normalize.js"; export * from "./custom-fields.js"; export * from "./php-serialize.js"; +export * from "./schema.js"; diff --git a/packages/domain/common/providers/whmcs-utils/schema.ts b/packages/domain/common/providers/whmcs-utils/schema.ts new file mode 100644 index 00000000..e460f7e9 --- /dev/null +++ b/packages/domain/common/providers/whmcs-utils/schema.ts @@ -0,0 +1,28 @@ +/** + * WHMCS Zod Schema Primitives (domain-internal) + * + * Coercing schema helpers for WHMCS API responses. + * WHMCS (PHP) is loosely typed — fields documented as strings may arrive + * as numbers (and vice-versa). These primitives absorb that inconsistency + * at the parsing boundary so the rest of the codebase sees clean types. + */ + +import { z } from "zod"; + +/** + * Coercing string — accepts string or number, always outputs string. + * Use for any WHMCS response field that should be a string. + */ +export const whmcsString = z.coerce.string(); + +/** + * Accepts number or string (e.g. "123"), keeps the raw union type. + * Use when downstream code handles both types (e.g. IDs you'll parse later). + */ +export const whmcsNumberLike = z.union([z.number(), z.string()]); + +/** + * Accepts boolean, number (0/1), or string ("true"/"false"/etc). + * Use for WHMCS boolean flags that arrive in varying formats. + */ +export const whmcsBooleanLike = z.union([z.boolean(), z.number(), z.string()]); diff --git a/packages/domain/payments/providers/whmcs/mapper.ts b/packages/domain/payments/providers/whmcs/mapper.ts index 3dd11ced..0d7fea66 100644 --- a/packages/domain/payments/providers/whmcs/mapper.ts +++ b/packages/domain/payments/providers/whmcs/mapper.ts @@ -43,7 +43,7 @@ export function transformWhmcsPaymentMethod(raw: unknown): PaymentMethod { const whmcs = whmcsPaymentMethodRawSchema.parse(raw); const paymentMethod: PaymentMethod = { - id: whmcs.id, + id: Number(whmcs.id), type: mapPaymentMethodType(whmcs.payment_type || whmcs.type || "manual"), description: whmcs.description, gatewayName: whmcs.gateway_name || whmcs.gateway, diff --git a/packages/domain/services/providers/whmcs/mapper.ts b/packages/domain/services/providers/whmcs/mapper.ts index 65146e8f..9bc9dfa2 100644 --- a/packages/domain/services/providers/whmcs/mapper.ts +++ b/packages/domain/services/providers/whmcs/mapper.ts @@ -113,7 +113,7 @@ export function transformWhmcsCatalogProductsResponse( return { id: String(product.pid), - groupId: product.gid, + groupId: Number(product.gid), name: product.name, description: product.description, module: product.module, diff --git a/packages/domain/services/providers/whmcs/raw.types.ts b/packages/domain/services/providers/whmcs/raw.types.ts index 5be7ab94..e1773386 100644 --- a/packages/domain/services/providers/whmcs/raw.types.ts +++ b/packages/domain/services/providers/whmcs/raw.types.ts @@ -5,26 +5,30 @@ */ import { z } from "zod"; +import { + whmcsString as s, + whmcsNumberLike as numberLike, +} from "../../../common/providers/whmcs-utils/index.js"; // ============================================================================ // WHMCS Catalog Product Pricing Cycle // ============================================================================ const whmcsCatalogProductPricingCycleSchema = z.object({ - prefix: z.string(), - suffix: z.string(), - msetupfee: z.string(), - qsetupfee: z.string(), - ssetupfee: z.string(), - asetupfee: z.string(), - bsetupfee: z.string(), - tsetupfee: z.string(), - monthly: z.string(), - quarterly: z.string(), - semiannually: z.string(), - annually: z.string(), - biennially: z.string(), - triennially: z.string(), + prefix: s, + suffix: s, + msetupfee: s, + qsetupfee: s, + ssetupfee: s, + asetupfee: s, + bsetupfee: s, + tsetupfee: s, + monthly: s, + quarterly: s, + semiannually: s, + annually: s, + biennially: s, + triennially: s, }); // ============================================================================ @@ -32,12 +36,12 @@ const whmcsCatalogProductPricingCycleSchema = z.object({ // ============================================================================ const whmcsCatalogProductSchema = z.object({ - pid: z.number(), - gid: z.number(), - name: z.string(), - description: z.string(), - module: z.string(), - paytype: z.string(), + pid: numberLike, + gid: numberLike, + name: s, + description: s, + module: s, + paytype: s, pricing: z.record(z.string(), whmcsCatalogProductPricingCycleSchema), }); @@ -54,7 +58,7 @@ export const whmcsCatalogProductListResponseSchema = z.object({ products: z.object({ product: z.array(whmcsCatalogProductSchema), }), - totalresults: z.number(), + totalresults: numberLike, }); export type WhmcsCatalogProductListResponse = z.infer; diff --git a/packages/domain/subscriptions/providers/whmcs/raw.types.ts b/packages/domain/subscriptions/providers/whmcs/raw.types.ts index 48df46ea..e22a89d2 100644 --- a/packages/domain/subscriptions/providers/whmcs/raw.types.ts +++ b/packages/domain/subscriptions/providers/whmcs/raw.types.ts @@ -8,6 +8,11 @@ import { z } from "zod"; +import { + whmcsString as s, + whmcsNumberLike as numberLike, +} from "../../../common/providers/whmcs-utils/index.js"; + const normalizeRequiredNumber = z.preprocess(value => { if (typeof value === "number") return value; if (typeof value === "string" && value.trim().length > 0) { @@ -73,10 +78,10 @@ export const whmcsCustomFieldsContainerSchema = z.object({ }); export const whmcsConfigOptionSchema = z.object({ - id: z.union([z.string(), z.number()]).optional(), - option: z.string().optional(), - type: z.string().optional(), - value: z.string().optional(), + id: numberLike.optional(), + option: s.optional(), + type: s.optional(), + value: s.optional(), }); export const whmcsConfigOptionsContainerSchema = z.object({ @@ -92,20 +97,20 @@ export const whmcsProductRawSchema = z.object({ pid: normalizeOptionalNumber, orderid: normalizeOptionalNumber, ordernumber: optionalStringField(), - regdate: z.string(), - name: z.string(), - translated_name: z.string().optional(), - groupname: z.string().optional(), - translated_groupname: z.string().optional(), + regdate: s, + name: s, + translated_name: s.optional(), + groupname: s.optional(), + translated_groupname: s.optional(), domain: optionalStringField(), dedicatedip: optionalStringField(), serverid: normalizeOptionalNumber, servername: optionalStringField(), serverip: optionalStringField(), serverhostname: optionalStringField(), - suspensionreason: z.string().optional(), + suspensionreason: s.optional(), promoid: normalizeOptionalNumber, - subscriptionid: z.string().optional(), + subscriptionid: s.optional(), overideautosuspend: optionalStringField(), overidesuspenduntil: optionalStringField(), ns1: optionalStringField(), @@ -113,29 +118,29 @@ export const whmcsProductRawSchema = z.object({ assignedips: optionalStringField(), // Pricing - firstpaymentamount: z.union([z.string(), z.number()]).optional(), - amount: z.union([z.string(), z.number()]).optional(), - recurringamount: z.union([z.string(), z.number()]).optional(), - billingcycle: z.string().optional(), - paymentmethod: z.string().optional(), - paymentmethodname: z.string().optional(), + firstpaymentamount: numberLike.optional(), + amount: numberLike.optional(), + recurringamount: numberLike.optional(), + billingcycle: s.optional(), + paymentmethod: s.optional(), + paymentmethodname: s.optional(), // Dates - nextduedate: z.string().optional(), - nextinvoicedate: z.string().optional(), + nextduedate: s.optional(), + nextinvoicedate: s.optional(), // Status - status: z.string(), - username: z.string().optional(), - password: z.string().optional(), + status: s, + username: s.optional(), + password: s.optional(), // Notes - notes: z.string().optional(), + notes: s.optional(), diskusage: normalizeOptionalNumber, disklimit: normalizeOptionalNumber, bwusage: normalizeOptionalNumber, bwlimit: normalizeOptionalNumber, - lastupdate: z.string().optional(), + lastupdate: s.optional(), // Custom fields customfields: whmcsCustomFieldsContainerSchema.optional(), @@ -144,10 +149,10 @@ export const whmcsProductRawSchema = z.object({ // Pricing details pricing: z .object({ - amount: z.union([z.string(), z.number()]).optional(), - currency: z.string().optional(), - currencyprefix: z.string().optional(), - currencysuffix: z.string().optional(), + amount: numberLike.optional(), + currency: s.optional(), + currencyprefix: s.optional(), + currencysuffix: s.optional(), }) .optional(), }); @@ -176,12 +181,12 @@ const whmcsProductContainerSchema = z.object({ export const whmcsProductListResponseSchema = z.object({ result: z.enum(["success", "error"]).optional(), - message: z.string().optional(), - clientid: z.union([z.number(), z.string()]).optional(), - serviceid: z.union([z.number(), z.string(), z.null()]).optional(), - pid: z.union([z.number(), z.string(), z.null()]).optional(), - domain: z.string().nullable().optional(), - totalresults: z.union([z.number(), z.string()]).optional(), + message: s.optional(), + clientid: numberLike.optional(), + serviceid: z.union([numberLike, z.null()]).optional(), + pid: z.union([numberLike, z.null()]).optional(), + domain: s.nullable().optional(), + totalresults: numberLike.optional(), startnumber: normalizeOptionalNumber, numreturned: normalizeOptionalNumber, products: z.preprocess(value => {