Refactor Error Handling and Improve Service Consistency

- Introduced `mapHttpStatusToErrorCode` function in the domain to standardize error code mapping from HTTP status codes, enhancing error handling across the application.
- Updated various services and controllers to utilize the new error mapping function, ensuring consistent error responses and improved clarity in logging.
- Refactored error handling in the BillingController and other modules to align with the updated practices, promoting maintainability and clarity in API responses.
- Cleaned up unused imports and optimized code structure for better maintainability across the BFF modules.
This commit is contained in:
barsa 2025-12-29 15:44:01 +09:00
parent 03eccd1db2
commit a55967a31f
38 changed files with 562 additions and 781 deletions

View File

@ -17,6 +17,30 @@ import {
type ErrorCodeType,
type ApiError,
} from "@customer-portal/domain/common";
import { generateRequestId } from "@bff/core/logging/request-id.util.js";
function mapHttpStatusToErrorCode(status?: number): ErrorCodeType {
if (!status) return ErrorCode.UNKNOWN;
switch (status) {
case 401:
return ErrorCode.SESSION_EXPIRED;
case 403:
return ErrorCode.FORBIDDEN;
case 404:
return ErrorCode.NOT_FOUND;
case 409:
return ErrorCode.ACCOUNT_EXISTS;
case 400:
return ErrorCode.VALIDATION_FAILED;
case 429:
return ErrorCode.RATE_LIMITED;
case 503:
return ErrorCode.SERVICE_UNAVAILABLE;
default:
return status >= 500 ? ErrorCode.INTERNAL_ERROR : ErrorCode.UNKNOWN;
}
}
/**
* Request context for error logging
@ -89,7 +113,7 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
}
// Map to error code
const errorCode = explicitCode ?? this.mapStatusToErrorCode(status);
const errorCode = explicitCode ?? mapHttpStatusToErrorCode(status);
return { status, errorCode, originalMessage };
}
@ -159,30 +183,6 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
return Object.prototype.hasOwnProperty.call(ErrorMessages, value);
}
/**
* Map status code to error code (no message parsing).
*/
private mapStatusToErrorCode(status: number): ErrorCodeType {
switch (status as HttpStatus) {
case HttpStatus.UNAUTHORIZED:
return ErrorCode.SESSION_EXPIRED;
case HttpStatus.FORBIDDEN:
return ErrorCode.FORBIDDEN;
case HttpStatus.NOT_FOUND:
return ErrorCode.NOT_FOUND;
case HttpStatus.CONFLICT:
return ErrorCode.ACCOUNT_EXISTS;
case HttpStatus.BAD_REQUEST:
return ErrorCode.VALIDATION_FAILED;
case HttpStatus.TOO_MANY_REQUESTS:
return ErrorCode.RATE_LIMITED;
case HttpStatus.SERVICE_UNAVAILABLE:
return ErrorCode.SERVICE_UNAVAILABLE;
default:
return status >= 500 ? ErrorCode.INTERNAL_ERROR : ErrorCode.UNKNOWN;
}
}
/**
* Get user-friendly message, with dev details in development mode
*/
@ -317,6 +317,6 @@ export class UnifiedExceptionFilter implements ExceptionFilter {
* Generate unique request ID
*/
private generateRequestId(): string {
return `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
return generateRequestId("req");
}
}

View File

@ -0,0 +1,6 @@
import { randomUUID } from "crypto";
export function generateRequestId(prefix?: string): string {
const id = randomUUID();
return prefix ? `${prefix}_${id}` : id;
}

View File

@ -11,9 +11,8 @@ import { RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
import type { Redis } from "ioredis";
import type { Request, Response } from "express";
import { Logger } from "nestjs-pino";
import { createHash } from "crypto";
import { RATE_LIMIT_KEY, type RateLimitOptions } from "./rate-limit.decorator.js";
import { extractClientIp } from "@bff/core/http/request-context.util.js";
import { buildRateLimitKey, getRequestRateLimitIdentity } from "./rate-limit.util.js";
/**
* Rate limit guard using rate-limiter-flexible with Redis backend.
@ -94,15 +93,13 @@ export class RateLimitGuard implements CanActivate {
* Build a unique key for rate limiting based on IP and User-Agent
*/
private buildKey(request: Request, context: ExecutionContext): string {
const ip = extractClientIp(request);
const userAgent = request.headers["user-agent"] || "unknown";
const uaHash = createHash("sha256").update(String(userAgent)).digest("hex").slice(0, 16);
const { ip, userAgentHash } = getRequestRateLimitIdentity(request);
// Use handler name as part of key to separate limits per endpoint
const handlerName = context.getHandler().name;
const controllerName = context.getClass().name;
return `rl:${controllerName}:${handlerName}:${ip}:${uaHash}`;
return buildRateLimitKey("rl", controllerName, handlerName, ip, userAgentHash);
}
/**

View File

@ -0,0 +1,23 @@
import { createHash } from "crypto";
import type { Request } from "express";
import { extractClientIp } from "@bff/core/http/request-context.util.js";
export function getUserAgentHash(userAgent: string | string[] | undefined): string {
const value = Array.isArray(userAgent) ? userAgent[0] : userAgent;
const normalized = typeof value === "string" ? value : "unknown";
return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
}
export function getRequestRateLimitIdentity(request: Request): {
ip: string;
userAgentHash: string;
} {
return {
ip: extractClientIp(request),
userAgentHash: getUserAgentHash(request.headers["user-agent"]),
};
}
export function buildRateLimitKey(...parts: Array<string | undefined>): string {
return parts.filter(Boolean).join(":");
}

View File

@ -1,10 +1,11 @@
import { BadRequestException } from "@nestjs/common";
import { uuidSchema } from "@customer-portal/domain/common";
import { validateUuidV4OrThrow } from "@customer-portal/domain/common";
export function parseUuidOrThrow(id: string, message = "Invalid ID format"): string {
const parsed = uuidSchema.safeParse(id);
if (!parsed.success) {
try {
// Domain validator throws its own Error; we translate to a BadRequestException with our message.
return validateUuidV4OrThrow(id);
} catch {
throw new BadRequestException(message);
}
return parsed.data;
}

View File

@ -6,7 +6,7 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js";
export interface DistributedStep<TId extends string = string, TResult = unknown> {
id: TId;
description: string;
execute: () => Promise<TResult>;
execute: (signal?: AbortSignal) => Promise<TResult>;
rollback?: () => Promise<void>;
critical?: boolean; // If true, failure stops entire transaction
retryable?: boolean; // If true, step can be retried on failure
@ -360,14 +360,22 @@ export class DistributedTransactionService {
step: DistributedStep<string, TResult>,
timeout: number
): Promise<TResult> {
return (await Promise.race([
step.execute(),
new Promise<never>((_, reject) => {
setTimeout(() => {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | null = null;
try {
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new Error(`Step ${step.id} timed out after ${timeout}ms`));
}, timeout);
}),
])) as TResult;
});
return (await Promise.race([step.execute(controller.signal), timeoutPromise])) as TResult;
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
private async executeRollbacks<TSteps extends readonly DistributedStep[]>(

View File

@ -154,10 +154,12 @@ export class TransactionService {
};
}
const result = await Promise.race([
this.executeTransactionAttempt(operation, context, isolationLevel),
this.createTimeoutPromise<T>(timeout, transactionId),
]);
const result = await this.executeTransactionAttempt(
operation,
context,
isolationLevel,
timeout
);
const duration = Date.now() - startTime.getTime();
@ -238,7 +240,8 @@ export class TransactionService {
private async executeTransactionAttempt<T>(
operation: TransactionOperation<T>,
context: TransactionContext,
isolationLevel: Prisma.TransactionIsolationLevel
isolationLevel: Prisma.TransactionIsolationLevel,
timeout: number
): Promise<T> {
return await this.prisma.$transaction(
async tx => {
@ -250,7 +253,7 @@ export class TransactionService {
},
{
isolationLevel,
timeout: 30000, // Prisma transaction timeout
timeout,
}
);
}
@ -317,14 +320,6 @@ export class TransactionService {
);
}
private async createTimeoutPromise<T>(timeout: number, transactionId: string): Promise<T> {
return new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`Transaction timeout after ${timeout}ms [${transactionId}]`));
}, timeout);
});
}
private async delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@ -11,6 +11,7 @@ import {
SalesforceQueueDegradationService,
type SalesforceDegradationSnapshot,
} from "./salesforce-queue-degradation.service.js";
import { generateRequestId } from "@bff/core/logging/request-id.util.js";
export interface SalesforceQueueMetrics {
totalRequests: number;
@ -569,7 +570,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
}
private generateRequestId(): string {
return `sf_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
return generateRequestId("sf");
}
private async restoreDailyUsageFromCache(): Promise<void> {

View File

@ -2,6 +2,8 @@ import { Injectable, Inject } from "@nestjs/common";
import type { OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import { withRetry } from "@bff/core/utils/retry.util.js";
import { generateRequestId } from "@bff/core/logging/request-id.util.js";
type PQueueCtor = new (options: {
concurrency?: number;
@ -285,37 +287,12 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
requestFn: () => Promise<T>,
options: WhmcsRequestOptions
): Promise<T> {
const maxAttempts = options.retryAttempts || 3;
const baseDelay = options.retryDelay || 1000;
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const result = await requestFn();
return result;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt === maxAttempts) {
break;
}
// Exponential backoff with jitter
const delay = baseDelay * Math.pow(2, attempt - 1) + Math.random() * 1000;
this.logger.debug("WHMCS request failed, retrying", {
attempt,
maxAttempts,
delay,
error: lastError.message,
});
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError));
return withRetry(requestFn, {
maxAttempts: options.retryAttempts ?? 3,
baseDelayMs: options.retryDelay ?? 1000,
logger: this.logger,
logContext: "WHMCS request",
});
}
private setupQueueListeners(): void {
@ -378,6 +355,6 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
}
private generateRequestId(): string {
return `whmcs_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
return generateRequestId("whmcs");
}
}

View File

@ -27,26 +27,27 @@ export class FreebitCancellationService {
*/
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
try {
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
account,
runTime: scheduledAt,
};
await this.rateLimiter.executeWithSpacing(account, "cancellation", async () => {
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
account,
runTime: scheduledAt,
};
this.logger.log(`Cancelling SIM plan via PA05-04 for account ${account}`, {
account,
runTime: scheduledAt,
this.logger.log(`Cancelling SIM plan via PA05-04 for account ${account}`, {
account,
runTime: scheduledAt,
});
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
"/mvno/releasePlan/",
request
);
this.logger.log(`Successfully cancelled SIM plan for account ${account}`, {
account,
runTime: scheduledAt,
});
});
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
"/mvno/releasePlan/",
request
);
this.logger.log(`Successfully cancelled SIM plan for account ${account}`, {
account,
runTime: scheduledAt,
});
await this.rateLimiter.stampOperation(account, "cancellation");
} catch (error) {
const message = extractErrorMessage(error);
this.logger.error(`Failed to cancel SIM plan for account ${account}`, {
@ -70,28 +71,29 @@ export class FreebitCancellationService {
*/
async cancelAccount(account: string, runDate?: string): Promise<void> {
try {
const request: Omit<FreebitCancelAccountRequest, "authKey"> = {
kind: "MVNO",
account,
runDate,
};
await this.rateLimiter.executeWithSpacing(account, "cancellation", async () => {
const request: Omit<FreebitCancelAccountRequest, "authKey"> = {
kind: "MVNO",
account,
runDate,
};
this.logger.log(`Cancelling SIM account via PA02-04 for account ${account}`, {
account,
runDate,
note: "After this, PA05-21 plan changes will cancel the cancellation",
this.logger.log(`Cancelling SIM account via PA02-04 for account ${account}`, {
account,
runDate,
note: "After this, PA05-21 plan changes will cancel the cancellation",
});
await this.client.makeAuthenticatedRequest<FreebitCancelAccountResponse, typeof request>(
"/master/cnclAcnt/",
request
);
this.logger.log(`Successfully cancelled SIM account for account ${account}`, {
account,
runDate,
});
});
await this.client.makeAuthenticatedRequest<FreebitCancelAccountResponse, typeof request>(
"/master/cnclAcnt/",
request
);
this.logger.log(`Successfully cancelled SIM account for account ${account}`, {
account,
runDate,
});
await this.rateLimiter.stampOperation(account, "cancellation");
} catch (error) {
const message = extractErrorMessage(error);
this.logger.error(`Failed to cancel SIM account for account ${account}`, {

View File

@ -39,82 +39,81 @@ export class FreebitPlanService {
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
): Promise<{ ipv4?: string; ipv6?: string }> {
try {
await this.rateLimiter.assertOperationSpacing(account, "plan");
return await this.rateLimiter.executeWithSpacing(account, "plan", async () => {
// First, get current SIM details to log for debugging
let currentPlanCode: string | undefined;
try {
const simDetails = await this.accountService.getSimDetails(account);
currentPlanCode = simDetails.planCode;
this.logger.log(`Current SIM plan details before change`, {
account,
currentPlanCode: simDetails.planCode,
status: simDetails.status,
simType: simDetails.simType,
});
} catch (detailsError) {
this.logger.warn(`Could not fetch current SIM details`, {
account,
error: extractErrorMessage(detailsError),
});
}
// First, get current SIM details to log for debugging
let currentPlanCode: string | undefined;
try {
const simDetails = await this.accountService.getSimDetails(account);
currentPlanCode = simDetails.planCode;
this.logger.log(`Current SIM plan details before change`, {
// PA05-21 requires runTime parameter in YYYYMMDD format (8 digits, date only)
// If not provided, default to 1st of next month
let runTime = options.scheduledAt || undefined;
if (!runTime) {
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1);
const year = nextMonth.getFullYear();
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
const day = "01";
runTime = `${year}${month}${day}`;
this.logger.log(`No scheduledAt provided, defaulting to 1st of next month: ${runTime}`, {
account,
runTime,
});
}
const request: Omit<FreebitPlanChangeRequest, "authKey"> = {
account,
currentPlanCode: simDetails.planCode,
status: simDetails.status,
simType: simDetails.simType,
});
} catch (detailsError) {
this.logger.warn(`Could not fetch current SIM details`, {
planCode: newPlanCode,
runTime: runTime,
...(options.assignGlobalIp === true ? { globalip: "1" } : {}),
};
this.logger.log(`Attempting to change SIM plan via PA05-21`, {
account,
error: extractErrorMessage(detailsError),
currentPlanCode,
newPlanCode,
planCode: newPlanCode,
globalip: request.globalip,
runTime: request.runTime,
scheduledAt: options.scheduledAt,
});
}
// PA05-21 requires runTime parameter in YYYYMMDD format (8 digits, date only)
// If not provided, default to 1st of next month
let runTime = options.scheduledAt || undefined;
if (!runTime) {
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1);
const year = nextMonth.getFullYear();
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
const day = "01";
runTime = `${year}${month}${day}`;
this.logger.log(`No scheduledAt provided, defaulting to 1st of next month: ${runTime}`, {
const response = await this.client.makeAuthenticatedRequest<
FreebitPlanChangeResponse,
typeof request
>("/mvno/changePlan/", request);
this.logger.log(`Successfully changed plan for account ${account} to ${newPlanCode}`, {
account,
runTime,
newPlanCode,
assignGlobalIp: options.assignGlobalIp,
scheduled: !!options.scheduledAt,
response: {
resultCode: response.resultCode,
statusCode: response.status?.statusCode,
message: response.status?.message,
},
});
}
const request: Omit<FreebitPlanChangeRequest, "authKey"> = {
account,
planCode: newPlanCode,
runTime: runTime,
...(options.assignGlobalIp === true ? { globalip: "1" } : {}),
};
this.logger.log(`Attempting to change SIM plan via PA05-21`, {
account,
currentPlanCode,
newPlanCode,
planCode: newPlanCode,
globalip: request.globalip,
runTime: request.runTime,
scheduledAt: options.scheduledAt,
return {
ipv4: response.ipv4,
ipv6: response.ipv6,
};
});
const response = await this.client.makeAuthenticatedRequest<
FreebitPlanChangeResponse,
typeof request
>("/mvno/changePlan/", request);
this.logger.log(`Successfully changed plan for account ${account} to ${newPlanCode}`, {
account,
newPlanCode,
assignGlobalIp: options.assignGlobalIp,
scheduled: !!options.scheduledAt,
response: {
resultCode: response.resultCode,
statusCode: response.status?.statusCode,
message: response.status?.message,
},
});
await this.rateLimiter.stampOperation(account, "plan");
return {
ipv4: response.ipv4,
ipv6: response.ipv6,
};
} catch (error) {
const message = extractErrorMessage(error);

View File

@ -1,6 +1,7 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type { Redis } from "ioredis";
import { randomUUID } from "crypto";
export type OperationType = "voice" | "network" | "plan" | "cancellation";
@ -29,6 +30,7 @@ export class FreebitRateLimiterService {
private readonly cleanupIntervalMs = 5 * 60 * 1000; // Run cleanup at most every 5 minutes
private readonly staleThresholdMs = 35 * 60 * 1000; // Remove entries older than 35 minutes
private readonly windowMs = 30 * 60 * 1000; // 30 minute window between operations
private readonly lockTtlMs = Math.min(this.windowMs, 10 * 60 * 1000);
constructor(
@Inject("REDIS_CLIENT") private readonly redis: Redis,
@ -93,6 +95,59 @@ export class FreebitRateLimiterService {
return `freebit:ops:${account}`;
}
private buildLockKey(account: string): string {
return `freebit:ops:lock:${account}`;
}
private async acquireLock(account: string, token: string): Promise<boolean> {
const key = this.buildLockKey(account);
const result = await this.redis.set(key, token, "PX", this.lockTtlMs, "NX");
return result === "OK";
}
private async releaseLock(account: string, token: string): Promise<void> {
const key = this.buildLockKey(account);
const script =
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
try {
await this.redis.eval(script, 1, key, token);
} catch (error) {
this.logger.warn("Failed to release Freebit operation lock", {
account,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Execute a Freebit operation with spacing checks and a distributed lock.
* Ensures no concurrent operations violate the 30-minute spacing rule.
*/
async executeWithSpacing<T>(
account: string,
op: OperationType,
operation: () => Promise<T>
): Promise<T> {
const token = randomUUID();
try {
const acquired = await this.acquireLock(account, token);
if (!acquired) {
throw new BadRequestException(
"Another SIM operation is in progress. Please try again shortly."
);
}
await this.assertOperationSpacing(account, op);
const result = await operation();
await this.stampOperation(account, op);
return result;
} finally {
await this.releaseLock(account, token);
}
}
private async getOperationWindow(account: string): Promise<OperationTimestamps> {
const key = this.buildKey(account);
try {

View File

@ -120,72 +120,71 @@ export class FreebitVoiceService {
*/
async updateVoiceFeatures(account: string, features: VoiceFeatures): Promise<void> {
try {
await this.rateLimiter.assertOperationSpacing(account, "voice");
await this.rateLimiter.executeWithSpacing(account, "voice", async () => {
const buildVoiceOptionPayload = (): Omit<FreebitVoiceOptionRequest, "authKey"> => {
const talkOption: FreebitVoiceOptionSettings = {};
const buildVoiceOptionPayload = (): Omit<FreebitVoiceOptionRequest, "authKey"> => {
const talkOption: FreebitVoiceOptionSettings = {};
if (typeof features.voiceMailEnabled === "boolean") {
talkOption.voiceMail = features.voiceMailEnabled ? "10" : "20";
}
if (typeof features.voiceMailEnabled === "boolean") {
talkOption.voiceMail = features.voiceMailEnabled ? "10" : "20";
}
if (typeof features.callWaitingEnabled === "boolean") {
talkOption.callWaiting = features.callWaitingEnabled ? "10" : "20";
}
if (typeof features.callWaitingEnabled === "boolean") {
talkOption.callWaiting = features.callWaitingEnabled ? "10" : "20";
}
if (typeof features.internationalRoamingEnabled === "boolean") {
talkOption.worldWing = features.internationalRoamingEnabled ? "10" : "20";
if (features.internationalRoamingEnabled) {
talkOption.worldWingCreditLimit = "50000";
}
}
if (typeof features.internationalRoamingEnabled === "boolean") {
talkOption.worldWing = features.internationalRoamingEnabled ? "10" : "20";
if (features.internationalRoamingEnabled) {
talkOption.worldWingCreditLimit = "50000";
if (Object.keys(talkOption).length === 0) {
throw new BadRequestException("No voice options specified for update");
}
return {
account,
userConfirmed: "10",
aladinOperated: "10",
talkOption,
};
};
const voiceOptionPayload = buildVoiceOptionPayload();
this.logger.debug(
"Submitting voice option change via /mvno/talkoption/changeOrder/ (PA05-06)",
{
account,
payload: voiceOptionPayload,
}
);
await this.client.makeAuthenticatedRequest<
FreebitVoiceOptionResponse,
typeof voiceOptionPayload
>("/mvno/talkoption/changeOrder/", voiceOptionPayload);
this.logger.log("Voice option change completed via PA05-06", {
account,
voiceMailEnabled: features.voiceMailEnabled,
callWaitingEnabled: features.callWaitingEnabled,
internationalRoamingEnabled: features.internationalRoamingEnabled,
});
// Save to database for future retrieval
if (this.voiceOptionsService) {
try {
await this.voiceOptionsService.saveVoiceOptions(account, features);
} catch (dbError) {
this.logger.warn("Failed to save voice options to database (non-fatal)", {
account,
error: extractErrorMessage(dbError),
});
}
}
if (Object.keys(talkOption).length === 0) {
throw new BadRequestException("No voice options specified for update");
}
return {
account,
userConfirmed: "10",
aladinOperated: "10",
talkOption,
};
};
const voiceOptionPayload = buildVoiceOptionPayload();
this.logger.debug(
"Submitting voice option change via /mvno/talkoption/changeOrder/ (PA05-06)",
{
account,
payload: voiceOptionPayload,
}
);
await this.client.makeAuthenticatedRequest<
FreebitVoiceOptionResponse,
typeof voiceOptionPayload
>("/mvno/talkoption/changeOrder/", voiceOptionPayload);
this.logger.log("Voice option change completed via PA05-06", {
account,
voiceMailEnabled: features.voiceMailEnabled,
callWaitingEnabled: features.callWaitingEnabled,
internationalRoamingEnabled: features.internationalRoamingEnabled,
});
await this.rateLimiter.stampOperation(account, "voice");
// Save to database for future retrieval
if (this.voiceOptionsService) {
try {
await this.voiceOptionsService.saveVoiceOptions(account, features);
} catch (dbError) {
this.logger.warn("Failed to save voice options to database (non-fatal)", {
account,
error: extractErrorMessage(dbError),
});
}
}
} catch (error) {
const message = extractErrorMessage(error);
this.logger.error(`Failed to update voice features for account ${account}`, {
@ -204,10 +203,10 @@ export class FreebitVoiceService {
* NOTE: Must be called 30 minutes after PA05-06 if both are being updated
*/
async updateNetworkType(account: string, networkType: "4G" | "5G"): Promise<void> {
let eid: string | undefined;
let productNumber: string | undefined;
try {
await this.rateLimiter.assertOperationSpacing(account, "network");
let eid: string | undefined;
let productNumber: string | undefined;
try {
const details = await this.accountService.getSimDetails(account);
if (details.eid) {
@ -238,44 +237,45 @@ export class FreebitVoiceService {
});
}
const request: Omit<FreebitContractLineChangeRequest, "authKey"> = {
account,
contractLine: networkType,
...(eid ? { eid } : {}),
...(productNumber ? { productNumber } : {}),
};
await this.rateLimiter.executeWithSpacing(account, "network", async () => {
const request: Omit<FreebitContractLineChangeRequest, "authKey"> = {
account,
contractLine: networkType,
...(eid ? { eid } : {}),
...(productNumber ? { productNumber } : {}),
};
this.logger.debug(`Updating network type via PA05-38 for account ${account}`, {
account,
networkType,
request,
});
this.logger.debug(`Updating network type via PA05-38 for account ${account}`, {
account,
networkType,
request,
});
const response = await this.client.makeAuthenticatedJsonRequest<
FreebitContractLineChangeResponse,
typeof request
>("/mvno/contractline/change/", request);
const response = await this.client.makeAuthenticatedJsonRequest<
FreebitContractLineChangeResponse,
typeof request
>("/mvno/contractline/change/", request);
this.logger.log(`Successfully updated network type for account ${account}`, {
account,
networkType,
resultCode: response.resultCode,
statusCode: response.status?.statusCode,
message: response.status?.message,
});
await this.rateLimiter.stampOperation(account, "network");
this.logger.log(`Successfully updated network type for account ${account}`, {
account,
networkType,
resultCode: response.resultCode,
statusCode: response.status?.statusCode,
message: response.status?.message,
});
// Save to database for future retrieval
if (this.voiceOptionsService) {
try {
await this.voiceOptionsService.saveVoiceOptions(account, { networkType });
} catch (dbError) {
this.logger.warn("Failed to save network type to database (non-fatal)", {
account,
error: extractErrorMessage(dbError),
});
// Save to database for future retrieval
if (this.voiceOptionsService) {
try {
await this.voiceOptionsService.saveVoiceOptions(account, { networkType });
} catch (dbError) {
this.logger.warn("Failed to save network type to database (non-fatal)", {
account,
error: extractErrorMessage(dbError),
});
}
}
}
});
} catch (error) {
const message = extractErrorMessage(error);
this.logger.error(`Failed to update network type for account ${account}`, {

View File

@ -10,8 +10,11 @@ import type {
WhmcsAddClientResponse,
WhmcsValidateLoginResponse,
} from "@customer-portal/domain/customer/providers";
import { transformWhmcsClientResponse } from "@customer-portal/domain/customer/providers";
import type { WhmcsClient } from "@customer-portal/domain/customer";
import {
prepareWhmcsClientAddressUpdate,
transformWhmcsClientResponse,
} from "@customer-portal/domain/customer/providers";
import { addressSchema, type Address, type WhmcsClient } from "@customer-portal/domain/customer";
@Injectable()
export class WhmcsClientService {
@ -134,4 +137,24 @@ export class WhmcsClientService {
await this.cacheService.invalidateUserCache(userId);
this.logger.log(`Invalidated cache for user ${userId}`);
}
/**
* Convenience helper for address get on WHMCS client.
* Keeps address parsing close to the integration boundary.
*/
async getClientAddress(clientId: number): Promise<Address> {
const customer = await this.getClientDetails(clientId);
return addressSchema.parse(customer.address ?? {});
}
/**
* Convenience helper for address update on WHMCS client.
* Parses input + prepares WHMCS update payload, then updates the client.
*/
async updateClientAddress(clientId: number, address: Partial<Address>): Promise<void> {
const parsed = addressSchema.partial().parse(address ?? {});
const updateData = prepareWhmcsClientAddressUpdate(parsed);
if (Object.keys(updateData).length === 0) return;
await this.updateClient(clientId, updateData);
}
}

View File

@ -2,7 +2,6 @@ import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { QueueModule } from "@bff/infra/queue/queue.module.js";
import { WhmcsCacheService } from "./cache/whmcs-cache.service.js";
import { WhmcsService } from "./whmcs.service.js";
import { WhmcsInvoiceService } from "./services/whmcs-invoice.service.js";
import { WhmcsSubscriptionService } from "./services/whmcs-subscription.service.js";
import { WhmcsClientService } from "./services/whmcs-client.service.js";
@ -35,15 +34,16 @@ import { WhmcsErrorHandlerService } from "./connection/services/whmcs-error-hand
WhmcsOrderService,
WhmcsCurrencyService,
WhmcsAccountDiscoveryService,
WhmcsService,
],
exports: [
WhmcsService,
WhmcsConnectionOrchestratorService,
WhmcsCacheService,
WhmcsClientService,
WhmcsOrderService,
WhmcsPaymentService,
WhmcsInvoiceService,
WhmcsSubscriptionService,
WhmcsSsoService,
WhmcsCurrencyService,
WhmcsAccountDiscoveryService,
],

View File

@ -1,340 +0,0 @@
import { Injectable, Inject } from "@nestjs/common";
import type { Invoice, InvoiceList } from "@customer-portal/domain/billing";
import type { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
import type { PaymentMethodList, PaymentGatewayList } from "@customer-portal/domain/payments";
import { addressSchema, type Address, type WhmcsClient } from "@customer-portal/domain/customer";
import { prepareWhmcsClientAddressUpdate } from "@customer-portal/domain/customer/providers";
import { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsInvoiceService } from "./services/whmcs-invoice.service.js";
import type { InvoiceFilters } from "./services/whmcs-invoice.service.js";
import { WhmcsSubscriptionService } from "./services/whmcs-subscription.service.js";
import type { SubscriptionFilters } from "./services/whmcs-subscription.service.js";
import { WhmcsClientService } from "./services/whmcs-client.service.js";
import { WhmcsPaymentService } from "./services/whmcs-payment.service.js";
import { WhmcsSsoService } from "./services/whmcs-sso.service.js";
import { WhmcsOrderService } from "./services/whmcs-order.service.js";
import type {
WhmcsAddClientParams,
WhmcsClientResponse,
} from "@customer-portal/domain/customer/providers";
import type {
WhmcsGetClientsProductsParams,
WhmcsProductListResponse,
} from "@customer-portal/domain/subscriptions/providers";
import type { WhmcsCatalogProductNormalized } from "@customer-portal/domain/services/providers";
import { Logger } from "nestjs-pino";
@Injectable()
export class WhmcsService {
constructor(
private readonly connectionService: WhmcsConnectionOrchestratorService,
private readonly invoiceService: WhmcsInvoiceService,
private readonly subscriptionService: WhmcsSubscriptionService,
private readonly clientService: WhmcsClientService,
private readonly paymentService: WhmcsPaymentService,
private readonly ssoService: WhmcsSsoService,
private readonly orderService: WhmcsOrderService,
@Inject(Logger) private readonly logger: Logger
) {}
// ==========================================
// INVOICE OPERATIONS (delegate to InvoiceService)
// ==========================================
/**
* Get paginated invoices for a client with caching
*/
async getInvoices(
clientId: number,
userId: string,
filters: InvoiceFilters = {}
): Promise<InvoiceList> {
return this.invoiceService.getInvoices(clientId, userId, filters);
}
/**
* Get invoices with items (for subscription linking)
*/
async getInvoicesWithItems(
clientId: number,
userId: string,
filters: InvoiceFilters = {}
): Promise<InvoiceList> {
return this.invoiceService.getInvoicesWithItems(clientId, userId, filters);
}
/**
* Get individual invoice by ID with caching
*/
async getInvoiceById(clientId: number, userId: string, invoiceId: number): Promise<Invoice> {
return this.invoiceService.getInvoiceById(clientId, userId, invoiceId);
}
/**
* Invalidate cache for a specific invoice
*/
async invalidateInvoiceCache(userId: string, invoiceId: number): Promise<void> {
return this.invoiceService.invalidateInvoiceCache(userId, invoiceId);
}
// ==========================================
// SUBSCRIPTION OPERATIONS (delegate to SubscriptionService)
// ==========================================
/**
* Get client subscriptions/services with caching
*/
async getSubscriptions(
clientId: number,
userId: string,
filters: SubscriptionFilters = {}
): Promise<SubscriptionList> {
return this.subscriptionService.getSubscriptions(clientId, userId, filters);
}
/**
* Get individual subscription by ID
*/
async getSubscriptionById(
clientId: number,
userId: string,
subscriptionId: number
): Promise<Subscription> {
return this.subscriptionService.getSubscriptionById(clientId, userId, subscriptionId);
}
/**
* Invalidate cache for a specific subscription
*/
async invalidateSubscriptionCache(userId: string, subscriptionId: number): Promise<void> {
return this.subscriptionService.invalidateSubscriptionCache(userId, subscriptionId);
}
// ==========================================
// CLIENT OPERATIONS (delegate to ClientService)
// ==========================================
/**
* Validate client login credentials
*/
async validateLogin(
email: string,
password: string
): Promise<{ userId: number; passwordHash: string }> {
return this.clientService.validateLogin(email, password);
}
/**
* Get client details by ID
* Returns internal WhmcsClient (type inferred)
*/
async getClientDetails(clientId: number): Promise<WhmcsClient> {
return this.clientService.getClientDetails(clientId);
}
/**
* Update client details in WHMCS
*/
async updateClient(
clientId: number,
updateData: Partial<WhmcsClientResponse["client"]>
): Promise<void> {
return this.clientService.updateClient(clientId, updateData);
}
/**
* Convenience helpers for address get/update on WHMCS client
*/
async getClientAddress(clientId: number): Promise<Address> {
const customer = await this.clientService.getClientDetails(clientId);
return addressSchema.parse(customer.address ?? {});
}
async updateClientAddress(clientId: number, address: Partial<Address>): Promise<void> {
const parsed = addressSchema.partial().parse(address ?? {});
const updateData = prepareWhmcsClientAddressUpdate(parsed);
if (Object.keys(updateData).length === 0) return;
await this.clientService.updateClient(clientId, updateData);
}
/**
* Add new client
*/
async addClient(clientData: WhmcsAddClientParams): Promise<{ clientId: number }> {
return this.clientService.addClient(clientData);
}
/**
* Invalidate cache for a user
*/
async invalidateUserCache(userId: string): Promise<void> {
return this.clientService.invalidateUserCache(userId);
}
// ==========================================
// PAYMENT OPERATIONS (delegate to PaymentService)
// ==========================================
/**
* Get payment methods for a client
*/
async getPaymentMethods(clientId: number, userId: string): Promise<PaymentMethodList> {
return this.paymentService.getPaymentMethods(clientId, userId);
}
/**
* Get available payment gateways
*/
async getPaymentGateways(): Promise<PaymentGatewayList> {
return this.paymentService.getPaymentGateways();
}
/**
* Invalidate payment methods cache for a user
*/
async invalidatePaymentMethodsCache(userId: string): Promise<void> {
return this.paymentService.invalidatePaymentMethodsCache(userId);
}
/**
* Create SSO token with payment method for invoice payment
*/
async createPaymentSsoToken(
clientId: number,
invoiceId: number,
paymentMethodId?: number,
gatewayName?: string
): Promise<{ url: string; expiresAt: string }> {
return this.paymentService.createPaymentSsoToken(
clientId,
invoiceId,
paymentMethodId,
gatewayName
);
}
/**
* Get products catalog
*/
async getProducts(): Promise<WhmcsCatalogProductNormalized[]> {
return this.paymentService.getProducts();
}
// ==========================================
// SSO OPERATIONS (delegate to SsoService)
// ==========================================
/**
* Create SSO token for WHMCS access
*/
async createSsoToken(
clientId: number,
destination?: string,
ssoRedirectPath?: string
): Promise<{ url: string; expiresAt: string }> {
return this.ssoService.createSsoToken(clientId, destination, ssoRedirectPath);
}
/**
* Helper function to create SSO links for invoices
*/
async whmcsSsoForInvoice(
clientId: number,
invoiceId: number,
target: "view" | "download" | "pay"
): Promise<string> {
return this.ssoService.whmcsSsoForInvoice(clientId, invoiceId, target);
}
// ==========================================
// CONNECTION & HEALTH (delegate to ConnectionService)
// ==========================================
/**
* Health check for WHMCS API
*/
async healthCheck(): Promise<boolean> {
return this.connectionService.healthCheck();
}
/**
* Check if WHMCS service is available
*/
async isAvailable(): Promise<boolean> {
return this.connectionService.isAvailable();
}
/**
* Get WHMCS system information
*/
async getSystemInfo(): Promise<unknown> {
return this.connectionService.getSystemInfo();
}
async getClientsProducts(
params: WhmcsGetClientsProductsParams
): Promise<WhmcsProductListResponse> {
return this.connectionService.getClientsProducts(params);
}
// ==========================================
// ORDER OPERATIONS (delegate to OrderService)
// ==========================================
/**
* Get order service for direct access to order operations
* Used by OrderProvisioningService for complex order workflows
*/
getOrderService(): WhmcsOrderService {
return this.orderService;
}
// ==========================================
// INVOICE CREATION AND PAYMENT OPERATIONS (Used by SIM/Order services)
// ==========================================
/**
* Create a new invoice for a client
*/
async createInvoice(params: {
clientId: number;
description: string;
amount: number;
currency?: string;
dueDate?: Date;
notes?: string;
}): Promise<{ id: number; number: string; total: number; status: string }> {
return this.invoiceService.createInvoice(params);
}
/**
* Update an existing invoice
*/
async updateInvoice(params: {
invoiceId: number;
status?:
| "Draft"
| "Unpaid"
| "Paid"
| "Cancelled"
| "Refunded"
| "Collections"
| "Payment Pending";
dueDate?: Date;
notes?: string;
}): Promise<{ success: boolean; message?: string }> {
return this.invoiceService.updateInvoice(params);
}
/**
* Capture payment for an invoice
*/
async capturePayment(params: {
invoiceId: number;
amount: number;
currency?: string;
userId?: string;
}): Promise<{ success: boolean; transactionId?: string; error?: string }> {
return this.invoiceService.capturePayment(params);
}
}

View File

@ -5,7 +5,7 @@ import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.js";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@ -32,7 +32,7 @@ export class AuthHealthService {
constructor(
private readonly usersFacade: UsersFacade,
private readonly configService: ConfigService,
private readonly whmcsService: WhmcsService,
private readonly whmcsConnectionService: WhmcsConnectionOrchestratorService,
private readonly salesforceService: SalesforceService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -65,7 +65,7 @@ export class AuthHealthService {
// Check WHMCS
try {
health.whmcs = await this.whmcsService.healthCheck();
health.whmcs = await this.whmcsConnectionService.healthCheck();
} catch (error) {
this.logger.debug("WHMCS health check failed", { error: extractErrorMessage(error) });
}

View File

@ -1,8 +1,8 @@
import { Injectable, UnauthorizedException, BadRequestException, Inject } from "@nestjs/common";
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
import { WhmcsSsoService } from "@bff/integrations/whmcs/services/whmcs-sso.service.js";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@ -45,7 +45,7 @@ export class AuthFacade {
constructor(
private readonly usersFacade: UsersFacade,
private readonly mappingsService: MappingsService,
private readonly whmcsService: WhmcsService,
private readonly whmcsSsoService: WhmcsSsoService,
private readonly discoveryService: WhmcsAccountDiscoveryService,
private readonly salesforceService: SalesforceService,
private readonly auditService: AuditService,
@ -183,7 +183,7 @@ export class AuthFacade {
const ssoDestination = "sso:custom_redirect";
const ssoRedirectPath = sanitizeWhmcsRedirectPath(destination);
const result = await this.whmcsService.createSsoToken(
const result = await this.whmcsSsoService.createSsoToken(
whmcsClientId,
ssoDestination,
ssoRedirectPath

View File

@ -12,7 +12,10 @@ import { RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
import { createHash } from "crypto";
import type { Redis } from "ioredis";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { extractClientIp } from "@bff/core/http/request-context.util.js";
import {
buildRateLimitKey,
getRequestRateLimitIdentity,
} from "@bff/core/rate-limiting/rate-limit.util.js";
export interface RateLimitOutcome {
key: string;
@ -116,11 +119,8 @@ export class AuthRateLimitService {
}
private buildKey(type: string, request: Request, suffix?: string): string {
const ip = extractClientIp(request);
const userAgent = request.headers["user-agent"] || "unknown";
const uaHash = createHash("sha256").update(String(userAgent)).digest("hex").slice(0, 16);
return ["auth", type, ip, uaHash, suffix].filter(Boolean).join(":");
const { ip, userAgentHash } = getRequestRateLimitIdentity(request);
return buildRateLimitKey("auth", type, ip, userAgentHash, suffix);
}
private createLimiter(prefix: string, limit: number, ttlMs: number): RateLimiterRedis {

View File

@ -10,8 +10,8 @@ import {
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
@ -47,7 +47,7 @@ export interface WhmcsCreatedClient {
@Injectable()
export class SignupWhmcsService {
constructor(
private readonly whmcsService: WhmcsService,
private readonly whmcsClientService: WhmcsClientService,
private readonly discoveryService: WhmcsAccountDiscoveryService,
private readonly mappingsService: MappingsService,
private readonly configService: ConfigService,
@ -98,7 +98,7 @@ export class SignupWhmcsService {
});
try {
const whmcsClient = await this.whmcsService.addClient({
const whmcsClient = await this.whmcsClientService.addClient({
firstname: params.firstName,
lastname: params.lastName,
email: params.email,
@ -139,7 +139,7 @@ export class SignupWhmcsService {
*/
async markClientForCleanup(clientId: number, email: string): Promise<void> {
try {
await this.whmcsService.updateClient(clientId, {
await this.whmcsClientService.updateClient(clientId, {
status: "Inactive",
});

View File

@ -8,8 +8,8 @@ import {
import { Logger } from "nestjs-pino";
import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
@ -26,7 +26,7 @@ export class WhmcsLinkWorkflowService {
constructor(
private readonly usersFacade: UsersFacade,
private readonly mappingsService: MappingsService,
private readonly whmcsService: WhmcsService,
private readonly whmcsClientService: WhmcsClientService,
private readonly discoveryService: WhmcsAccountDiscoveryService,
private readonly salesforceService: SalesforceService,
@Inject(Logger) private readonly logger: Logger
@ -67,7 +67,7 @@ export class WhmcsLinkWorkflowService {
}
this.logger.debug("Validating WHMCS credentials");
const validateResult = await this.whmcsService.validateLogin(email, password);
const validateResult = await this.whmcsClientService.validateLogin(email, password);
this.logger.debug("WHMCS validation successful");
if (!validateResult || !validateResult.userId) {
throw new BadRequestException("Invalid email or password. Please try again.");

View File

@ -7,6 +7,7 @@ import {
type RequestWithRateLimit,
} from "@bff/modules/auth/presentation/http/guards/failed-login-throttle.guard.js";
import type { Request, Response } from "express";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@Injectable()
export class LoginResultInterceptor implements NestInterceptor {
@ -27,7 +28,7 @@ export class LoginResultInterceptor implements NestInterceptor {
}),
catchError(error =>
defer(async () => {
const message = this.extractErrorMessage(error).toLowerCase();
const message = extractErrorMessage(error).toLowerCase();
const status = this.extractStatusCode(error);
const isAuthError =
error instanceof UnauthorizedException ||
@ -63,19 +64,6 @@ export class LoginResultInterceptor implements NestInterceptor {
);
}
private extractErrorMessage(error: unknown): string {
if (typeof error === "string") {
return error;
}
if (error && typeof error === "object" && "message" in error) {
const maybeMessage = (error as { message?: unknown }).message;
if (typeof maybeMessage === "string") {
return maybeMessage;
}
}
return "";
}
private extractStatusCode(error: unknown): number | undefined {
if (error && typeof error === "object" && "status" in error) {
const statusValue = (error as { status?: unknown }).status;

View File

@ -1,6 +1,7 @@
import { Controller, Get, Post, Param, Query, Request, HttpCode, HttpStatus } from "@nestjs/common";
import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js";
import { WhmcsSsoService } from "@bff/integrations/whmcs/services/whmcs-sso.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { createZodDto, ZodResponse } from "nestjs-zod";
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
@ -47,7 +48,8 @@ class InvoicePaymentLinkDto extends createZodDto(invoicePaymentLinkSchema) {}
export class BillingController {
constructor(
private readonly invoicesService: InvoiceRetrievalService,
private readonly whmcsService: WhmcsService,
private readonly whmcsPaymentService: WhmcsPaymentService,
private readonly whmcsSsoService: WhmcsSsoService,
private readonly mappingsService: MappingsService
) {}
@ -64,13 +66,13 @@ export class BillingController {
@ZodResponse({ description: "List payment methods", type: PaymentMethodListDto })
async getPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
return this.whmcsService.getPaymentMethods(whmcsClientId, req.user.id);
return this.whmcsPaymentService.getPaymentMethods(whmcsClientId, req.user.id);
}
@Get("payment-gateways")
@ZodResponse({ description: "List payment gateways", type: PaymentGatewayListDto })
async getPaymentGateways(): Promise<PaymentGatewayList> {
return this.whmcsService.getPaymentGateways();
return this.whmcsPaymentService.getPaymentGateways();
}
@Post("payment-methods/refresh")
@ -78,11 +80,11 @@ export class BillingController {
@ZodResponse({ description: "Refresh payment methods", type: PaymentMethodListDto })
async refreshPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
// Invalidate cache first
await this.whmcsService.invalidatePaymentMethodsCache(req.user.id);
await this.whmcsPaymentService.invalidatePaymentMethodsCache(req.user.id);
// Return fresh payment methods
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
return this.whmcsService.getPaymentMethods(whmcsClientId, req.user.id);
return this.whmcsPaymentService.getPaymentMethods(whmcsClientId, req.user.id);
}
@Get(":id")
@ -104,7 +106,7 @@ export class BillingController {
): Promise<InvoiceSsoLink> {
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
const ssoUrl = await this.whmcsService.whmcsSsoForInvoice(
const ssoUrl = await this.whmcsSsoService.whmcsSsoForInvoice(
whmcsClientId,
params.id,
query.target
@ -126,7 +128,7 @@ export class BillingController {
): Promise<InvoicePaymentLink> {
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
const ssoResult = await this.whmcsService.createPaymentSsoToken(
const ssoResult = await this.whmcsPaymentService.createPaymentSsoToken(
whmcsClientId,
params.id,
query.paymentMethodId,

View File

@ -6,7 +6,8 @@ import type {
InvoiceListQuery,
InvoiceStatus,
} from "@customer-portal/domain/billing";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { INVOICE_PAGINATION } from "@customer-portal/domain/billing";
import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js";
@ -20,7 +21,7 @@ import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js";
@Injectable()
export class InvoiceRetrievalService {
constructor(
private readonly whmcsService: WhmcsService,
private readonly whmcsInvoiceService: WhmcsInvoiceService,
private readonly mappingsService: MappingsService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -29,7 +30,11 @@ export class InvoiceRetrievalService {
* Get paginated invoices for a user
*/
async getInvoices(userId: string, options: InvoiceListQuery = {}): Promise<InvoiceList> {
const { page = 1, limit = 10, status } = options;
const {
page = INVOICE_PAGINATION.DEFAULT_PAGE,
limit = INVOICE_PAGINATION.DEFAULT_LIMIT,
status,
} = options;
// Validate userId first
parseUuidOrThrow(userId, "Invalid user ID format");
@ -37,7 +42,7 @@ export class InvoiceRetrievalService {
return withErrorHandling(
async () => {
const invoiceList = await this.whmcsService.getInvoices(whmcsClientId, userId, {
const invoiceList = await this.whmcsInvoiceService.getInvoices(whmcsClientId, userId, {
page,
limit,
status,
@ -66,7 +71,11 @@ export class InvoiceRetrievalService {
return withErrorHandling(
async () => {
const invoice = await this.whmcsService.getInvoiceById(whmcsClientId, userId, invoiceId);
const invoice = await this.whmcsInvoiceService.getInvoiceById(
whmcsClientId,
userId,
invoiceId
);
this.logger.log(`Retrieved invoice ${invoiceId} for user ${userId}`);
return invoice;
},
@ -86,7 +95,8 @@ export class InvoiceRetrievalService {
status: InvoiceStatus,
options: Partial<InvoiceListQuery> = {}
): Promise<InvoiceList> {
const { page = 1, limit = 10 } = options;
const { page = INVOICE_PAGINATION.DEFAULT_PAGE, limit = INVOICE_PAGINATION.DEFAULT_LIMIT } =
options;
const queryStatus = status as "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
return withErrorHandling(

View File

@ -1,6 +1,7 @@
import { Injectable } from "@nestjs/common";
import { ORDER_FULFILLMENT_ERROR_CODE } from "@customer-portal/domain/orders";
import type { OrderFulfillmentErrorCode } from "@customer-portal/domain/orders";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
/**
* Centralized error code determination and error handling for order fulfillment
@ -14,7 +15,7 @@ export class OrderFulfillmentErrorService {
* Determine error code from error object or message
*/
determineErrorCode(error: unknown): OrderFulfillmentErrorCode {
const errorMessage = this.extractErrorMessage(error);
const errorMessage = extractErrorMessage(error);
if (errorMessage.includes("Payment method missing")) {
return ORDER_FULFILLMENT_ERROR_CODE.PAYMENT_METHOD_MISSING;
@ -64,16 +65,6 @@ export class OrderFulfillmentErrorService {
/**
* Extract error message from various error types
*/
private extractErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return String(error);
}
/**
* Create standardized error response
*/
@ -93,7 +84,7 @@ export class OrderFulfillmentErrorService {
* Extract a short error code for diagnostics (e.g., 429, 503, ETIMEOUT)
*/
getShortCode(error: unknown): string | undefined {
const msg = this.extractErrorMessage(error);
const msg = extractErrorMessage(error);
const m = msg.match(/HTTP\s+(\d{3})/i);
if (m) return m[1];
if (/timeout/i.test(msg)) return "ETIMEOUT";

View File

@ -13,7 +13,8 @@
import { Injectable, Inject, BadRequestException, NotFoundException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js";
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
@ -34,7 +35,8 @@ import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/
@Injectable()
export class InternetCancellationService {
constructor(
private readonly whmcsService: WhmcsService,
private readonly whmcsConnectionService: WhmcsConnectionOrchestratorService,
private readonly whmcsClientService: WhmcsClientService,
private readonly mappingsService: MappingsService,
private readonly caseService: SalesforceCaseService,
private readonly opportunityService: SalesforceOpportunityService,
@ -93,7 +95,7 @@ export class InternetCancellationService {
}
// Get subscription from WHMCS
const productsResponse = await this.whmcsService.getClientsProducts({
const productsResponse = await this.whmcsConnectionService.getClientsProducts({
clientid: mapping.whmcsClientId,
});
const productContainer = productsResponse.products?.product;
@ -150,7 +152,7 @@ export class InternetCancellationService {
);
// Get customer info from WHMCS
const clientDetails = await this.whmcsService.getClientDetails(whmcsClientId);
const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId);
const customerName =
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
const customerEmail = clientDetails.email || "";
@ -209,7 +211,7 @@ export class InternetCancellationService {
});
// Get customer info for notifications
const clientDetails = await this.whmcsService.getClientDetails(whmcsClientId);
const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId);
const customerName =
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
const customerEmail = clientDetails.email || "";

View File

@ -2,7 +2,7 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SimValidationService } from "./sim-validation.service.js";
import { SimNotificationService } from "./sim-notification.service.js";
@ -14,7 +14,7 @@ import type { SimReissueRequest, SimReissueFullRequest } from "@customer-portal/
export class EsimManagementService {
constructor(
private readonly freebitService: FreebitOrchestratorService,
private readonly whmcsService: WhmcsService,
private readonly whmcsClientService: WhmcsClientService,
private readonly mappingsService: MappingsService,
private readonly simValidation: SimValidationService,
private readonly simNotification: SimNotificationService,
@ -101,7 +101,7 @@ export class EsimManagementService {
// Get customer info from WHMCS
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
const clientDetails = await this.whmcsService.getClientDetails(whmcsClientId);
const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId);
const customerName =
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
const customerEmail = clientDetails.email || "";

View File

@ -1,6 +1,6 @@
import { BadRequestException, Inject, Injectable } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
export interface SimChargeInvoiceResult {
@ -24,7 +24,7 @@ interface OneTimeChargeParams {
@Injectable()
export class SimBillingService {
constructor(
private readonly whmcs: WhmcsService,
private readonly whmcsInvoiceService: WhmcsInvoiceService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -42,7 +42,7 @@ export class SimBillingService {
metadata = {},
} = params;
const invoice = await this.whmcs.createInvoice({
const invoice = await this.whmcsInvoiceService.createInvoice({
clientId,
description,
amount: amountJpy,
@ -51,7 +51,7 @@ export class SimBillingService {
notes,
});
const paymentResult = await this.whmcs.capturePayment({
const paymentResult = await this.whmcsInvoiceService.capturePayment({
invoiceId: invoice.id,
amount: amountJpy,
currency,
@ -90,7 +90,7 @@ export class SimBillingService {
}
async appendInvoiceNote(invoiceId: number, note: string): Promise<void> {
await this.whmcs.updateInvoice({
await this.whmcsInvoiceService.updateInvoice({
invoiceId,
notes: note,
});
@ -98,7 +98,7 @@ export class SimBillingService {
private async cancelInvoice(invoiceId: number, reason: string): Promise<void> {
try {
await this.whmcs.updateInvoice({
await this.whmcsInvoiceService.updateInvoice({
invoiceId,
status: "Cancelled",
notes: reason,

View File

@ -80,7 +80,7 @@ export class SimCallHistoryService {
const domestic: DomesticCallRecord[] = [];
const international: InternationalCallRecord[] = [];
const lines = content.split("\n").filter(line => line.trim());
const lines = content.split(/\r?\n/).filter(line => line.trim());
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
@ -179,7 +179,7 @@ export class SimCallHistoryService {
parseSmsDetailCsv(content: string, month: string): SmsRecord[] {
const records: SmsRecord[] = [];
const lines = content.split("\n").filter(line => line.trim());
const lines = content.split(/\r?\n/).filter(line => line.trim());
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
@ -587,15 +587,16 @@ export class SimCallHistoryService {
// Helper methods
private parseCsvLine(line: string): string[] {
const normalizedLine = line.replace(/\r$/, "").replace(/^\uFEFF/, "");
// CSV parsing with quoted field and escaped quote support
const result: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
for (let i = 0; i < normalizedLine.length; i++) {
const char = normalizedLine[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
if (inQuotes && normalizedLine[i + 1] === '"') {
current += '"';
i++;
continue;

View File

@ -2,7 +2,7 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SimValidationService } from "./sim-validation.service.js";
import type {
@ -21,7 +21,7 @@ import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/
export class SimCancellationService {
constructor(
private readonly freebitService: FreebitOrchestratorService,
private readonly whmcsService: WhmcsService,
private readonly whmcsClientService: WhmcsClientService,
private readonly mappingsService: MappingsService,
private readonly simValidation: SimValidationService,
private readonly simSchedule: SimScheduleService,
@ -101,7 +101,7 @@ export class SimCancellationService {
// Get customer info from WHMCS
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
const clientDetails = await this.whmcsService.getClientDetails(whmcsClientId);
const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId);
const customerName =
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
const customerEmail = clientDetails.email || "";
@ -196,7 +196,7 @@ export class SimCancellationService {
// Get customer info from WHMCS
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
const clientDetails = await this.whmcsService.getClientDetails(whmcsClientId);
const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId);
const customerName =
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
const customerEmail = clientDetails.email || "";

View File

@ -1,7 +1,7 @@
import { Injectable, BadRequestException, Inject, ConflictException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { CacheService } from "@bff/infra/cache/cache.service.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
@ -14,7 +14,7 @@ import { SimScheduleService } from "./sim-management/services/sim-schedule.servi
export class SimOrderActivationService {
constructor(
private readonly freebit: FreebitOrchestratorService,
private readonly whmcs: WhmcsService,
private readonly whmcsOrderService: WhmcsOrderService,
private readonly mappings: MappingsService,
private readonly cache: CacheService,
private readonly simBilling: SimBillingService,
@ -144,8 +144,7 @@ export class SimOrderActivationService {
if (req.monthlyAmountJpy > 0) {
const nextBillingIso = this.simSchedule.firstDayOfNextMonthIsoDate();
const orderService = this.whmcs.getOrderService();
await orderService.addOrder({
await this.whmcsOrderService.addOrder({
clientId: whmcsClientId,
items: [
{

View File

@ -11,8 +11,11 @@ import type {
SubscriptionStatus,
} from "@customer-portal/domain/subscriptions";
import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsCacheService } from "@bff/integrations/whmcs/cache/whmcs-cache.service.js";
import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js";
import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { Logger } from "nestjs-pino";
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
@ -27,7 +30,10 @@ export interface GetSubscriptionsOptions {
@Injectable()
export class SubscriptionsService {
constructor(
private readonly whmcsService: WhmcsService,
private readonly whmcsSubscriptionService: WhmcsSubscriptionService,
private readonly whmcsInvoiceService: WhmcsInvoiceService,
private readonly whmcsClientService: WhmcsClientService,
private readonly whmcsConnectionService: WhmcsConnectionOrchestratorService,
private readonly cacheService: WhmcsCacheService,
private readonly mappingsService: MappingsService,
@Inject(Logger) private readonly logger: Logger
@ -45,9 +51,11 @@ export class SubscriptionsService {
return withErrorHandling(
async () => {
const subscriptionList = await this.whmcsService.getSubscriptions(whmcsClientId, userId, {
status,
});
const subscriptionList = await this.whmcsSubscriptionService.getSubscriptions(
whmcsClientId,
userId,
{ status }
);
const parsed = subscriptionListSchema.parse(subscriptionList);
@ -84,7 +92,7 @@ export class SubscriptionsService {
return withErrorHandling(
async () => {
const subscription = await this.whmcsService.getSubscriptionById(
const subscription = await this.whmcsSubscriptionService.getSubscriptionById(
whmcsClientId,
userId,
subscriptionId
@ -338,10 +346,11 @@ export class SubscriptionsService {
let totalPages = 1;
do {
const invoiceBatch = await this.whmcsService.getInvoicesWithItems(whmcsClientId, userId, {
page: currentPage,
limit: batchSize,
});
const invoiceBatch = await this.whmcsInvoiceService.getInvoicesWithItems(
whmcsClientId,
userId,
{ page: currentPage, limit: batchSize }
);
totalPages = invoiceBatch.pagination.totalPages;
@ -414,9 +423,9 @@ export class SubscriptionsService {
async invalidateCache(userId: string, subscriptionId?: number): Promise<void> {
try {
if (subscriptionId) {
await this.whmcsService.invalidateSubscriptionCache(userId, subscriptionId);
await this.whmcsSubscriptionService.invalidateSubscriptionCache(userId, subscriptionId);
} else {
await this.whmcsService.invalidateUserCache(userId);
await this.whmcsClientService.invalidateUserCache(userId);
}
this.logger.log(
@ -435,7 +444,7 @@ export class SubscriptionsService {
*/
async healthCheck(): Promise<{ status: string; details: unknown }> {
try {
const whmcsHealthy = await this.whmcsService.healthCheck();
const whmcsHealthy = await this.whmcsConnectionService.healthCheck();
return {
status: whmcsHealthy ? "healthy" : "unhealthy",
@ -465,7 +474,7 @@ export class SubscriptionsService {
return false;
}
const productsResponse = await this.whmcsService.getClientsProducts({
const productsResponse = await this.whmcsConnectionService.getClientsProducts({
clientid: mapping.whmcsClientId,
});
const productContainer = productsResponse.products?.product;

View File

@ -28,7 +28,9 @@ import type { Invoice } from "@customer-portal/domain/billing";
import type { Activity, DashboardSummary, NextInvoice } from "@customer-portal/domain/dashboard";
import { dashboardSummarySchema } from "@customer-portal/domain/dashboard";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js";
import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js";
@ -39,7 +41,9 @@ export class UserProfileService {
constructor(
private readonly userAuthRepository: UserAuthRepository,
private readonly mappingsService: MappingsService,
private readonly whmcsService: WhmcsService,
private readonly whmcsClientService: WhmcsClientService,
private readonly whmcsInvoiceService: WhmcsInvoiceService,
private readonly whmcsSubscriptionService: WhmcsSubscriptionService,
private readonly salesforceService: SalesforceService,
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
@ -81,8 +85,8 @@ export class UserProfileService {
return withErrorHandling(
async () => {
await this.whmcsService.updateClientAddress(whmcsClientId, parsed);
await this.whmcsService.invalidateUserCache(validId);
await this.whmcsClientService.updateClientAddress(whmcsClientId, parsed);
await this.whmcsClientService.invalidateUserCache(validId);
this.logger.log("Successfully updated customer address in WHMCS", {
userId: validId,
@ -94,7 +98,7 @@ export class UserProfileService {
return refreshedProfile.address;
}
const refreshedAddress = await this.whmcsService.getClientAddress(whmcsClientId);
const refreshedAddress = await this.whmcsClientService.getClientAddress(whmcsClientId);
return addressSchema.parse(refreshedAddress ?? {});
},
this.logger,
@ -135,7 +139,7 @@ export class UserProfileService {
}
// Update WHMCS first (source of truth for billing profile), then update Portal DB.
await this.whmcsService.updateClient(mapping.whmcsClientId, { email: newEmail });
await this.whmcsClientService.updateClient(mapping.whmcsClientId, { email: newEmail });
await this.userAuthRepository.updateEmail(validId, newEmail);
}
@ -146,7 +150,7 @@ export class UserProfileService {
void firstname; // Name changes are explicitly disallowed
void lastname;
if (Object.keys(whmcsUpdate).length > 0) {
await this.whmcsService.updateClient(mapping.whmcsClientId, whmcsUpdate);
await this.whmcsClientService.updateClient(mapping.whmcsClientId, whmcsUpdate);
}
this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS");
@ -189,9 +193,9 @@ export class UserProfileService {
}
const [subscriptionsData, invoicesData, unpaidInvoicesData] = await Promise.allSettled([
this.whmcsService.getSubscriptions(mapping.whmcsClientId, userId),
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { limit: 10 }),
this.whmcsService.getInvoices(mapping.whmcsClientId, userId, {
this.whmcsSubscriptionService.getSubscriptions(mapping.whmcsClientId, userId),
this.whmcsInvoiceService.getInvoices(mapping.whmcsClientId, userId, { limit: 10 }),
this.whmcsInvoiceService.getInvoices(mapping.whmcsClientId, userId, {
status: "Unpaid",
limit: 1,
}),
@ -374,7 +378,7 @@ export class UserProfileService {
let currency = "JPY";
try {
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
const client = await this.whmcsClientService.getClientDetails(mapping.whmcsClientId);
const resolvedCurrency =
typeof client.currency_code === "string" && client.currency_code.trim().length > 0
? client.currency_code
@ -414,7 +418,7 @@ export class UserProfileService {
return withErrorHandling(
async () => {
const whmcsClient = await this.whmcsService.getClientDetails(whmcsClientId);
const whmcsClient = await this.whmcsClientService.getClientDetails(whmcsClientId);
const userAuth = mapPrismaUserToUserAuth(user);
const base = combineToUser(userAuth, whmcsClient);

View File

@ -10,6 +10,7 @@ import {
ErrorCode,
ErrorMessages,
ErrorMetadata,
mapHttpStatusToErrorCode,
type ErrorCodeType,
} from "@customer-portal/domain/common";
import { parseDomainError } from "@/lib/api";
@ -87,7 +88,7 @@ function parseApiError(error: ClientApiError): ParsedError {
}
// Fall back to status code mapping
const code = mapStatusToErrorCode(status);
const code = mapHttpStatusToErrorCode(status);
const metadata = ErrorMetadata[code];
return {
code,
@ -129,29 +130,6 @@ function parseNativeError(error: Error): ParsedError {
};
}
function mapStatusToErrorCode(status?: number): ErrorCodeType {
if (!status) return ErrorCode.UNKNOWN;
switch (status) {
case 400:
return ErrorCode.VALIDATION_FAILED;
case 401:
return ErrorCode.SESSION_EXPIRED;
case 403:
return ErrorCode.FORBIDDEN;
case 404:
return ErrorCode.NOT_FOUND;
case 409:
return ErrorCode.ACCOUNT_EXISTS;
case 429:
return ErrorCode.RATE_LIMITED;
case 503:
return ErrorCode.SERVICE_UNAVAILABLE;
default:
return status >= 500 ? ErrorCode.INTERNAL_ERROR : ErrorCode.UNKNOWN;
}
}
// ============================================================================
// Convenience Functions
// ============================================================================

View File

@ -23,7 +23,7 @@
"format": "prettier -w .",
"format:check": "prettier -c .",
"prepare": "husky",
"type-check": "pnpm --filter @customer-portal/domain run type-check && pnpm --filter @customer-portal/bff --filter @customer-portal/portal run type-check",
"type-check": "pnpm --filter @customer-portal/domain build && pnpm --filter @customer-portal/bff --filter @customer-portal/portal run type-check",
"check:imports": "node scripts/check-domain-imports.mjs",
"clean": "pnpm --recursive run clean",
"dev:start": "./scripts/dev/manage.sh start",

View File

@ -6,6 +6,7 @@
*/
import { z } from "zod";
import { INVOICE_PAGINATION } from "./constants.js";
// ============================================================================
// Currency (Domain Model)
@ -128,8 +129,19 @@ export const billingSummarySchema = z.object({
* Schema for invoice list query parameters
*/
export const invoiceQueryParamsSchema = z.object({
page: z.coerce.number().int().positive().optional(),
limit: z.coerce.number().int().positive().max(100).optional(),
page: z.coerce
.number()
.int()
.min(INVOICE_PAGINATION.DEFAULT_PAGE)
.optional()
.default(INVOICE_PAGINATION.DEFAULT_PAGE),
limit: z.coerce
.number()
.int()
.min(INVOICE_PAGINATION.MIN_LIMIT)
.max(INVOICE_PAGINATION.MAX_LIMIT)
.optional()
.default(INVOICE_PAGINATION.DEFAULT_LIMIT),
status: invoiceStatusSchema.optional(),
dateFrom: z.string().datetime().optional(),
dateTo: z.string().datetime().optional(),
@ -140,8 +152,19 @@ export type InvoiceQueryParams = z.infer<typeof invoiceQueryParamsSchema>;
const invoiceListStatusSchema = z.enum(["Paid", "Unpaid", "Cancelled", "Overdue", "Collections"]);
export const invoiceListQuerySchema = z.object({
page: z.coerce.number().int().positive().optional(),
limit: z.coerce.number().int().positive().max(100).optional(),
page: z.coerce
.number()
.int()
.min(INVOICE_PAGINATION.DEFAULT_PAGE)
.optional()
.default(INVOICE_PAGINATION.DEFAULT_PAGE),
limit: z.coerce
.number()
.int()
.min(INVOICE_PAGINATION.MIN_LIMIT)
.max(INVOICE_PAGINATION.MAX_LIMIT)
.optional()
.default(INVOICE_PAGINATION.DEFAULT_LIMIT),
status: invoiceListStatusSchema.optional(),
});

View File

@ -377,6 +377,33 @@ export const ErrorMetadata: Record<ErrorCodeType, ErrorMetadata> = {
// Helper Functions
// ============================================================================
/**
* Map HTTP status codes to domain error codes.
* Shared between BFF (for responses) and Portal (for parsing).
*/
export function mapHttpStatusToErrorCode(status?: number): ErrorCodeType {
if (!status) return ErrorCode.UNKNOWN;
switch (status) {
case 401:
return ErrorCode.SESSION_EXPIRED;
case 403:
return ErrorCode.FORBIDDEN;
case 404:
return ErrorCode.NOT_FOUND;
case 409:
return ErrorCode.ACCOUNT_EXISTS;
case 400:
return ErrorCode.VALIDATION_FAILED;
case 429:
return ErrorCode.RATE_LIMITED;
case 503:
return ErrorCode.SERVICE_UNAVAILABLE;
default:
return status >= 500 ? ErrorCode.INTERNAL_ERROR : ErrorCode.UNKNOWN;
}
}
/**
* Get user-friendly message for an error code
*/

View File

@ -48,12 +48,12 @@ export function normalizeAndValidateEmail(email: string): string {
*
* @throws Error if UUID format is invalid
*/
export function validateUuidV4OrThrow(id: string): string {
try {
return uuidSchema.parse(id);
} catch {
throw new Error("Invalid user ID format");
export function validateUuidV4OrThrow(id: string, message = "Invalid UUID format"): string {
const parsed = uuidSchema.safeParse(id);
if (!parsed.success) {
throw new Error(message);
}
return parsed.data;
}
/**