From a55967a31f7c30e0773d62ef7fefa8441d50f1a5 Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 29 Dec 2025 15:44:01 +0900 Subject: [PATCH] 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. --- apps/bff/src/core/http/exception.filter.ts | 52 +-- apps/bff/src/core/logging/request-id.util.ts | 6 + .../core/rate-limiting/rate-limit.guard.ts | 9 +- .../src/core/rate-limiting/rate-limit.util.ts | 23 ++ apps/bff/src/core/utils/validation.util.ts | 9 +- .../distributed-transaction.service.ts | 22 +- .../database/services/transaction.service.ts | 23 +- .../salesforce-request-queue.service.ts | 3 +- .../services/whmcs-request-queue.service.ts | 41 +-- .../services/freebit-cancellation.service.ts | 78 ++-- .../freebit/services/freebit-plan.service.ts | 133 ++++--- .../services/freebit-rate-limiter.service.ts | 55 +++ .../freebit/services/freebit-voice.service.ts | 190 +++++----- .../whmcs/services/whmcs-client.service.ts | 27 +- .../src/integrations/whmcs/whmcs.module.ts | 6 +- .../src/integrations/whmcs/whmcs.service.ts | 340 ------------------ .../auth/application/auth-health.service.ts | 6 +- .../modules/auth/application/auth.facade.ts | 6 +- .../rate-limiting/auth-rate-limit.service.ts | 12 +- .../workflows/signup/signup-whmcs.service.ts | 8 +- .../workflows/whmcs-link-workflow.service.ts | 6 +- .../interceptors/login-result.interceptor.ts | 16 +- .../src/modules/billing/billing.controller.ts | 18 +- .../services/invoice-retrieval.service.ts | 22 +- .../order-fulfillment-error.service.ts | 15 +- .../services/internet-cancellation.service.ts | 12 +- .../services/esim-management.service.ts | 6 +- .../services/sim-billing.service.ts | 12 +- .../services/sim-call-history.service.ts | 11 +- .../services/sim-cancellation.service.ts | 8 +- .../sim-order-activation.service.ts | 7 +- .../subscriptions/subscriptions.service.ts | 37 +- .../users/infra/user-profile.service.ts | 28 +- apps/portal/src/lib/utils/error-handling.ts | 26 +- package.json | 2 +- packages/domain/billing/schema.ts | 31 +- packages/domain/common/errors.ts | 27 ++ packages/domain/common/validation.ts | 10 +- 38 files changed, 562 insertions(+), 781 deletions(-) create mode 100644 apps/bff/src/core/logging/request-id.util.ts create mode 100644 apps/bff/src/core/rate-limiting/rate-limit.util.ts delete mode 100644 apps/bff/src/integrations/whmcs/whmcs.service.ts diff --git a/apps/bff/src/core/http/exception.filter.ts b/apps/bff/src/core/http/exception.filter.ts index ec622767..b032491a 100644 --- a/apps/bff/src/core/http/exception.filter.ts +++ b/apps/bff/src/core/http/exception.filter.ts @@ -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"); } } diff --git a/apps/bff/src/core/logging/request-id.util.ts b/apps/bff/src/core/logging/request-id.util.ts new file mode 100644 index 00000000..a23441e6 --- /dev/null +++ b/apps/bff/src/core/logging/request-id.util.ts @@ -0,0 +1,6 @@ +import { randomUUID } from "crypto"; + +export function generateRequestId(prefix?: string): string { + const id = randomUUID(); + return prefix ? `${prefix}_${id}` : id; +} diff --git a/apps/bff/src/core/rate-limiting/rate-limit.guard.ts b/apps/bff/src/core/rate-limiting/rate-limit.guard.ts index 3c0de603..f549f6e3 100644 --- a/apps/bff/src/core/rate-limiting/rate-limit.guard.ts +++ b/apps/bff/src/core/rate-limiting/rate-limit.guard.ts @@ -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); } /** diff --git a/apps/bff/src/core/rate-limiting/rate-limit.util.ts b/apps/bff/src/core/rate-limiting/rate-limit.util.ts new file mode 100644 index 00000000..d2c34613 --- /dev/null +++ b/apps/bff/src/core/rate-limiting/rate-limit.util.ts @@ -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 { + return parts.filter(Boolean).join(":"); +} diff --git a/apps/bff/src/core/utils/validation.util.ts b/apps/bff/src/core/utils/validation.util.ts index c008390f..275aec66 100644 --- a/apps/bff/src/core/utils/validation.util.ts +++ b/apps/bff/src/core/utils/validation.util.ts @@ -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; } diff --git a/apps/bff/src/infra/database/services/distributed-transaction.service.ts b/apps/bff/src/infra/database/services/distributed-transaction.service.ts index 2012d5e7..be61ab32 100644 --- a/apps/bff/src/infra/database/services/distributed-transaction.service.ts +++ b/apps/bff/src/infra/database/services/distributed-transaction.service.ts @@ -6,7 +6,7 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js"; export interface DistributedStep { id: TId; description: string; - execute: () => Promise; + execute: (signal?: AbortSignal) => Promise; rollback?: () => Promise; 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, timeout: number ): Promise { - return (await Promise.race([ - step.execute(), - new Promise((_, reject) => { - setTimeout(() => { + const controller = new AbortController(); + let timer: ReturnType | null = null; + try { + const timeoutPromise = new Promise((_, 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( diff --git a/apps/bff/src/infra/database/services/transaction.service.ts b/apps/bff/src/infra/database/services/transaction.service.ts index 8a0ada10..cadffa80 100644 --- a/apps/bff/src/infra/database/services/transaction.service.ts +++ b/apps/bff/src/infra/database/services/transaction.service.ts @@ -154,10 +154,12 @@ export class TransactionService { }; } - const result = await Promise.race([ - this.executeTransactionAttempt(operation, context, isolationLevel), - this.createTimeoutPromise(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( operation: TransactionOperation, context: TransactionContext, - isolationLevel: Prisma.TransactionIsolationLevel + isolationLevel: Prisma.TransactionIsolationLevel, + timeout: number ): Promise { 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(timeout: number, transactionId: string): Promise { - return new Promise((_, reject) => { - setTimeout(() => { - reject(new Error(`Transaction timeout after ${timeout}ms [${transactionId}]`)); - }, timeout); - }); - } - private async delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } diff --git a/apps/bff/src/infra/queue/services/salesforce-request-queue.service.ts b/apps/bff/src/infra/queue/services/salesforce-request-queue.service.ts index 9ebdc5a1..75f063f0 100644 --- a/apps/bff/src/infra/queue/services/salesforce-request-queue.service.ts +++ b/apps/bff/src/infra/queue/services/salesforce-request-queue.service.ts @@ -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 { diff --git a/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts b/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts index 7c703b97..1fc7a999 100644 --- a/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts +++ b/apps/bff/src/infra/queue/services/whmcs-request-queue.service.ts @@ -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, options: WhmcsRequestOptions ): Promise { - 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"); } } diff --git a/apps/bff/src/integrations/freebit/services/freebit-cancellation.service.ts b/apps/bff/src/integrations/freebit/services/freebit-cancellation.service.ts index 6b216ddc..101a8e33 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-cancellation.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-cancellation.service.ts @@ -27,26 +27,27 @@ export class FreebitCancellationService { */ async cancelSim(account: string, scheduledAt?: string): Promise { try { - const request: Omit = { - account, - runTime: scheduledAt, - }; + await this.rateLimiter.executeWithSpacing(account, "cancellation", async () => { + const request: Omit = { + 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( + "/mvno/releasePlan/", + request + ); + + this.logger.log(`Successfully cancelled SIM plan for account ${account}`, { + account, + runTime: scheduledAt, + }); }); - - await this.client.makeAuthenticatedRequest( - "/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 { try { - const request: Omit = { - kind: "MVNO", - account, - runDate, - }; + await this.rateLimiter.executeWithSpacing(account, "cancellation", async () => { + const request: Omit = { + 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( + "/master/cnclAcnt/", + request + ); + + this.logger.log(`Successfully cancelled SIM account for account ${account}`, { + account, + runDate, + }); }); - - await this.client.makeAuthenticatedRequest( - "/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}`, { diff --git a/apps/bff/src/integrations/freebit/services/freebit-plan.service.ts b/apps/bff/src/integrations/freebit/services/freebit-plan.service.ts index 90f8c27b..553d2925 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-plan.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-plan.service.ts @@ -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 = { 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 = { - 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); diff --git a/apps/bff/src/integrations/freebit/services/freebit-rate-limiter.service.ts b/apps/bff/src/integrations/freebit/services/freebit-rate-limiter.service.ts index f3948136..19ea62d8 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-rate-limiter.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-rate-limiter.service.ts @@ -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 { + 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 { + 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( + account: string, + op: OperationType, + operation: () => Promise + ): Promise { + 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 { const key = this.buildKey(account); try { diff --git a/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts b/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts index 2ccc27ef..3b4f8b56 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-voice.service.ts @@ -120,72 +120,71 @@ export class FreebitVoiceService { */ async updateVoiceFeatures(account: string, features: VoiceFeatures): Promise { try { - await this.rateLimiter.assertOperationSpacing(account, "voice"); + await this.rateLimiter.executeWithSpacing(account, "voice", async () => { + const buildVoiceOptionPayload = (): Omit => { + const talkOption: FreebitVoiceOptionSettings = {}; - const buildVoiceOptionPayload = (): Omit => { - 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 { + 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 = { - account, - contractLine: networkType, - ...(eid ? { eid } : {}), - ...(productNumber ? { productNumber } : {}), - }; + await this.rateLimiter.executeWithSpacing(account, "network", async () => { + const request: Omit = { + 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}`, { diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts index 17f0ffca..61617748 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-client.service.ts @@ -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
{ + 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
): Promise { + const parsed = addressSchema.partial().parse(address ?? {}); + const updateData = prepareWhmcsClientAddressUpdate(parsed); + if (Object.keys(updateData).length === 0) return; + await this.updateClient(clientId, updateData); + } } diff --git a/apps/bff/src/integrations/whmcs/whmcs.module.ts b/apps/bff/src/integrations/whmcs/whmcs.module.ts index c1bf4b0e..9b2ab233 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.module.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.module.ts @@ -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, ], diff --git a/apps/bff/src/integrations/whmcs/whmcs.service.ts b/apps/bff/src/integrations/whmcs/whmcs.service.ts deleted file mode 100644 index 3bbcb991..00000000 --- a/apps/bff/src/integrations/whmcs/whmcs.service.ts +++ /dev/null @@ -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 { - return this.invoiceService.getInvoices(clientId, userId, filters); - } - - /** - * Get invoices with items (for subscription linking) - */ - async getInvoicesWithItems( - clientId: number, - userId: string, - filters: InvoiceFilters = {} - ): Promise { - return this.invoiceService.getInvoicesWithItems(clientId, userId, filters); - } - - /** - * Get individual invoice by ID with caching - */ - async getInvoiceById(clientId: number, userId: string, invoiceId: number): Promise { - return this.invoiceService.getInvoiceById(clientId, userId, invoiceId); - } - - /** - * Invalidate cache for a specific invoice - */ - async invalidateInvoiceCache(userId: string, invoiceId: number): Promise { - 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 { - return this.subscriptionService.getSubscriptions(clientId, userId, filters); - } - - /** - * Get individual subscription by ID - */ - async getSubscriptionById( - clientId: number, - userId: string, - subscriptionId: number - ): Promise { - return this.subscriptionService.getSubscriptionById(clientId, userId, subscriptionId); - } - - /** - * Invalidate cache for a specific subscription - */ - async invalidateSubscriptionCache(userId: string, subscriptionId: number): Promise { - 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 { - return this.clientService.getClientDetails(clientId); - } - - /** - * Update client details in WHMCS - */ - async updateClient( - clientId: number, - updateData: Partial - ): Promise { - return this.clientService.updateClient(clientId, updateData); - } - - /** - * Convenience helpers for address get/update on WHMCS client - */ - async getClientAddress(clientId: number): Promise
{ - const customer = await this.clientService.getClientDetails(clientId); - return addressSchema.parse(customer.address ?? {}); - } - - async updateClientAddress(clientId: number, address: Partial
): Promise { - 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 { - return this.clientService.invalidateUserCache(userId); - } - - // ========================================== - // PAYMENT OPERATIONS (delegate to PaymentService) - // ========================================== - - /** - * Get payment methods for a client - */ - async getPaymentMethods(clientId: number, userId: string): Promise { - return this.paymentService.getPaymentMethods(clientId, userId); - } - - /** - * Get available payment gateways - */ - async getPaymentGateways(): Promise { - return this.paymentService.getPaymentGateways(); - } - - /** - * Invalidate payment methods cache for a user - */ - async invalidatePaymentMethodsCache(userId: string): Promise { - 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 { - 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 { - return this.ssoService.whmcsSsoForInvoice(clientId, invoiceId, target); - } - - // ========================================== - // CONNECTION & HEALTH (delegate to ConnectionService) - // ========================================== - - /** - * Health check for WHMCS API - */ - async healthCheck(): Promise { - return this.connectionService.healthCheck(); - } - - /** - * Check if WHMCS service is available - */ - async isAvailable(): Promise { - return this.connectionService.isAvailable(); - } - - /** - * Get WHMCS system information - */ - async getSystemInfo(): Promise { - return this.connectionService.getSystemInfo(); - } - - async getClientsProducts( - params: WhmcsGetClientsProductsParams - ): Promise { - 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); - } -} diff --git a/apps/bff/src/modules/auth/application/auth-health.service.ts b/apps/bff/src/modules/auth/application/auth-health.service.ts index cbd183a1..6284cd98 100644 --- a/apps/bff/src/modules/auth/application/auth-health.service.ts +++ b/apps/bff/src/modules/auth/application/auth-health.service.ts @@ -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) }); } diff --git a/apps/bff/src/modules/auth/application/auth.facade.ts b/apps/bff/src/modules/auth/application/auth.facade.ts index eff5cca2..7a8e8bbe 100644 --- a/apps/bff/src/modules/auth/application/auth.facade.ts +++ b/apps/bff/src/modules/auth/application/auth.facade.ts @@ -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 diff --git a/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts b/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts index 373b4b5b..6738a6aa 100644 --- a/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts +++ b/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts @@ -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 { diff --git a/apps/bff/src/modules/auth/infra/workflows/signup/signup-whmcs.service.ts b/apps/bff/src/modules/auth/infra/workflows/signup/signup-whmcs.service.ts index 212c4d9d..f1dc8b2c 100644 --- a/apps/bff/src/modules/auth/infra/workflows/signup/signup-whmcs.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/signup/signup-whmcs.service.ts @@ -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 { try { - await this.whmcsService.updateClient(clientId, { + await this.whmcsClientService.updateClient(clientId, { status: "Inactive", }); diff --git a/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts index b3cd6622..7aa8cbc6 100644 --- a/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts @@ -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."); diff --git a/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts b/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts index 45fd2278..60873010 100644 --- a/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts +++ b/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts @@ -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; diff --git a/apps/bff/src/modules/billing/billing.controller.ts b/apps/bff/src/modules/billing/billing.controller.ts index 9d0a6d94..0d47487d 100644 --- a/apps/bff/src/modules/billing/billing.controller.ts +++ b/apps/bff/src/modules/billing/billing.controller.ts @@ -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 { 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 { - 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 { // 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 { 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 { 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, diff --git a/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts b/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts index 7adb986b..0f7a6d0e 100644 --- a/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts +++ b/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts @@ -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 { - 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 = {} ): Promise { - 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( diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-error.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-error.service.ts index 80e1f02e..eadbb572 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-error.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-error.service.ts @@ -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"; diff --git a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts index a1de84dc..a8e229a2 100644 --- a/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/internet-management/services/internet-cancellation.service.ts @@ -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 || ""; diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts index 5c9a89a5..f85e6577 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts @@ -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 || ""; diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts index 9eb2562e..56081db4 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts @@ -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 { - 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 { try { - await this.whmcs.updateInvoice({ + await this.whmcsInvoiceService.updateInvoice({ invoiceId, status: "Cancelled", notes: reason, diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts index a26f47f1..5930f984 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts @@ -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; diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts index 9044f374..109152eb 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts @@ -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 || ""; diff --git a/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts b/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts index d5330dbf..95e7261a 100644 --- a/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts @@ -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: [ { diff --git a/apps/bff/src/modules/subscriptions/subscriptions.service.ts b/apps/bff/src/modules/subscriptions/subscriptions.service.ts index 13a7d5e0..466131e0 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.service.ts @@ -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 { 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; diff --git a/apps/bff/src/modules/users/infra/user-profile.service.ts b/apps/bff/src/modules/users/infra/user-profile.service.ts index 0fdc06fa..5069ed06 100644 --- a/apps/bff/src/modules/users/infra/user-profile.service.ts +++ b/apps/bff/src/modules/users/infra/user-profile.service.ts @@ -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); diff --git a/apps/portal/src/lib/utils/error-handling.ts b/apps/portal/src/lib/utils/error-handling.ts index 3236e8c7..c716d889 100644 --- a/apps/portal/src/lib/utils/error-handling.ts +++ b/apps/portal/src/lib/utils/error-handling.ts @@ -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 // ============================================================================ diff --git a/package.json b/package.json index f7c72230..6519fd10 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/domain/billing/schema.ts b/packages/domain/billing/schema.ts index 19ecaf30..03e62687 100644 --- a/packages/domain/billing/schema.ts +++ b/packages/domain/billing/schema.ts @@ -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; 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(), }); diff --git a/packages/domain/common/errors.ts b/packages/domain/common/errors.ts index 4ffbcbfe..9e89194d 100644 --- a/packages/domain/common/errors.ts +++ b/packages/domain/common/errors.ts @@ -377,6 +377,33 @@ export const ErrorMetadata: Record = { // 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 */ diff --git a/packages/domain/common/validation.ts b/packages/domain/common/validation.ts index 68391536..d827e42f 100644 --- a/packages/domain/common/validation.ts +++ b/packages/domain/common/validation.ts @@ -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; } /**