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:
parent
29b511e44c
commit
230a61c520
@ -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
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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 !== "");
|
||||
|
||||
@ -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([]),
|
||||
});
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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 => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user