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. **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 ## Commands
```bash ```bash

View File

@ -21,6 +21,20 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { OtpService } from "../otp/otp.service.js"; import { OtpService } from "../otp/otp.service.js";
import { GetStartedSessionService } from "../otp/get-started-session.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 * Verification Workflow Service
* *
@ -98,10 +112,20 @@ export class VerificationWorkflowService {
const sessionToken = await this.sessionService.create(normalizedEmail); const sessionToken = await this.sessionService.create(normalizedEmail);
// Check account status across all systems // 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) // 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 // Update session with verified status and account info
const prefillData = this.buildSessionPrefillData(prefill, accountStatus); const prefillData = this.buildSessionPrefillData(prefill, accountStatus);
@ -177,13 +201,7 @@ export class VerificationWorkflowService {
}; };
} }
private async determineAccountStatus(email: string): Promise<{ private async determineAccountStatus(email: string): Promise<AccountStatusResult> {
status: AccountStatus;
sfAccountId?: string;
whmcsClientId?: number;
whmcsFirstName?: string;
whmcsLastName?: string;
}> {
// Check Portal user first // Check Portal user first
const portalUser = await this.usersService.findByEmailInternal(email); const portalUser = await this.usersService.findByEmailInternal(email);
if (portalUser) { if (portalUser) {
@ -223,12 +241,7 @@ export class VerificationWorkflowService {
private getPrefillData( private getPrefillData(
email: string, email: string,
accountStatus: { accountStatus: AccountStatusResult
status: AccountStatus;
sfAccountId?: string;
whmcsFirstName?: string;
whmcsLastName?: string;
}
): VerifyCodeResponse["prefill"] { ): VerifyCodeResponse["prefill"] {
if (accountStatus.status === ACCOUNT_STATUS.WHMCS_UNMAPPED) { if (accountStatus.status === ACCOUNT_STATUS.WHMCS_UNMAPPED) {
return { return {
@ -245,24 +258,24 @@ export class VerificationWorkflowService {
private async resolvePrefillData( private async resolvePrefillData(
email: string, email: string,
accountStatus: { status: AccountStatus; sfAccountId?: string; whmcsClientId?: number }, accountStatus: AccountStatusResult,
handoffToken?: string handoffToken?: string
): Promise<VerifyCodeResponse["prefill"]> { ): Promise<{ prefill: VerifyCodeResponse["prefill"]; sfAccountId?: string }> {
let prefill = this.getPrefillData(email, accountStatus); const prefill = this.getPrefillData(email, accountStatus);
if (!handoffToken) { if (!handoffToken) {
return prefill; return { prefill };
} }
const handoffData = await this.sessionService.getGuestHandoffToken(handoffToken, email); const handoffData = await this.sessionService.getGuestHandoffToken(handoffToken, email);
if (!handoffData) { if (!handoffData) {
return prefill; return { prefill };
} }
this.logger.debug({ email, handoffToken }, "Applying handoff token data to session"); this.logger.debug({ email, handoffToken }, "Applying handoff token data to session");
prefill = { const mergedPrefill = {
...prefill, ...prefill,
firstName: handoffData.firstName, firstName: handoffData.firstName,
lastName: handoffData.lastName, lastName: handoffData.lastName,
@ -270,18 +283,14 @@ export class VerificationWorkflowService {
address: handoffData.address, address: handoffData.address,
}; };
if (!accountStatus.sfAccountId && handoffData.sfAccountId) {
accountStatus.sfAccountId = handoffData.sfAccountId;
}
await this.sessionService.invalidateHandoffToken(handoffToken); await this.sessionService.invalidateHandoffToken(handoffToken);
return prefill; return { prefill: mergedPrefill, sfAccountId: handoffData.sfAccountId };
} }
private buildSessionPrefillData( private buildSessionPrefillData(
prefill: VerifyCodeResponse["prefill"], prefill: VerifyCodeResponse["prefill"],
accountStatus: { status: AccountStatus; sfAccountId?: string; whmcsClientId?: number } accountStatus: AccountStatusResult
): Record<string, unknown> { ): Record<string, unknown> {
return { return {
...(prefill?.firstName && { firstName: prefill.firstName }), ...(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 type { CanActivate, ExecutionContext } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { Reflector } from "@nestjs/core"; import { Reflector } from "@nestjs/core";
import { ErrorCode } from "@customer-portal/domain/common";
import type { Request } from "express"; import type { Request } from "express";
@ -94,7 +95,10 @@ export class GlobalAuthGuard implements CanActivate {
if (isLogoutRoute) { if (isLogoutRoute) {
return true; return true;
} }
throw new UnauthorizedException("Missing token"); throw new UnauthorizedException({
message: "Missing token",
code: ErrorCode.TOKEN_INVALID,
});
} }
await this.attachUserFromToken(request, token, route); await this.attachUserFromToken(request, token, route);
@ -119,7 +123,10 @@ export class GlobalAuthGuard implements CanActivate {
const rawRequest = context.switchToHttp().getRequest<unknown>(); const rawRequest = context.switchToHttp().getRequest<unknown>();
if (!this.isRequestWithRoute(rawRequest)) { if (!this.isRequestWithRoute(rawRequest)) {
this.logger.error("Unable to determine HTTP request in auth guard"); 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; return rawRequest;
} }
@ -174,20 +181,32 @@ export class GlobalAuthGuard implements CanActivate {
const tokenType = (payload as { type?: unknown }).type; const tokenType = (payload as { type?: unknown }).type;
if (typeof tokenType === "string" && tokenType !== "access") { 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) { 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 // Explicit expiry buffer check to avoid tokens expiring mid-request
if (typeof payload.exp !== "number") { 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); const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp < nowSeconds + 60) { 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 // Then check token blacklist
@ -196,15 +215,21 @@ export class GlobalAuthGuard implements CanActivate {
if (route) { if (route) {
this.logger.warn(`Blacklisted token attempted access to: ${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); const prismaUser = await this.usersService.findByIdInternal(payload.sub);
if (!prismaUser) { if (!prismaUser) {
throw new UnauthorizedException("User not found"); throw new UnauthorizedException({ message: "User not found", code: ErrorCode.TOKEN_INVALID });
} }
if (prismaUser.email !== payload.email) { 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); 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 { CanActivate, ExecutionContext } from "@nestjs/common";
import type { Request } from "express"; import type { Request } from "express";
import { AuthOrchestrator } from "@bff/modules/auth/application/auth-orchestrator.service.js"; import { AuthOrchestrator } from "@bff/modules/auth/application/auth-orchestrator.service.js";
import { ErrorCode } from "@customer-portal/domain/common";
@Injectable() @Injectable()
export class LocalAuthGuard implements CanActivate { export class LocalAuthGuard implements CanActivate {
@ -15,12 +16,18 @@ export class LocalAuthGuard implements CanActivate {
const password = typeof body["password"] === "string" ? body["password"] : ""; const password = typeof body["password"] === "string" ? body["password"] : "";
if (!email || !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); const user = await this.authOrchestrator.validateUser(email, password, request);
if (!user) { 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) // 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]; const itemArray = Array.isArray(parsed.item) ? parsed.item : [parsed.item];
return itemArray.map(item => ({ return itemArray.map(item => ({
id: Number(item.id), id: 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: 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 // Transform to domain model
const invoice: Invoice = { const invoice: Invoice = {
id: Number(whmcsInvoice.invoiceid ?? 0), id: 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,

View File

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

View File

@ -54,3 +54,39 @@ export const whmcsOptionalNumber = z.preprocess((value): number | undefined => {
} }
return undefined; return undefined;
}, z.number().optional()); }, 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, country: client.country ?? null,
countryCode: client.countrycode ?? null, countryCode: client.countrycode ?? null,
phoneNumber: client.phonenumberformatted ?? client.phonenumber ?? 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 !== ""); const hasValues = Object.values(address).some(v => v !== undefined && v !== null && v !== "");

View File

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

View File

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

View File

@ -21,6 +21,11 @@
import { z } from "zod"; import { z } from "zod";
import {
whmcsRequiredNumber,
whmcsOptionalNumber,
} from "../../../common/providers/whmcs-utils/index.js";
// ============================================================================ // ============================================================================
// WHMCS Order Item Schema // WHMCS Order Item Schema
// ============================================================================ // ============================================================================
@ -91,8 +96,8 @@ export type WhmcsAddOrderPayload = z.infer<typeof whmcsAddOrderPayloadSchema>;
// ============================================================================ // ============================================================================
export const whmcsAddOrderResponseSchema = z.object({ export const whmcsAddOrderResponseSchema = z.object({
orderid: z.union([z.string(), z.number()]), orderid: whmcsRequiredNumber,
invoiceid: z.union([z.string(), z.number()]).optional(), invoiceid: whmcsOptionalNumber,
serviceids: z.string().optional(), serviceids: z.string().optional(),
addonids: z.string().optional(), addonids: z.string().optional(),
domainids: z.string().optional(), domainids: z.string().optional(),
@ -115,8 +120,8 @@ export type WhmcsOrderResult = z.infer<typeof whmcsOrderResultSchema>;
// ============================================================================ // ============================================================================
export const whmcsAcceptOrderResponseSchema = z.object({ export const whmcsAcceptOrderResponseSchema = z.object({
orderid: z.union([z.string(), z.number()]).optional(), orderid: whmcsOptionalNumber,
invoiceid: z.union([z.string(), z.number()]).optional(), invoiceid: whmcsOptionalNumber,
serviceids: z.string().optional(), serviceids: z.string().optional(),
addonids: z.string().optional(), addonids: z.string().optional(),
domainids: 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"; 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 { export function transformWhmcsPaymentMethod(raw: unknown): PaymentMethod {
const whmcs = whmcsPaymentMethodRawSchema.parse(raw); const whmcs = whmcsPaymentMethodRawSchema.parse(raw);
const paymentMethod: PaymentMethod = { const paymentMethod: PaymentMethod = {
id: Number(whmcs.id), id: 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,
@ -53,7 +46,7 @@ export function transformWhmcsPaymentMethod(raw: unknown): PaymentMethod {
bankName: whmcs.bank_name, bankName: whmcs.bank_name,
remoteToken: whmcs.remote_token, remoteToken: whmcs.remote_token,
lastUpdated: whmcs.last_updated, lastUpdated: whmcs.last_updated,
isDefault: coerceBoolean(whmcs.is_default), isDefault: whmcs.is_default ?? false,
}; };
return paymentMethodSchema.parse(paymentMethod); return paymentMethodSchema.parse(paymentMethod);
@ -66,7 +59,7 @@ export function transformWhmcsPaymentGateway(raw: unknown): PaymentGateway {
name: whmcs.name, name: whmcs.name,
displayName: whmcs.display_name || whmcs.name, displayName: whmcs.display_name || whmcs.name,
type: mapGatewayType(whmcs.type), type: mapGatewayType(whmcs.type),
isActive: coerceBoolean(whmcs.visible), isActive: whmcs.visible ?? false,
configuration: whmcs.configuration, configuration: whmcs.configuration,
}; };

View File

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

View File

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

View File

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

View File

@ -209,13 +209,7 @@ export function transformWhmcsSubscriptionListResponse(
} }
} }
const totalResultsRaw = parsed.totalresults; const totalResults = parsed.totalresults ?? subscriptions.length;
const totalResults =
typeof totalResultsRaw === "number"
? totalResultsRaw
: typeof totalResultsRaw === "string"
? Number.parseInt(totalResultsRaw, 10)
: subscriptions.length;
if (status) { if (status) {
const normalizedStatus = subscriptionStatusSchema.parse(status); const normalizedStatus = subscriptionStatusSchema.parse(status);
@ -228,7 +222,7 @@ export function transformWhmcsSubscriptionListResponse(
return subscriptionListSchema.parse({ return subscriptionListSchema.parse({
subscriptions, 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({ export const whmcsConfigOptionSchema = z.object({
id: numberLike.optional(), id: normalizeOptionalNumber,
option: s.optional(), option: s.optional(),
type: s.optional(), type: s.optional(),
value: s.optional(), value: s.optional(),
@ -165,11 +165,11 @@ 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: s.optional(), message: s.optional(),
clientid: numberLike.optional(), clientid: normalizeOptionalNumber,
serviceid: z.union([numberLike, z.null()]).optional(), serviceid: normalizeOptionalNumber,
pid: z.union([numberLike, z.null()]).optional(), pid: normalizeOptionalNumber,
domain: s.nullable().optional(), domain: s.nullable().optional(),
totalresults: numberLike.optional(), totalresults: normalizeOptionalNumber,
startnumber: normalizeOptionalNumber, startnumber: normalizeOptionalNumber,
numreturned: normalizeOptionalNumber, numreturned: normalizeOptionalNumber,
products: z.preprocess(value => { products: z.preprocess(value => {