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.
This commit is contained in:
barsa 2026-02-24 13:56:02 +09:00
parent 7bc4c14b4c
commit 5c329bbe96
9 changed files with 169 additions and 127 deletions

View File

@ -276,7 +276,7 @@ export class WhmcsInvoiceService {
}, },
}); });
const totalItems = response.totalresults || 0; const totalItems = Number(response.totalresults) || 0;
const totalPages = Math.ceil(totalItems / limit); const totalPages = Math.ceil(totalItems / limit);
return { return {
@ -353,7 +353,7 @@ export class WhmcsInvoiceService {
}); });
return { return {
id: response.invoiceid, id: Number(response.invoiceid),
number: `INV-${response.invoiceid}`, number: `INV-${response.invoiceid}`,
total: params.amount, total: params.amount,
status: response.status, status: response.status,

View File

@ -54,12 +54,12 @@ function mapItems(rawItems: unknown): InvoiceItem[] {
const itemArray = Array.isArray(parsed.item) ? parsed.item : [parsed.item]; const itemArray = Array.isArray(parsed.item) ? parsed.item : [parsed.item];
return itemArray.map(item => ({ return itemArray.map(item => ({
id: item.id, id: Number(item.id),
description: item.description, description: item.description,
amount: parseAmount(item.amount), amount: parseAmount(item.amount),
quantity: 1, quantity: 1,
type: item.type, 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 // Transform to domain model
const invoice: Invoice = { const invoice: Invoice = {
id: whmcsInvoice.invoiceid ?? 0, id: Number(whmcsInvoice.invoiceid ?? 0),
number: whmcsInvoice.invoicenum || `INV-${whmcsInvoice.invoiceid}`, number: whmcsInvoice.invoicenum || `INV-${whmcsInvoice.invoiceid}`,
status: mapStatus(whmcsInvoice.status), status: mapStatus(whmcsInvoice.status),
currency, currency,
@ -115,7 +115,7 @@ export function transformWhmcsInvoices(
return rawInvoices.map(raw => transformWhmcsInvoice(raw, options)); 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 listItem: WhmcsInvoiceListItem = whmcsInvoiceListItemSchema.parse(rawInvoice);
const invoiceid = listItem.invoiceid ?? listItem.id; const invoiceid = listItem.invoiceid ?? listItem.id;
return { return {

View File

@ -9,6 +9,10 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import {
whmcsString as s,
whmcsNumberLike as numberLike,
} from "../../../common/providers/whmcs-utils/index.js";
// ============================================================================ // ============================================================================
// Request Parameter Types // Request Parameter Types
@ -102,12 +106,12 @@ export interface WhmcsCapturePaymentParams {
// Raw WHMCS Invoice Item // Raw WHMCS Invoice Item
export const whmcsInvoiceItemRawSchema = z.object({ export const whmcsInvoiceItemRawSchema = z.object({
id: z.number(), id: numberLike,
type: z.string(), type: s,
relid: z.number(), relid: numberLike,
description: z.string(), description: s,
amount: z.union([z.string(), z.number()]), amount: numberLike,
taxed: z.number().optional(), taxed: numberLike.optional(),
}); });
export type WhmcsInvoiceItemRaw = z.infer<typeof whmcsInvoiceItemRawSchema>; export type WhmcsInvoiceItemRaw = z.infer<typeof whmcsInvoiceItemRawSchema>;
@ -121,53 +125,53 @@ export type WhmcsInvoiceItemsRaw = z.infer<typeof whmcsInvoiceItemsRawSchema>;
const whmcsInvoiceCommonSchema = z const whmcsInvoiceCommonSchema = z
.object({ .object({
invoicenum: z.string().optional(), invoicenum: s.optional(),
userid: z.number(), userid: numberLike,
date: z.string(), date: s,
duedate: z.string(), duedate: s,
subtotal: z.string(), subtotal: s,
credit: z.string(), credit: s,
tax: z.string(), tax: s,
tax2: z.string(), tax2: s,
total: z.string(), total: s,
balance: z.string().optional(), balance: s.optional(),
status: z.string(), status: s,
paymentmethod: z.string(), paymentmethod: s,
notes: z.string().optional(), notes: s.optional(),
ccgateway: z.boolean().optional(), ccgateway: z.boolean().optional(),
items: whmcsInvoiceItemsRawSchema.optional(), items: whmcsInvoiceItemsRawSchema.optional(),
transactions: z.unknown().optional(), transactions: z.unknown().optional(),
clientid: z.number().optional(), clientid: numberLike.optional(),
datecreated: z.string().optional(), datecreated: s.optional(),
paymentmethodname: z.string().optional(), paymentmethodname: s.optional(),
currencyprefix: z.string().optional(), currencyprefix: s.optional(),
currencysuffix: z.string().optional(), currencysuffix: s.optional(),
lastcaptureattempt: z.string().optional(), lastcaptureattempt: s.optional(),
last_capture_attempt: z.string().optional(), last_capture_attempt: s.optional(),
datepaid: z.string().optional(), datepaid: s.optional(),
date_refunded: z.string().optional(), date_refunded: s.optional(),
date_cancelled: z.string().optional(), date_cancelled: s.optional(),
created_at: z.string().optional(), created_at: s.optional(),
updated_at: z.string().optional(), updated_at: s.optional(),
taxrate: z.string().optional(), taxrate: s.optional(),
taxrate2: z.string().optional(), taxrate2: s.optional(),
firstname: z.string().optional(), firstname: s.optional(),
lastname: z.string().optional(), lastname: s.optional(),
companyname: z.string().optional(), companyname: s.optional(),
currencycode: z.string().optional(), currencycode: s.optional(),
}) })
.passthrough(); .passthrough();
export const whmcsInvoiceListItemSchema = whmcsInvoiceCommonSchema.extend({ export const whmcsInvoiceListItemSchema = whmcsInvoiceCommonSchema.extend({
id: z.number(), id: numberLike,
invoiceid: z.number().optional(), invoiceid: numberLike.optional(),
}); });
// Raw WHMCS Invoice (detailed GetInvoice response) // Raw WHMCS Invoice (detailed GetInvoice response)
export const whmcsInvoiceRawSchema = whmcsInvoiceCommonSchema.extend({ export const whmcsInvoiceRawSchema = whmcsInvoiceCommonSchema.extend({
invoiceid: z.number(), invoiceid: numberLike,
id: z.number().optional(), id: numberLike.optional(),
balance: z.string().optional(), balance: s.optional(),
}); });
export type WhmcsInvoiceRaw = z.infer<typeof whmcsInvoiceRawSchema>; export type WhmcsInvoiceRaw = z.infer<typeof whmcsInvoiceRawSchema>;
@ -184,9 +188,9 @@ export const whmcsInvoiceListResponseSchema = z.object({
invoices: z.object({ invoices: z.object({
invoice: z.array(whmcsInvoiceListItemSchema), invoice: z.array(whmcsInvoiceListItemSchema),
}), }),
totalresults: z.number(), totalresults: numberLike,
numreturned: z.number(), numreturned: numberLike,
startnumber: z.number(), startnumber: numberLike,
}); });
export type WhmcsInvoiceListResponse = z.infer<typeof whmcsInvoiceListResponseSchema>; export type WhmcsInvoiceListResponse = z.infer<typeof whmcsInvoiceListResponseSchema>;
@ -214,9 +218,9 @@ export type WhmcsInvoiceResponse = z.infer<typeof whmcsInvoiceResponseSchema>;
*/ */
export const whmcsCreateInvoiceResponseSchema = z.object({ export const whmcsCreateInvoiceResponseSchema = z.object({
result: z.enum(["success", "error"]), result: z.enum(["success", "error"]),
invoiceid: z.number(), invoiceid: numberLike,
status: z.string(), status: s,
message: z.string().optional(), message: s.optional(),
}); });
export type WhmcsCreateInvoiceResponse = z.infer<typeof whmcsCreateInvoiceResponseSchema>; export type WhmcsCreateInvoiceResponse = z.infer<typeof whmcsCreateInvoiceResponseSchema>;
@ -230,9 +234,9 @@ export type WhmcsCreateInvoiceResponse = z.infer<typeof whmcsCreateInvoiceRespon
*/ */
export const whmcsUpdateInvoiceResponseSchema = z.object({ export const whmcsUpdateInvoiceResponseSchema = z.object({
result: z.enum(["success", "error"]), result: z.enum(["success", "error"]),
invoiceid: z.number(), invoiceid: numberLike,
status: z.string(), status: s,
message: z.string().optional(), message: s.optional(),
}); });
export type WhmcsUpdateInvoiceResponse = z.infer<typeof whmcsUpdateInvoiceResponseSchema>; export type WhmcsUpdateInvoiceResponse = z.infer<typeof whmcsUpdateInvoiceResponseSchema>;
@ -246,13 +250,13 @@ export type WhmcsUpdateInvoiceResponse = z.infer<typeof whmcsUpdateInvoiceRespon
*/ */
export const whmcsCapturePaymentResponseSchema = z.object({ export const whmcsCapturePaymentResponseSchema = z.object({
result: z.enum(["success", "error"]), result: z.enum(["success", "error"]),
invoiceid: z.number(), invoiceid: numberLike,
status: z.string(), status: s,
transactionid: z.string().optional(), transactionid: s.optional(),
amount: z.number().optional(), amount: numberLike.optional(),
fees: z.number().optional(), fees: numberLike.optional(),
message: z.string().optional(), message: s.optional(),
error: z.string().optional(), error: s.optional(),
}); });
export type WhmcsCapturePaymentResponse = z.infer<typeof whmcsCapturePaymentResponseSchema>; export type WhmcsCapturePaymentResponse = z.infer<typeof whmcsCapturePaymentResponseSchema>;
@ -265,12 +269,12 @@ export type WhmcsCapturePaymentResponse = z.infer<typeof whmcsCapturePaymentResp
* WHMCS Currency schema * WHMCS Currency schema
*/ */
export const whmcsCurrencySchema = z.object({ export const whmcsCurrencySchema = z.object({
id: z.number(), id: numberLike,
code: z.string(), code: s,
prefix: z.string(), prefix: s,
suffix: z.string(), suffix: s,
format: z.string(), format: s,
rate: z.string(), rate: s,
}); });
export type WhmcsCurrency = z.infer<typeof whmcsCurrencySchema>; export type WhmcsCurrency = z.infer<typeof whmcsCurrencySchema>;

View File

@ -10,3 +10,4 @@ export * from "./parsing.js";
export * from "./normalize.js"; export * from "./normalize.js";
export * from "./custom-fields.js"; export * from "./custom-fields.js";
export * from "./php-serialize.js"; export * from "./php-serialize.js";
export * from "./schema.js";

View File

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

View File

@ -43,7 +43,7 @@ export function transformWhmcsPaymentMethod(raw: unknown): PaymentMethod {
const whmcs = whmcsPaymentMethodRawSchema.parse(raw); const whmcs = whmcsPaymentMethodRawSchema.parse(raw);
const paymentMethod: PaymentMethod = { const paymentMethod: PaymentMethod = {
id: whmcs.id, id: Number(whmcs.id),
type: mapPaymentMethodType(whmcs.payment_type || whmcs.type || "manual"), type: mapPaymentMethodType(whmcs.payment_type || whmcs.type || "manual"),
description: whmcs.description, description: whmcs.description,
gatewayName: whmcs.gateway_name || whmcs.gateway, gatewayName: whmcs.gateway_name || whmcs.gateway,

View File

@ -113,7 +113,7 @@ export function transformWhmcsCatalogProductsResponse(
return { return {
id: String(product.pid), id: String(product.pid),
groupId: product.gid, groupId: Number(product.gid),
name: product.name, name: product.name,
description: product.description, description: product.description,
module: product.module, module: product.module,

View File

@ -5,26 +5,30 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import {
whmcsString as s,
whmcsNumberLike as numberLike,
} from "../../../common/providers/whmcs-utils/index.js";
// ============================================================================ // ============================================================================
// WHMCS Catalog Product Pricing Cycle // WHMCS Catalog Product Pricing Cycle
// ============================================================================ // ============================================================================
const whmcsCatalogProductPricingCycleSchema = z.object({ const whmcsCatalogProductPricingCycleSchema = z.object({
prefix: z.string(), prefix: s,
suffix: z.string(), suffix: s,
msetupfee: z.string(), msetupfee: s,
qsetupfee: z.string(), qsetupfee: s,
ssetupfee: z.string(), ssetupfee: s,
asetupfee: z.string(), asetupfee: s,
bsetupfee: z.string(), bsetupfee: s,
tsetupfee: z.string(), tsetupfee: s,
monthly: z.string(), monthly: s,
quarterly: z.string(), quarterly: s,
semiannually: z.string(), semiannually: s,
annually: z.string(), annually: s,
biennially: z.string(), biennially: s,
triennially: z.string(), triennially: s,
}); });
// ============================================================================ // ============================================================================
@ -32,12 +36,12 @@ const whmcsCatalogProductPricingCycleSchema = z.object({
// ============================================================================ // ============================================================================
const whmcsCatalogProductSchema = z.object({ const whmcsCatalogProductSchema = z.object({
pid: z.number(), pid: numberLike,
gid: z.number(), gid: numberLike,
name: z.string(), name: s,
description: z.string(), description: s,
module: z.string(), module: s,
paytype: z.string(), paytype: s,
pricing: z.record(z.string(), whmcsCatalogProductPricingCycleSchema), pricing: z.record(z.string(), whmcsCatalogProductPricingCycleSchema),
}); });
@ -54,7 +58,7 @@ export const whmcsCatalogProductListResponseSchema = z.object({
products: z.object({ products: z.object({
product: z.array(whmcsCatalogProductSchema), product: z.array(whmcsCatalogProductSchema),
}), }),
totalresults: z.number(), totalresults: numberLike,
}); });
export type WhmcsCatalogProductListResponse = z.infer<typeof whmcsCatalogProductListResponseSchema>; export type WhmcsCatalogProductListResponse = z.infer<typeof whmcsCatalogProductListResponseSchema>;

View File

@ -8,6 +8,11 @@
import { z } from "zod"; import { z } from "zod";
import {
whmcsString as s,
whmcsNumberLike as numberLike,
} from "../../../common/providers/whmcs-utils/index.js";
const normalizeRequiredNumber = z.preprocess(value => { const normalizeRequiredNumber = z.preprocess(value => {
if (typeof value === "number") return value; if (typeof value === "number") return value;
if (typeof value === "string" && value.trim().length > 0) { if (typeof value === "string" && value.trim().length > 0) {
@ -73,10 +78,10 @@ export const whmcsCustomFieldsContainerSchema = z.object({
}); });
export const whmcsConfigOptionSchema = z.object({ export const whmcsConfigOptionSchema = z.object({
id: z.union([z.string(), z.number()]).optional(), id: numberLike.optional(),
option: z.string().optional(), option: s.optional(),
type: z.string().optional(), type: s.optional(),
value: z.string().optional(), value: s.optional(),
}); });
export const whmcsConfigOptionsContainerSchema = z.object({ export const whmcsConfigOptionsContainerSchema = z.object({
@ -92,20 +97,20 @@ export const whmcsProductRawSchema = z.object({
pid: normalizeOptionalNumber, pid: normalizeOptionalNumber,
orderid: normalizeOptionalNumber, orderid: normalizeOptionalNumber,
ordernumber: optionalStringField(), ordernumber: optionalStringField(),
regdate: z.string(), regdate: s,
name: z.string(), name: s,
translated_name: z.string().optional(), translated_name: s.optional(),
groupname: z.string().optional(), groupname: s.optional(),
translated_groupname: z.string().optional(), translated_groupname: s.optional(),
domain: optionalStringField(), domain: optionalStringField(),
dedicatedip: optionalStringField(), dedicatedip: optionalStringField(),
serverid: normalizeOptionalNumber, serverid: normalizeOptionalNumber,
servername: optionalStringField(), servername: optionalStringField(),
serverip: optionalStringField(), serverip: optionalStringField(),
serverhostname: optionalStringField(), serverhostname: optionalStringField(),
suspensionreason: z.string().optional(), suspensionreason: s.optional(),
promoid: normalizeOptionalNumber, promoid: normalizeOptionalNumber,
subscriptionid: z.string().optional(), subscriptionid: s.optional(),
overideautosuspend: optionalStringField(), overideautosuspend: optionalStringField(),
overidesuspenduntil: optionalStringField(), overidesuspenduntil: optionalStringField(),
ns1: optionalStringField(), ns1: optionalStringField(),
@ -113,29 +118,29 @@ export const whmcsProductRawSchema = z.object({
assignedips: optionalStringField(), assignedips: optionalStringField(),
// Pricing // Pricing
firstpaymentamount: z.union([z.string(), z.number()]).optional(), firstpaymentamount: numberLike.optional(),
amount: z.union([z.string(), z.number()]).optional(), amount: numberLike.optional(),
recurringamount: z.union([z.string(), z.number()]).optional(), recurringamount: numberLike.optional(),
billingcycle: z.string().optional(), billingcycle: s.optional(),
paymentmethod: z.string().optional(), paymentmethod: s.optional(),
paymentmethodname: z.string().optional(), paymentmethodname: s.optional(),
// Dates // Dates
nextduedate: z.string().optional(), nextduedate: s.optional(),
nextinvoicedate: z.string().optional(), nextinvoicedate: s.optional(),
// Status // Status
status: z.string(), status: s,
username: z.string().optional(), username: s.optional(),
password: z.string().optional(), password: s.optional(),
// Notes // Notes
notes: z.string().optional(), notes: s.optional(),
diskusage: normalizeOptionalNumber, diskusage: normalizeOptionalNumber,
disklimit: normalizeOptionalNumber, disklimit: normalizeOptionalNumber,
bwusage: normalizeOptionalNumber, bwusage: normalizeOptionalNumber,
bwlimit: normalizeOptionalNumber, bwlimit: normalizeOptionalNumber,
lastupdate: z.string().optional(), lastupdate: s.optional(),
// Custom fields // Custom fields
customfields: whmcsCustomFieldsContainerSchema.optional(), customfields: whmcsCustomFieldsContainerSchema.optional(),
@ -144,10 +149,10 @@ export const whmcsProductRawSchema = z.object({
// Pricing details // Pricing details
pricing: z pricing: z
.object({ .object({
amount: z.union([z.string(), z.number()]).optional(), amount: numberLike.optional(),
currency: z.string().optional(), currency: s.optional(),
currencyprefix: z.string().optional(), currencyprefix: s.optional(),
currencysuffix: z.string().optional(), currencysuffix: s.optional(),
}) })
.optional(), .optional(),
}); });
@ -176,12 +181,12 @@ const whmcsProductContainerSchema = z.object({
export const whmcsProductListResponseSchema = z.object({ export const whmcsProductListResponseSchema = z.object({
result: z.enum(["success", "error"]).optional(), result: z.enum(["success", "error"]).optional(),
message: z.string().optional(), message: s.optional(),
clientid: z.union([z.number(), z.string()]).optional(), clientid: numberLike.optional(),
serviceid: z.union([z.number(), z.string(), z.null()]).optional(), serviceid: z.union([numberLike, z.null()]).optional(),
pid: z.union([z.number(), z.string(), z.null()]).optional(), pid: z.union([numberLike, z.null()]).optional(),
domain: z.string().nullable().optional(), domain: s.nullable().optional(),
totalresults: z.union([z.number(), z.string()]).optional(), totalresults: numberLike.optional(),
startnumber: normalizeOptionalNumber, startnumber: normalizeOptionalNumber,
numreturned: normalizeOptionalNumber, numreturned: normalizeOptionalNumber,
products: z.preprocess(value => { products: z.preprocess(value => {