refactor: enhance account status handling and error messaging in auth guards

- Introduced a new `AccountStatusResult` interface to standardize account status detection across systems.
- Updated the `VerificationWorkflowService` to merge handoff data with discovered account status.
- Enhanced error handling in `GlobalAuthGuard` and `LocalAuthGuard` to include structured error codes for better clarity in unauthorized responses.
- Refined WHMCS and Salesforce integration schemas to ensure consistent data validation and coercion.
This commit is contained in:
barsa 2026-03-02 18:00:41 +09:00
parent 29b511e44c
commit 230a61c520
17 changed files with 224 additions and 196 deletions

View File

@ -10,6 +10,8 @@
**Use dedicated tools, not Bash** — Read (not `cat`/`head`/`tail`), Glob (not `find`/`ls`), Grep (not `grep`/`rg`), Edit (not `sed`/`awk`). This applies to all agents and subagents.
**Never run destructive git commands** — `git stash`, `git checkout -- .`, `git restore .`, `git reset --hard`, `git clean`, or any command that discards uncommitted work. If you need to check pre-existing state, use `git diff` or `git status` — never stash or discard the working tree.
## Commands
```bash

View File

@ -21,6 +21,20 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { OtpService } from "../otp/otp.service.js";
import { GetStartedSessionService } from "../otp/get-started-session.service.js";
/**
* Result of account status detection across Portal, WHMCS, and Salesforce.
* Optional fields are populated based on the detected status:
* - WHMCS_UNMAPPED: whmcsClientId, whmcsFirstName, whmcsLastName
* - SF_UNMAPPED: sfAccountId
*/
interface AccountStatusResult {
status: AccountStatus;
sfAccountId?: string;
whmcsClientId?: number;
whmcsFirstName?: string;
whmcsLastName?: string;
}
/**
* Verification Workflow Service
*
@ -98,10 +112,20 @@ export class VerificationWorkflowService {
const sessionToken = await this.sessionService.create(normalizedEmail);
// Check account status across all systems
const accountStatus = await this.determineAccountStatus(normalizedEmail);
const discoveredStatus = await this.determineAccountStatus(normalizedEmail);
// Get prefill data (including handoff token data if provided)
const prefill = await this.resolvePrefillData(normalizedEmail, accountStatus, handoffToken);
const { prefill, sfAccountId: handoffSfAccountId } = await this.resolvePrefillData(
normalizedEmail,
discoveredStatus,
handoffToken
);
// Merge handoff SF account ID if discovery didn't find one
const accountStatus: AccountStatusResult =
!discoveredStatus.sfAccountId && handoffSfAccountId
? { ...discoveredStatus, sfAccountId: handoffSfAccountId }
: discoveredStatus;
// Update session with verified status and account info
const prefillData = this.buildSessionPrefillData(prefill, accountStatus);
@ -177,13 +201,7 @@ export class VerificationWorkflowService {
};
}
private async determineAccountStatus(email: string): Promise<{
status: AccountStatus;
sfAccountId?: string;
whmcsClientId?: number;
whmcsFirstName?: string;
whmcsLastName?: string;
}> {
private async determineAccountStatus(email: string): Promise<AccountStatusResult> {
// Check Portal user first
const portalUser = await this.usersService.findByEmailInternal(email);
if (portalUser) {
@ -223,12 +241,7 @@ export class VerificationWorkflowService {
private getPrefillData(
email: string,
accountStatus: {
status: AccountStatus;
sfAccountId?: string;
whmcsFirstName?: string;
whmcsLastName?: string;
}
accountStatus: AccountStatusResult
): VerifyCodeResponse["prefill"] {
if (accountStatus.status === ACCOUNT_STATUS.WHMCS_UNMAPPED) {
return {
@ -245,24 +258,24 @@ export class VerificationWorkflowService {
private async resolvePrefillData(
email: string,
accountStatus: { status: AccountStatus; sfAccountId?: string; whmcsClientId?: number },
accountStatus: AccountStatusResult,
handoffToken?: string
): Promise<VerifyCodeResponse["prefill"]> {
let prefill = this.getPrefillData(email, accountStatus);
): Promise<{ prefill: VerifyCodeResponse["prefill"]; sfAccountId?: string }> {
const prefill = this.getPrefillData(email, accountStatus);
if (!handoffToken) {
return prefill;
return { prefill };
}
const handoffData = await this.sessionService.getGuestHandoffToken(handoffToken, email);
if (!handoffData) {
return prefill;
return { prefill };
}
this.logger.debug({ email, handoffToken }, "Applying handoff token data to session");
prefill = {
const mergedPrefill = {
...prefill,
firstName: handoffData.firstName,
lastName: handoffData.lastName,
@ -270,18 +283,14 @@ export class VerificationWorkflowService {
address: handoffData.address,
};
if (!accountStatus.sfAccountId && handoffData.sfAccountId) {
accountStatus.sfAccountId = handoffData.sfAccountId;
}
await this.sessionService.invalidateHandoffToken(handoffToken);
return prefill;
return { prefill: mergedPrefill, sfAccountId: handoffData.sfAccountId };
}
private buildSessionPrefillData(
prefill: VerifyCodeResponse["prefill"],
accountStatus: { status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }
accountStatus: AccountStatusResult
): Record<string, unknown> {
return {
...(prefill?.firstName && { firstName: prefill.firstName }),

View File

@ -2,6 +2,7 @@ import { Injectable, Inject, UnauthorizedException } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { Reflector } from "@nestjs/core";
import { ErrorCode } from "@customer-portal/domain/common";
import type { Request } from "express";
@ -94,7 +95,10 @@ export class GlobalAuthGuard implements CanActivate {
if (isLogoutRoute) {
return true;
}
throw new UnauthorizedException("Missing token");
throw new UnauthorizedException({
message: "Missing token",
code: ErrorCode.TOKEN_INVALID,
});
}
await this.attachUserFromToken(request, token, route);
@ -119,7 +123,10 @@ export class GlobalAuthGuard implements CanActivate {
const rawRequest = context.switchToHttp().getRequest<unknown>();
if (!this.isRequestWithRoute(rawRequest)) {
this.logger.error("Unable to determine HTTP request in auth guard");
throw new UnauthorizedException("Invalid request context");
throw new UnauthorizedException({
message: "Invalid request context",
code: ErrorCode.TOKEN_INVALID,
});
}
return rawRequest;
}
@ -174,20 +181,32 @@ export class GlobalAuthGuard implements CanActivate {
const tokenType = (payload as { type?: unknown }).type;
if (typeof tokenType === "string" && tokenType !== "access") {
throw new UnauthorizedException("Invalid access token");
throw new UnauthorizedException({
message: "Invalid access token",
code: ErrorCode.TOKEN_INVALID,
});
}
if (!payload.sub || !payload.email) {
throw new UnauthorizedException("Invalid token payload");
throw new UnauthorizedException({
message: "Invalid token payload",
code: ErrorCode.TOKEN_INVALID,
});
}
// Explicit expiry buffer check to avoid tokens expiring mid-request
if (typeof payload.exp !== "number") {
throw new UnauthorizedException("Token missing expiration claim");
throw new UnauthorizedException({
message: "Token missing expiration claim",
code: ErrorCode.TOKEN_INVALID,
});
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp < nowSeconds + 60) {
throw new UnauthorizedException("Token expired or expiring soon");
throw new UnauthorizedException({
message: "Token expired or expiring soon",
code: ErrorCode.SESSION_EXPIRED,
});
}
// Then check token blacklist
@ -196,15 +215,21 @@ export class GlobalAuthGuard implements CanActivate {
if (route) {
this.logger.warn(`Blacklisted token attempted access to: ${route}`);
}
throw new UnauthorizedException("Token has been revoked");
throw new UnauthorizedException({
message: "Token has been revoked",
code: ErrorCode.TOKEN_REVOKED,
});
}
const prismaUser = await this.usersService.findByIdInternal(payload.sub);
if (!prismaUser) {
throw new UnauthorizedException("User not found");
throw new UnauthorizedException({ message: "User not found", code: ErrorCode.TOKEN_INVALID });
}
if (prismaUser.email !== payload.email) {
throw new UnauthorizedException("Token subject does not match user record");
throw new UnauthorizedException({
message: "Token subject does not match user record",
code: ErrorCode.TOKEN_INVALID,
});
}
const profile: UserAuth = mapPrismaUserToDomain(prismaUser);

View File

@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common";
import type { Request } from "express";
import { AuthOrchestrator } from "@bff/modules/auth/application/auth-orchestrator.service.js";
import { ErrorCode } from "@customer-portal/domain/common";
@Injectable()
export class LocalAuthGuard implements CanActivate {
@ -15,12 +16,18 @@ export class LocalAuthGuard implements CanActivate {
const password = typeof body["password"] === "string" ? body["password"] : "";
if (!email || !password) {
throw new UnauthorizedException("Invalid credentials");
throw new UnauthorizedException({
message: "Invalid credentials",
code: ErrorCode.INVALID_CREDENTIALS,
});
}
const user = await this.authOrchestrator.validateUser(email, password, request);
if (!user) {
throw new UnauthorizedException("Invalid credentials");
throw new UnauthorizedException({
message: "Invalid credentials",
code: ErrorCode.INVALID_CREDENTIALS,
});
}
// Attach user to request (replaces Passport's req.user behavior)

View File

@ -54,12 +54,12 @@ function mapItems(rawItems: unknown): InvoiceItem[] {
const itemArray = Array.isArray(parsed.item) ? parsed.item : [parsed.item];
return itemArray.map(item => ({
id: Number(item.id),
id: item.id,
description: item.description,
amount: parseAmount(item.amount),
quantity: 1,
type: item.type,
serviceId: Number(item.relid) > 0 ? Number(item.relid) : undefined,
serviceId: item.relid > 0 ? item.relid : undefined,
}));
}
@ -86,7 +86,7 @@ export function transformWhmcsInvoice(
// Transform to domain model
const invoice: Invoice = {
id: Number(whmcsInvoice.invoiceid ?? 0),
id: whmcsInvoice.invoiceid ?? 0,
number: whmcsInvoice.invoicenum || `INV-${whmcsInvoice.invoiceid}`,
status: mapStatus(whmcsInvoice.status),
currency,

View File

@ -11,7 +11,8 @@
import { z } from "zod";
import {
whmcsString as s,
whmcsNumberLike as numberLike,
whmcsRequiredNumber,
whmcsOptionalNumber,
} from "../../../common/providers/whmcs-utils/index.js";
// ============================================================================
@ -106,12 +107,12 @@ export interface WhmcsCapturePaymentParams {
// Raw WHMCS Invoice Item
export const whmcsInvoiceItemRawSchema = z.object({
id: numberLike,
id: whmcsRequiredNumber,
type: s,
relid: numberLike,
relid: whmcsRequiredNumber,
description: s,
amount: numberLike,
taxed: numberLike.optional(),
amount: s,
taxed: s.optional(),
});
export type WhmcsInvoiceItemRaw = z.infer<typeof whmcsInvoiceItemRawSchema>;
@ -126,7 +127,7 @@ export type WhmcsInvoiceItemsRaw = z.infer<typeof whmcsInvoiceItemsRawSchema>;
const whmcsInvoiceCommonSchema = z
.object({
invoicenum: s.optional(),
userid: numberLike,
userid: whmcsRequiredNumber,
date: s,
duedate: s,
subtotal: s,
@ -141,7 +142,7 @@ const whmcsInvoiceCommonSchema = z
ccgateway: z.boolean().optional(),
items: whmcsInvoiceItemsRawSchema.optional(),
transactions: z.unknown().optional(),
clientid: numberLike.optional(),
clientid: whmcsOptionalNumber,
datecreated: s.optional(),
paymentmethodname: s.optional(),
currencyprefix: s.optional(),
@ -163,14 +164,14 @@ const whmcsInvoiceCommonSchema = z
.strip();
export const whmcsInvoiceListItemSchema = whmcsInvoiceCommonSchema.extend({
id: numberLike,
invoiceid: numberLike.optional(),
id: whmcsRequiredNumber,
invoiceid: whmcsOptionalNumber,
});
// Raw WHMCS Invoice (detailed GetInvoice response)
export const whmcsInvoiceRawSchema = whmcsInvoiceCommonSchema.extend({
invoiceid: numberLike,
id: numberLike.optional(),
invoiceid: whmcsRequiredNumber,
id: whmcsOptionalNumber,
balance: s.optional(),
});
@ -188,9 +189,9 @@ export const whmcsInvoiceListResponseSchema = z.object({
invoices: z.object({
invoice: z.array(whmcsInvoiceListItemSchema),
}),
totalresults: numberLike,
numreturned: numberLike,
startnumber: numberLike,
totalresults: whmcsRequiredNumber,
numreturned: whmcsRequiredNumber,
startnumber: whmcsRequiredNumber,
});
export type WhmcsInvoiceListResponse = z.infer<typeof whmcsInvoiceListResponseSchema>;
@ -218,7 +219,7 @@ export type WhmcsInvoiceResponse = z.infer<typeof whmcsInvoiceResponseSchema>;
*/
export const whmcsCreateInvoiceResponseSchema = z.object({
result: z.enum(["success", "error"]),
invoiceid: numberLike,
invoiceid: whmcsRequiredNumber,
status: s,
message: s.optional(),
});
@ -234,7 +235,7 @@ export type WhmcsCreateInvoiceResponse = z.infer<typeof whmcsCreateInvoiceRespon
*/
export const whmcsUpdateInvoiceResponseSchema = z.object({
result: z.enum(["success", "error"]),
invoiceid: numberLike,
invoiceid: whmcsRequiredNumber,
status: s,
message: s.optional(),
});
@ -250,11 +251,11 @@ export type WhmcsUpdateInvoiceResponse = z.infer<typeof whmcsUpdateInvoiceRespon
*/
export const whmcsCapturePaymentResponseSchema = z.object({
result: z.enum(["success", "error"]),
invoiceid: numberLike,
invoiceid: whmcsRequiredNumber,
status: s,
transactionid: s.optional(),
amount: numberLike.optional(),
fees: numberLike.optional(),
amount: s.optional(),
fees: s.optional(),
message: s.optional(),
error: s.optional(),
});
@ -269,7 +270,7 @@ export type WhmcsCapturePaymentResponse = z.infer<typeof whmcsCapturePaymentResp
* WHMCS Currency schema
*/
export const whmcsCurrencySchema = z.object({
id: numberLike,
id: whmcsRequiredNumber,
code: s,
prefix: s,
suffix: s,

View File

@ -54,3 +54,39 @@ export const whmcsOptionalNumber = z.preprocess((value): number | undefined => {
}
return undefined;
}, z.number().optional());
// ---------------------------------------------------------------------------
// Boolean coercion helpers
// ---------------------------------------------------------------------------
/**
* Coerce a WHMCS boolean-like value to a real boolean.
*
* Truthy: `true`, `1`, `"1"`, `"true"`, `"yes"`, `"on"`
* Everything else `false`
*/
function toBool(value: unknown): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value === 1;
if (typeof value === "string") {
const n = value.trim().toLowerCase();
return n === "1" || n === "true" || n === "yes" || n === "on";
}
return false;
}
/**
* Coercing required boolean accepts boolean, number (0/1), or string, always outputs boolean.
* Use for WHMCS boolean flags that must resolve to true/false.
*/
export const whmcsBoolean = z.preprocess(toBool, z.boolean());
/**
* Coercing optional boolean accepts boolean, number, string, null, or undefined.
* Returns `undefined` for null/undefined, coerces everything else to boolean.
* Use for WHMCS boolean flags that may be absent.
*/
export const whmcsOptionalBoolean = z.preprocess((value): boolean | undefined => {
if (value === undefined || value === null) return undefined;
return toBool(value);
}, z.boolean().optional());

View File

@ -56,7 +56,7 @@ function normalizeAddress(client: WhmcsRawClient): Address | undefined {
country: client.country ?? null,
countryCode: client.countrycode ?? null,
phoneNumber: client.phonenumberformatted ?? client.phonenumber ?? null,
phoneCountryCode: client.phonecc == null ? null : String(client.phonecc),
phoneCountryCode: client.phonecc ?? null,
});
const hasValues = Object.values(address).some(v => v !== undefined && v !== null && v !== "");

View File

@ -1,5 +1,12 @@
import { z } from "zod";
import {
whmcsRequiredNumber,
whmcsOptionalNumber,
whmcsOptionalBoolean,
whmcsString,
} from "../../../common/providers/whmcs-utils/index.js";
// ============================================================================
// Request Parameter Types
// ============================================================================
@ -57,12 +64,9 @@ export interface WhmcsCreateSsoTokenParams {
// Response Types
// ============================================================================
const booleanLike = z.union([z.boolean(), z.number(), z.string()]);
const numberLike = z.union([z.number(), z.string()]);
export const whmcsCustomFieldSchema = z
.object({
id: numberLike,
id: whmcsRequiredNumber,
value: z.string().optional().nullable(),
name: z.string().optional(),
type: z.string().optional(),
@ -71,10 +75,10 @@ export const whmcsCustomFieldSchema = z
export const whmcsUserSchema = z
.object({
id: numberLike,
id: whmcsRequiredNumber,
name: z.string(),
email: z.string(),
is_owner: booleanLike.optional(),
is_owner: whmcsOptionalBoolean,
})
.strip();
@ -100,10 +104,10 @@ const usersSchema = z
export const whmcsClientSchema = z
.object({
client_id: numberLike.optional(),
owner_user_id: numberLike.optional(),
userid: numberLike.optional(),
id: numberLike,
client_id: whmcsOptionalNumber,
owner_user_id: whmcsOptionalNumber,
userid: whmcsOptionalNumber,
id: whmcsRequiredNumber,
uuid: z.string().optional(),
firstname: z.string().optional(),
lastname: z.string().optional(),
@ -119,25 +123,25 @@ export const whmcsClientSchema = z
postcode: z.string().optional(),
country: z.string().optional(),
countrycode: z.string().optional(),
phonecc: numberLike.optional(),
phonecc: whmcsString.optional(),
phonenumber: z.string().optional(),
phonenumberformatted: z.string().optional(),
telephoneNumber: z.string().optional(),
tax_id: z.string().optional(),
currency: numberLike.optional(),
currency: whmcsOptionalNumber,
currency_code: z.string().optional(),
defaultgateway: z.string().optional(),
defaultpaymethodid: numberLike.optional(),
defaultpaymethodid: whmcsOptionalNumber,
language: z.string().optional(),
status: z.string().optional(),
notes: z.string().optional(),
datecreated: z.string().optional(),
lastlogin: z.string().optional(),
email_preferences: whmcsEmailPreferencesSchema,
allowSingleSignOn: booleanLike.optional(),
email_verified: booleanLike.optional(),
marketing_emails_opt_in: booleanLike.optional(),
isOptedInToMarketingEmails: booleanLike.optional(),
allowSingleSignOn: whmcsOptionalBoolean,
email_verified: whmcsOptionalBoolean,
marketing_emails_opt_in: whmcsOptionalBoolean,
isOptedInToMarketingEmails: whmcsOptionalBoolean,
phoneNumber: z.string().optional(),
customfields: customFieldsSchema,
users: usersSchema,
@ -151,7 +155,7 @@ export const whmcsClientStatsSchema = z
export const whmcsClientResponseSchema = z
.object({
result: z.string().optional(),
client_id: numberLike.optional(),
client_id: whmcsOptionalNumber,
client: whmcsClientSchema,
stats: whmcsClientStatsSchema,
})
@ -215,12 +219,12 @@ export type WhmcsClientStats = z.infer<typeof whmcsClientStatsSchema>;
* @see https://developers.whmcs.com/api-reference/getusers/
*/
export const whmcsUserClientAssociationSchema = z.object({
id: numberLike,
id: whmcsRequiredNumber,
isOwner: z.boolean().optional(),
});
export const whmcsGetUsersUserSchema = z.object({
id: numberLike,
id: whmcsRequiredNumber,
firstname: z.string().optional(),
lastname: z.string().optional(),
email: z.string(),
@ -230,9 +234,9 @@ export const whmcsGetUsersUserSchema = z.object({
});
export const whmcsGetUsersResponseSchema = z.object({
totalresults: numberLike,
startnumber: numberLike.optional(),
numreturned: numberLike.optional(),
totalresults: whmcsRequiredNumber,
startnumber: whmcsOptionalNumber,
numreturned: whmcsOptionalNumber,
users: z.array(whmcsGetUsersUserSchema).optional().default([]),
});

View File

@ -14,8 +14,9 @@ import { z } from "zod";
import { countryCodeSchema } from "../common/schema.js";
import {
whmcsNumberLike as numberLike,
whmcsBooleanLike as booleanLike,
whmcsRequiredNumber,
whmcsOptionalNumber,
whmcsOptionalBoolean,
} from "../common/providers/whmcs-utils/index.js";
import {
whmcsClientSchema as whmcsRawClientSchema,
@ -28,23 +29,6 @@ import {
const stringOrNull = z.union([z.string(), z.null()]);
/**
* Normalize boolean-like values to actual booleans
*/
const normalizeBoolean = (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 === 1;
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return (
normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"
);
}
return null;
};
// ============================================================================
// Address Schemas
// ============================================================================
@ -132,41 +116,25 @@ export const userAuthSchema = z.object({
* Email preferences from WHMCS
* Internal to Providers.Whmcs namespace
*/
const emailPreferencesSchema = z
.object({
general: booleanLike.optional(),
invoice: booleanLike.optional(),
support: booleanLike.optional(),
product: booleanLike.optional(),
domain: booleanLike.optional(),
affiliate: booleanLike.optional(),
})
.transform(prefs => ({
general: normalizeBoolean(prefs.general),
invoice: normalizeBoolean(prefs.invoice),
support: normalizeBoolean(prefs.support),
product: normalizeBoolean(prefs.product),
domain: normalizeBoolean(prefs.domain),
affiliate: normalizeBoolean(prefs.affiliate),
}));
const emailPreferencesSchema = z.object({
general: whmcsOptionalBoolean,
invoice: whmcsOptionalBoolean,
support: whmcsOptionalBoolean,
product: whmcsOptionalBoolean,
domain: whmcsOptionalBoolean,
affiliate: whmcsOptionalBoolean,
});
/**
* Sub-user from WHMCS
* Internal to Providers.Whmcs namespace
*/
const subUserSchema = z
.object({
id: numberLike,
name: z.string(),
email: z.string(),
is_owner: booleanLike.optional(),
})
.transform(user => ({
id: Number(user.id),
name: user.name,
email: user.email,
is_owner: normalizeBoolean(user.is_owner),
}));
const subUserSchema = z.object({
id: whmcsRequiredNumber,
name: z.string(),
email: z.string(),
is_owner: whmcsOptionalBoolean,
});
/**
* Billing stats from WHMCS
@ -243,38 +211,18 @@ const nullableProfileOverrides = nullableProfileFields.reduce<Record<string, z.Z
export const whmcsClientSchema = whmcsRawClientSchema
.extend({
...nullableProfileOverrides,
// Allow nullable numeric strings
defaultpaymethodid: numberLike.nullable().optional(),
currency: numberLike.nullable().optional(),
allowSingleSignOn: booleanLike.nullable().optional(),
email_verified: booleanLike.nullable().optional(),
marketing_emails_opt_in: booleanLike.nullable().optional(),
defaultpaymethodid: whmcsOptionalNumber.nullable(),
currency: whmcsOptionalNumber.nullable(),
allowSingleSignOn: whmcsOptionalBoolean.nullable(),
email_verified: whmcsOptionalBoolean.nullable(),
marketing_emails_opt_in: whmcsOptionalBoolean.nullable(),
address: addressSchema.nullable().optional(),
email_preferences: emailPreferencesSchema.nullable().optional(),
customfields: whmcsCustomFieldsSchema,
users: whmcsUsersSchema,
stats: statsSchema.optional(),
})
.transform(raw => {
const coerceNumber = (value: unknown) =>
value === null || value === undefined ? null : Number(value);
const coerceOptionalNumber = (value: unknown) =>
value === null || value === undefined ? undefined : Number(value);
return {
...raw,
id: Number(raw.id),
client_id: coerceOptionalNumber((raw as Record<string, unknown>)["client_id"]),
owner_user_id: coerceOptionalNumber((raw as Record<string, unknown>)["owner_user_id"]),
userid: coerceOptionalNumber((raw as Record<string, unknown>)["userid"]),
allowSingleSignOn: normalizeBoolean(raw.allowSingleSignOn),
email_verified: normalizeBoolean(raw.email_verified),
marketing_emails_opt_in: normalizeBoolean(raw.marketing_emails_opt_in),
defaultpaymethodid: coerceNumber(raw.defaultpaymethodid),
currency: coerceNumber(raw.currency),
};
});
.transform(raw => ({ ...raw }));
// ============================================================================
// User Schema (API Response - Normalized camelCase)

View File

@ -21,6 +21,11 @@
import { z } from "zod";
import {
whmcsRequiredNumber,
whmcsOptionalNumber,
} from "../../../common/providers/whmcs-utils/index.js";
// ============================================================================
// WHMCS Order Item Schema
// ============================================================================
@ -91,8 +96,8 @@ export type WhmcsAddOrderPayload = z.infer<typeof whmcsAddOrderPayloadSchema>;
// ============================================================================
export const whmcsAddOrderResponseSchema = z.object({
orderid: z.union([z.string(), z.number()]),
invoiceid: z.union([z.string(), z.number()]).optional(),
orderid: whmcsRequiredNumber,
invoiceid: whmcsOptionalNumber,
serviceids: z.string().optional(),
addonids: z.string().optional(),
domainids: z.string().optional(),
@ -115,8 +120,8 @@ export type WhmcsOrderResult = z.infer<typeof whmcsOrderResultSchema>;
// ============================================================================
export const whmcsAcceptOrderResponseSchema = z.object({
orderid: z.union([z.string(), z.number()]).optional(),
invoiceid: z.union([z.string(), z.number()]).optional(),
orderid: whmcsOptionalNumber,
invoiceid: whmcsOptionalNumber,
serviceids: z.string().optional(),
addonids: z.string().optional(),
domainids: z.string().optional(),

View File

@ -32,18 +32,11 @@ function mapGatewayType(type: string): PaymentGateway["type"] {
return GATEWAY_TYPE_MAP[normalized] ?? "manual";
}
function coerceBoolean(value: boolean | number | string | undefined): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value === 1;
if (typeof value === "string") return value === "1" || value.toLowerCase() === "true";
return false;
}
export function transformWhmcsPaymentMethod(raw: unknown): PaymentMethod {
const whmcs = whmcsPaymentMethodRawSchema.parse(raw);
const paymentMethod: PaymentMethod = {
id: Number(whmcs.id),
id: whmcs.id,
type: mapPaymentMethodType(whmcs.payment_type || whmcs.type || "manual"),
description: whmcs.description,
gatewayName: whmcs.gateway_name || whmcs.gateway,
@ -53,7 +46,7 @@ export function transformWhmcsPaymentMethod(raw: unknown): PaymentMethod {
bankName: whmcs.bank_name,
remoteToken: whmcs.remote_token,
lastUpdated: whmcs.last_updated,
isDefault: coerceBoolean(whmcs.is_default),
isDefault: whmcs.is_default ?? false,
};
return paymentMethodSchema.parse(paymentMethod);
@ -66,7 +59,7 @@ export function transformWhmcsPaymentGateway(raw: unknown): PaymentGateway {
name: whmcs.name,
displayName: whmcs.display_name || whmcs.name,
type: mapGatewayType(whmcs.type),
isActive: coerceBoolean(whmcs.visible),
isActive: whmcs.visible ?? false,
configuration: whmcs.configuration,
};

View File

@ -7,6 +7,10 @@
*/
import { z } from "zod";
import {
whmcsOptionalBoolean,
whmcsRequiredNumber,
} from "../../../common/providers/whmcs-utils/index.js";
// ============================================================================
// Request Parameter Types
@ -38,7 +42,7 @@ export const whmcsPaymentMethodRawSchema = z.object({
bank_name: z.string().optional(),
remote_token: z.string().optional(),
last_updated: z.string().optional(),
is_default: z.union([z.boolean(), z.number(), z.string()]).optional(),
is_default: whmcsOptionalBoolean,
});
export type WhmcsPaymentMethodRaw = z.infer<typeof whmcsPaymentMethodRawSchema>;
@ -47,7 +51,7 @@ export const whmcsPaymentGatewayRawSchema = z.object({
name: z.string(),
display_name: z.string().optional(),
type: z.string(),
visible: z.union([z.boolean(), z.number(), z.string()]).optional(),
visible: whmcsOptionalBoolean,
configuration: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))
.optional(),
@ -85,7 +89,7 @@ export type WhmcsPaymentMethod = z.infer<typeof whmcsPaymentMethodSchema>;
* WHMCS GetPayMethods API response schema
*/
export const whmcsPaymentMethodListResponseSchema = z.object({
clientid: z.union([z.number(), z.string()]),
clientid: whmcsRequiredNumber,
paymethods: z.array(whmcsPaymentMethodSchema).optional(),
message: z.string().optional(),
});

View File

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

View File

@ -7,7 +7,7 @@
import { z } from "zod";
import {
whmcsString as s,
whmcsNumberLike as numberLike,
whmcsRequiredNumber,
} from "../../../common/providers/whmcs-utils/index.js";
// ============================================================================
@ -36,8 +36,8 @@ const whmcsCatalogProductPricingCycleSchema = z.object({
// ============================================================================
const whmcsCatalogProductSchema = z.object({
pid: numberLike,
gid: numberLike,
pid: s,
gid: whmcsRequiredNumber,
name: s,
description: s,
module: s,
@ -58,7 +58,7 @@ export const whmcsCatalogProductListResponseSchema = z.object({
products: z.object({
product: z.array(whmcsCatalogProductSchema),
}),
totalresults: numberLike,
totalresults: whmcsRequiredNumber,
});
export type WhmcsCatalogProductListResponse = z.infer<typeof whmcsCatalogProductListResponseSchema>;

View File

@ -209,13 +209,7 @@ export function transformWhmcsSubscriptionListResponse(
}
}
const totalResultsRaw = parsed.totalresults;
const totalResults =
typeof totalResultsRaw === "number"
? totalResultsRaw
: typeof totalResultsRaw === "string"
? Number.parseInt(totalResultsRaw, 10)
: subscriptions.length;
const totalResults = parsed.totalresults ?? subscriptions.length;
if (status) {
const normalizedStatus = subscriptionStatusSchema.parse(status);
@ -228,7 +222,7 @@ export function transformWhmcsSubscriptionListResponse(
return subscriptionListSchema.parse({
subscriptions,
totalCount: Number.isFinite(totalResults) ? totalResults : subscriptions.length,
totalCount: totalResults,
});
}

View File

@ -61,7 +61,7 @@ export const whmcsCustomFieldsContainerSchema = z.object({
});
export const whmcsConfigOptionSchema = z.object({
id: numberLike.optional(),
id: normalizeOptionalNumber,
option: s.optional(),
type: s.optional(),
value: s.optional(),
@ -165,11 +165,11 @@ const whmcsProductContainerSchema = z.object({
export const whmcsProductListResponseSchema = z.object({
result: z.enum(["success", "error"]).optional(),
message: s.optional(),
clientid: numberLike.optional(),
serviceid: z.union([numberLike, z.null()]).optional(),
pid: z.union([numberLike, z.null()]).optional(),
clientid: normalizeOptionalNumber,
serviceid: normalizeOptionalNumber,
pid: normalizeOptionalNumber,
domain: s.nullable().optional(),
totalresults: numberLike.optional(),
totalresults: normalizeOptionalNumber,
startnumber: normalizeOptionalNumber,
numreturned: normalizeOptionalNumber,
products: z.preprocess(value => {