From 230a61c520e7040ca58126e6baafe9cee4b24562 Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 2 Mar 2026 18:00:41 +0900 Subject: [PATCH] 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. --- CLAUDE.md | 2 + .../verification-workflow.service.ts | 63 +++++++----- .../http/guards/global-auth.guard.ts | 43 ++++++-- .../http/guards/local-auth.guard.ts | 11 ++- .../domain/billing/providers/whmcs/mapper.ts | 6 +- .../billing/providers/whmcs/raw.types.ts | 41 ++++---- .../common/providers/whmcs-utils/schema.ts | 36 +++++++ .../domain/customer/providers/whmcs/mapper.ts | 2 +- .../customer/providers/whmcs/raw.types.ts | 50 +++++----- packages/domain/customer/schema.ts | 98 +++++-------------- .../orders/providers/whmcs/raw.types.ts | 13 ++- .../domain/payments/providers/whmcs/mapper.ts | 13 +-- .../payments/providers/whmcs/raw.types.ts | 10 +- .../domain/services/providers/whmcs/mapper.ts | 4 +- .../services/providers/whmcs/raw.types.ts | 8 +- .../subscriptions/providers/whmcs/mapper.ts | 10 +- .../providers/whmcs/raw.types.ts | 10 +- 17 files changed, 224 insertions(+), 196 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f8b038ba..1a5cd774 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts index b1661faf..5aed6c68 100644 --- a/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/verification-workflow.service.ts @@ -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 { // 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 { - 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 { return { ...(prefill?.firstName && { firstName: prefill.firstName }), diff --git a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts index df50b4fb..fe4a6b5f 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts @@ -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(); 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); diff --git a/apps/bff/src/modules/auth/presentation/http/guards/local-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/local-auth.guard.ts index b99eb8b7..3e0ae98f 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/local-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/local-auth.guard.ts @@ -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) diff --git a/packages/domain/billing/providers/whmcs/mapper.ts b/packages/domain/billing/providers/whmcs/mapper.ts index 70f022d4..00bde8c9 100644 --- a/packages/domain/billing/providers/whmcs/mapper.ts +++ b/packages/domain/billing/providers/whmcs/mapper.ts @@ -54,12 +54,12 @@ function mapItems(rawItems: unknown): InvoiceItem[] { const itemArray = Array.isArray(parsed.item) ? parsed.item : [parsed.item]; return itemArray.map(item => ({ - id: 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, diff --git a/packages/domain/billing/providers/whmcs/raw.types.ts b/packages/domain/billing/providers/whmcs/raw.types.ts index c2be8f3d..c9a0a8d5 100644 --- a/packages/domain/billing/providers/whmcs/raw.types.ts +++ b/packages/domain/billing/providers/whmcs/raw.types.ts @@ -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; @@ -126,7 +127,7 @@ export type WhmcsInvoiceItemsRaw = z.infer; 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; @@ -218,7 +219,7 @@ export type WhmcsInvoiceResponse = z.infer; */ 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 { } 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()); diff --git a/packages/domain/customer/providers/whmcs/mapper.ts b/packages/domain/customer/providers/whmcs/mapper.ts index 9a01d49e..2fef2657 100644 --- a/packages/domain/customer/providers/whmcs/mapper.ts +++ b/packages/domain/customer/providers/whmcs/mapper.ts @@ -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 !== ""); diff --git a/packages/domain/customer/providers/whmcs/raw.types.ts b/packages/domain/customer/providers/whmcs/raw.types.ts index 8f5a007d..6c66c8f6 100644 --- a/packages/domain/customer/providers/whmcs/raw.types.ts +++ b/packages/domain/customer/providers/whmcs/raw.types.ts @@ -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; * @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([]), }); diff --git a/packages/domain/customer/schema.ts b/packages/domain/customer/schema.ts index f5fcdf78..e6329c0c 100644 --- a/packages/domain/customer/schema.ts +++ b/packages/domain/customer/schema.ts @@ -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 { - 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)["client_id"]), - owner_user_id: coerceOptionalNumber((raw as Record)["owner_user_id"]), - userid: coerceOptionalNumber((raw as Record)["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) diff --git a/packages/domain/orders/providers/whmcs/raw.types.ts b/packages/domain/orders/providers/whmcs/raw.types.ts index 5f9a3c08..61aec17a 100644 --- a/packages/domain/orders/providers/whmcs/raw.types.ts +++ b/packages/domain/orders/providers/whmcs/raw.types.ts @@ -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; // ============================================================================ 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; // ============================================================================ 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(), diff --git a/packages/domain/payments/providers/whmcs/mapper.ts b/packages/domain/payments/providers/whmcs/mapper.ts index 0d7fea66..4956b091 100644 --- a/packages/domain/payments/providers/whmcs/mapper.ts +++ b/packages/domain/payments/providers/whmcs/mapper.ts @@ -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, }; diff --git a/packages/domain/payments/providers/whmcs/raw.types.ts b/packages/domain/payments/providers/whmcs/raw.types.ts index cf614f08..e9c809ff 100644 --- a/packages/domain/payments/providers/whmcs/raw.types.ts +++ b/packages/domain/payments/providers/whmcs/raw.types.ts @@ -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; @@ -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; * 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(), }); diff --git a/packages/domain/services/providers/whmcs/mapper.ts b/packages/domain/services/providers/whmcs/mapper.ts index 9bc9dfa2..ab98f5fd 100644 --- a/packages/domain/services/providers/whmcs/mapper.ts +++ b/packages/domain/services/providers/whmcs/mapper.ts @@ -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, diff --git a/packages/domain/services/providers/whmcs/raw.types.ts b/packages/domain/services/providers/whmcs/raw.types.ts index e1773386..3fbe10b5 100644 --- a/packages/domain/services/providers/whmcs/raw.types.ts +++ b/packages/domain/services/providers/whmcs/raw.types.ts @@ -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; diff --git a/packages/domain/subscriptions/providers/whmcs/mapper.ts b/packages/domain/subscriptions/providers/whmcs/mapper.ts index fab3566e..1824dd21 100644 --- a/packages/domain/subscriptions/providers/whmcs/mapper.ts +++ b/packages/domain/subscriptions/providers/whmcs/mapper.ts @@ -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, }); } diff --git a/packages/domain/subscriptions/providers/whmcs/raw.types.ts b/packages/domain/subscriptions/providers/whmcs/raw.types.ts index 5e246ae1..c63f4535 100644 --- a/packages/domain/subscriptions/providers/whmcs/raw.types.ts +++ b/packages/domain/subscriptions/providers/whmcs/raw.types.ts @@ -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 => {