Refactor Error Handling and Improve Service Consistency
- Introduced `mapHttpStatusToErrorCode` function in the domain to standardize error code mapping from HTTP status codes, enhancing error handling across the application. - Updated various services and controllers to utilize the new error mapping function, ensuring consistent error responses and improved clarity in logging. - Refactored error handling in the BillingController and other modules to align with the updated practices, promoting maintainability and clarity in API responses. - Cleaned up unused imports and optimized code structure for better maintainability across the BFF modules.
This commit is contained in:
parent
03eccd1db2
commit
a55967a31f
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
6
apps/bff/src/core/logging/request-id.util.ts
Normal file
6
apps/bff/src/core/logging/request-id.util.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export function generateRequestId(prefix?: string): string {
|
||||
const id = randomUUID();
|
||||
return prefix ? `${prefix}_${id}` : id;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
23
apps/bff/src/core/rate-limiting/rate-limit.util.ts
Normal file
23
apps/bff/src/core/rate-limiting/rate-limit.util.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { createHash } from "crypto";
|
||||
import type { Request } from "express";
|
||||
import { extractClientIp } from "@bff/core/http/request-context.util.js";
|
||||
|
||||
export function getUserAgentHash(userAgent: string | string[] | undefined): string {
|
||||
const value = Array.isArray(userAgent) ? userAgent[0] : userAgent;
|
||||
const normalized = typeof value === "string" ? value : "unknown";
|
||||
return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export function getRequestRateLimitIdentity(request: Request): {
|
||||
ip: string;
|
||||
userAgentHash: string;
|
||||
} {
|
||||
return {
|
||||
ip: extractClientIp(request),
|
||||
userAgentHash: getUserAgentHash(request.headers["user-agent"]),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRateLimitKey(...parts: Array<string | undefined>): string {
|
||||
return parts.filter(Boolean).join(":");
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
export interface DistributedStep<TId extends string = string, TResult = unknown> {
|
||||
id: TId;
|
||||
description: string;
|
||||
execute: () => Promise<TResult>;
|
||||
execute: (signal?: AbortSignal) => Promise<TResult>;
|
||||
rollback?: () => Promise<void>;
|
||||
critical?: boolean; // If true, failure stops entire transaction
|
||||
retryable?: boolean; // If true, step can be retried on failure
|
||||
@ -360,14 +360,22 @@ export class DistributedTransactionService {
|
||||
step: DistributedStep<string, TResult>,
|
||||
timeout: number
|
||||
): Promise<TResult> {
|
||||
return (await Promise.race([
|
||||
step.execute(),
|
||||
new Promise<never>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error(`Step ${step.id} timed out after ${timeout}ms`));
|
||||
}, timeout);
|
||||
}),
|
||||
])) as TResult;
|
||||
});
|
||||
|
||||
return (await Promise.race([step.execute(controller.signal), timeoutPromise])) as TResult;
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeRollbacks<TSteps extends readonly DistributedStep[]>(
|
||||
|
||||
@ -154,10 +154,12 @@ export class TransactionService {
|
||||
};
|
||||
}
|
||||
|
||||
const result = await Promise.race([
|
||||
this.executeTransactionAttempt(operation, context, isolationLevel),
|
||||
this.createTimeoutPromise<T>(timeout, transactionId),
|
||||
]);
|
||||
const result = await this.executeTransactionAttempt(
|
||||
operation,
|
||||
context,
|
||||
isolationLevel,
|
||||
timeout
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime.getTime();
|
||||
|
||||
@ -238,7 +240,8 @@ export class TransactionService {
|
||||
private async executeTransactionAttempt<T>(
|
||||
operation: TransactionOperation<T>,
|
||||
context: TransactionContext,
|
||||
isolationLevel: Prisma.TransactionIsolationLevel
|
||||
isolationLevel: Prisma.TransactionIsolationLevel,
|
||||
timeout: number
|
||||
): Promise<T> {
|
||||
return await this.prisma.$transaction(
|
||||
async tx => {
|
||||
@ -250,7 +253,7 @@ export class TransactionService {
|
||||
},
|
||||
{
|
||||
isolationLevel,
|
||||
timeout: 30000, // Prisma transaction timeout
|
||||
timeout,
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -317,14 +320,6 @@ export class TransactionService {
|
||||
);
|
||||
}
|
||||
|
||||
private async createTimeoutPromise<T>(timeout: number, transactionId: string): Promise<T> {
|
||||
return new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(`Transaction timeout after ${timeout}ms [${transactionId}]`));
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
private async delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
SalesforceQueueDegradationService,
|
||||
type SalesforceDegradationSnapshot,
|
||||
} from "./salesforce-queue-degradation.service.js";
|
||||
import { generateRequestId } from "@bff/core/logging/request-id.util.js";
|
||||
|
||||
export interface SalesforceQueueMetrics {
|
||||
totalRequests: number;
|
||||
@ -569,7 +570,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest
|
||||
}
|
||||
|
||||
private generateRequestId(): string {
|
||||
return `sf_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
return generateRequestId("sf");
|
||||
}
|
||||
|
||||
private async restoreDailyUsageFromCache(): Promise<void> {
|
||||
|
||||
@ -2,6 +2,8 @@ import { Injectable, Inject } from "@nestjs/common";
|
||||
import type { OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { withRetry } from "@bff/core/utils/retry.util.js";
|
||||
import { generateRequestId } from "@bff/core/logging/request-id.util.js";
|
||||
|
||||
type PQueueCtor = new (options: {
|
||||
concurrency?: number;
|
||||
@ -285,37 +287,12 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
||||
requestFn: () => Promise<T>,
|
||||
options: WhmcsRequestOptions
|
||||
): Promise<T> {
|
||||
const maxAttempts = options.retryAttempts || 3;
|
||||
const baseDelay = options.retryDelay || 1000;
|
||||
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const result = await requestFn();
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (attempt === maxAttempts) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Exponential backoff with jitter
|
||||
const delay = baseDelay * Math.pow(2, attempt - 1) + Math.random() * 1000;
|
||||
|
||||
this.logger.debug("WHMCS request failed, retrying", {
|
||||
attempt,
|
||||
maxAttempts,
|
||||
delay,
|
||||
error: lastError.message,
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
||||
return withRetry(requestFn, {
|
||||
maxAttempts: options.retryAttempts ?? 3,
|
||||
baseDelayMs: options.retryDelay ?? 1000,
|
||||
logger: this.logger,
|
||||
logContext: "WHMCS request",
|
||||
});
|
||||
}
|
||||
|
||||
private setupQueueListeners(): void {
|
||||
@ -378,6 +355,6 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
private generateRequestId(): string {
|
||||
return `whmcs_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
return generateRequestId("whmcs");
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,26 +27,27 @@ export class FreebitCancellationService {
|
||||
*/
|
||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||
try {
|
||||
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
|
||||
account,
|
||||
runTime: scheduledAt,
|
||||
};
|
||||
await this.rateLimiter.executeWithSpacing(account, "cancellation", async () => {
|
||||
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
|
||||
account,
|
||||
runTime: scheduledAt,
|
||||
};
|
||||
|
||||
this.logger.log(`Cancelling SIM plan via PA05-04 for account ${account}`, {
|
||||
account,
|
||||
runTime: scheduledAt,
|
||||
this.logger.log(`Cancelling SIM plan via PA05-04 for account ${account}`, {
|
||||
account,
|
||||
runTime: scheduledAt,
|
||||
});
|
||||
|
||||
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
|
||||
"/mvno/releasePlan/",
|
||||
request
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully cancelled SIM plan for account ${account}`, {
|
||||
account,
|
||||
runTime: scheduledAt,
|
||||
});
|
||||
});
|
||||
|
||||
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
|
||||
"/mvno/releasePlan/",
|
||||
request
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully cancelled SIM plan for account ${account}`, {
|
||||
account,
|
||||
runTime: scheduledAt,
|
||||
});
|
||||
await this.rateLimiter.stampOperation(account, "cancellation");
|
||||
} catch (error) {
|
||||
const message = extractErrorMessage(error);
|
||||
this.logger.error(`Failed to cancel SIM plan for account ${account}`, {
|
||||
@ -70,28 +71,29 @@ export class FreebitCancellationService {
|
||||
*/
|
||||
async cancelAccount(account: string, runDate?: string): Promise<void> {
|
||||
try {
|
||||
const request: Omit<FreebitCancelAccountRequest, "authKey"> = {
|
||||
kind: "MVNO",
|
||||
account,
|
||||
runDate,
|
||||
};
|
||||
await this.rateLimiter.executeWithSpacing(account, "cancellation", async () => {
|
||||
const request: Omit<FreebitCancelAccountRequest, "authKey"> = {
|
||||
kind: "MVNO",
|
||||
account,
|
||||
runDate,
|
||||
};
|
||||
|
||||
this.logger.log(`Cancelling SIM account via PA02-04 for account ${account}`, {
|
||||
account,
|
||||
runDate,
|
||||
note: "After this, PA05-21 plan changes will cancel the cancellation",
|
||||
this.logger.log(`Cancelling SIM account via PA02-04 for account ${account}`, {
|
||||
account,
|
||||
runDate,
|
||||
note: "After this, PA05-21 plan changes will cancel the cancellation",
|
||||
});
|
||||
|
||||
await this.client.makeAuthenticatedRequest<FreebitCancelAccountResponse, typeof request>(
|
||||
"/master/cnclAcnt/",
|
||||
request
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully cancelled SIM account for account ${account}`, {
|
||||
account,
|
||||
runDate,
|
||||
});
|
||||
});
|
||||
|
||||
await this.client.makeAuthenticatedRequest<FreebitCancelAccountResponse, typeof request>(
|
||||
"/master/cnclAcnt/",
|
||||
request
|
||||
);
|
||||
|
||||
this.logger.log(`Successfully cancelled SIM account for account ${account}`, {
|
||||
account,
|
||||
runDate,
|
||||
});
|
||||
await this.rateLimiter.stampOperation(account, "cancellation");
|
||||
} catch (error) {
|
||||
const message = extractErrorMessage(error);
|
||||
this.logger.error(`Failed to cancel SIM account for account ${account}`, {
|
||||
|
||||
@ -39,82 +39,81 @@ export class FreebitPlanService {
|
||||
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||
try {
|
||||
await this.rateLimiter.assertOperationSpacing(account, "plan");
|
||||
return await this.rateLimiter.executeWithSpacing(account, "plan", async () => {
|
||||
// First, get current SIM details to log for debugging
|
||||
let currentPlanCode: string | undefined;
|
||||
try {
|
||||
const simDetails = await this.accountService.getSimDetails(account);
|
||||
currentPlanCode = simDetails.planCode;
|
||||
this.logger.log(`Current SIM plan details before change`, {
|
||||
account,
|
||||
currentPlanCode: simDetails.planCode,
|
||||
status: simDetails.status,
|
||||
simType: simDetails.simType,
|
||||
});
|
||||
} catch (detailsError) {
|
||||
this.logger.warn(`Could not fetch current SIM details`, {
|
||||
account,
|
||||
error: extractErrorMessage(detailsError),
|
||||
});
|
||||
}
|
||||
|
||||
// First, get current SIM details to log for debugging
|
||||
let currentPlanCode: string | undefined;
|
||||
try {
|
||||
const simDetails = await this.accountService.getSimDetails(account);
|
||||
currentPlanCode = simDetails.planCode;
|
||||
this.logger.log(`Current SIM plan details before change`, {
|
||||
// PA05-21 requires runTime parameter in YYYYMMDD format (8 digits, date only)
|
||||
// If not provided, default to 1st of next month
|
||||
let runTime = options.scheduledAt || undefined;
|
||||
if (!runTime) {
|
||||
const nextMonth = new Date();
|
||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||
nextMonth.setDate(1);
|
||||
const year = nextMonth.getFullYear();
|
||||
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||
const day = "01";
|
||||
runTime = `${year}${month}${day}`;
|
||||
this.logger.log(`No scheduledAt provided, defaulting to 1st of next month: ${runTime}`, {
|
||||
account,
|
||||
runTime,
|
||||
});
|
||||
}
|
||||
|
||||
const request: Omit<FreebitPlanChangeRequest, "authKey"> = {
|
||||
account,
|
||||
currentPlanCode: simDetails.planCode,
|
||||
status: simDetails.status,
|
||||
simType: simDetails.simType,
|
||||
});
|
||||
} catch (detailsError) {
|
||||
this.logger.warn(`Could not fetch current SIM details`, {
|
||||
planCode: newPlanCode,
|
||||
runTime: runTime,
|
||||
...(options.assignGlobalIp === true ? { globalip: "1" } : {}),
|
||||
};
|
||||
|
||||
this.logger.log(`Attempting to change SIM plan via PA05-21`, {
|
||||
account,
|
||||
error: extractErrorMessage(detailsError),
|
||||
currentPlanCode,
|
||||
newPlanCode,
|
||||
planCode: newPlanCode,
|
||||
globalip: request.globalip,
|
||||
runTime: request.runTime,
|
||||
scheduledAt: options.scheduledAt,
|
||||
});
|
||||
}
|
||||
|
||||
// PA05-21 requires runTime parameter in YYYYMMDD format (8 digits, date only)
|
||||
// If not provided, default to 1st of next month
|
||||
let runTime = options.scheduledAt || undefined;
|
||||
if (!runTime) {
|
||||
const nextMonth = new Date();
|
||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||
nextMonth.setDate(1);
|
||||
const year = nextMonth.getFullYear();
|
||||
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||
const day = "01";
|
||||
runTime = `${year}${month}${day}`;
|
||||
this.logger.log(`No scheduledAt provided, defaulting to 1st of next month: ${runTime}`, {
|
||||
const response = await this.client.makeAuthenticatedRequest<
|
||||
FreebitPlanChangeResponse,
|
||||
typeof request
|
||||
>("/mvno/changePlan/", request);
|
||||
|
||||
this.logger.log(`Successfully changed plan for account ${account} to ${newPlanCode}`, {
|
||||
account,
|
||||
runTime,
|
||||
newPlanCode,
|
||||
assignGlobalIp: options.assignGlobalIp,
|
||||
scheduled: !!options.scheduledAt,
|
||||
response: {
|
||||
resultCode: response.resultCode,
|
||||
statusCode: response.status?.statusCode,
|
||||
message: response.status?.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const request: Omit<FreebitPlanChangeRequest, "authKey"> = {
|
||||
account,
|
||||
planCode: newPlanCode,
|
||||
runTime: runTime,
|
||||
...(options.assignGlobalIp === true ? { globalip: "1" } : {}),
|
||||
};
|
||||
|
||||
this.logger.log(`Attempting to change SIM plan via PA05-21`, {
|
||||
account,
|
||||
currentPlanCode,
|
||||
newPlanCode,
|
||||
planCode: newPlanCode,
|
||||
globalip: request.globalip,
|
||||
runTime: request.runTime,
|
||||
scheduledAt: options.scheduledAt,
|
||||
return {
|
||||
ipv4: response.ipv4,
|
||||
ipv6: response.ipv6,
|
||||
};
|
||||
});
|
||||
|
||||
const response = await this.client.makeAuthenticatedRequest<
|
||||
FreebitPlanChangeResponse,
|
||||
typeof request
|
||||
>("/mvno/changePlan/", request);
|
||||
|
||||
this.logger.log(`Successfully changed plan for account ${account} to ${newPlanCode}`, {
|
||||
account,
|
||||
newPlanCode,
|
||||
assignGlobalIp: options.assignGlobalIp,
|
||||
scheduled: !!options.scheduledAt,
|
||||
response: {
|
||||
resultCode: response.resultCode,
|
||||
statusCode: response.status?.statusCode,
|
||||
message: response.status?.message,
|
||||
},
|
||||
});
|
||||
await this.rateLimiter.stampOperation(account, "plan");
|
||||
|
||||
return {
|
||||
ipv4: response.ipv4,
|
||||
ipv6: response.ipv6,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = extractErrorMessage(error);
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import type { Redis } from "ioredis";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export type OperationType = "voice" | "network" | "plan" | "cancellation";
|
||||
|
||||
@ -29,6 +30,7 @@ export class FreebitRateLimiterService {
|
||||
private readonly cleanupIntervalMs = 5 * 60 * 1000; // Run cleanup at most every 5 minutes
|
||||
private readonly staleThresholdMs = 35 * 60 * 1000; // Remove entries older than 35 minutes
|
||||
private readonly windowMs = 30 * 60 * 1000; // 30 minute window between operations
|
||||
private readonly lockTtlMs = Math.min(this.windowMs, 10 * 60 * 1000);
|
||||
|
||||
constructor(
|
||||
@Inject("REDIS_CLIENT") private readonly redis: Redis,
|
||||
@ -93,6 +95,59 @@ export class FreebitRateLimiterService {
|
||||
return `freebit:ops:${account}`;
|
||||
}
|
||||
|
||||
private buildLockKey(account: string): string {
|
||||
return `freebit:ops:lock:${account}`;
|
||||
}
|
||||
|
||||
private async acquireLock(account: string, token: string): Promise<boolean> {
|
||||
const key = this.buildLockKey(account);
|
||||
const result = await this.redis.set(key, token, "PX", this.lockTtlMs, "NX");
|
||||
return result === "OK";
|
||||
}
|
||||
|
||||
private async releaseLock(account: string, token: string): Promise<void> {
|
||||
const key = this.buildLockKey(account);
|
||||
const script =
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
|
||||
try {
|
||||
await this.redis.eval(script, 1, key, token);
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to release Freebit operation lock", {
|
||||
account,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Freebit operation with spacing checks and a distributed lock.
|
||||
* Ensures no concurrent operations violate the 30-minute spacing rule.
|
||||
*/
|
||||
async executeWithSpacing<T>(
|
||||
account: string,
|
||||
op: OperationType,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const token = randomUUID();
|
||||
|
||||
try {
|
||||
const acquired = await this.acquireLock(account, token);
|
||||
if (!acquired) {
|
||||
throw new BadRequestException(
|
||||
"Another SIM operation is in progress. Please try again shortly."
|
||||
);
|
||||
}
|
||||
|
||||
await this.assertOperationSpacing(account, op);
|
||||
|
||||
const result = await operation();
|
||||
await this.stampOperation(account, op);
|
||||
return result;
|
||||
} finally {
|
||||
await this.releaseLock(account, token);
|
||||
}
|
||||
}
|
||||
|
||||
private async getOperationWindow(account: string): Promise<OperationTimestamps> {
|
||||
const key = this.buildKey(account);
|
||||
try {
|
||||
|
||||
@ -120,72 +120,71 @@ export class FreebitVoiceService {
|
||||
*/
|
||||
async updateVoiceFeatures(account: string, features: VoiceFeatures): Promise<void> {
|
||||
try {
|
||||
await this.rateLimiter.assertOperationSpacing(account, "voice");
|
||||
await this.rateLimiter.executeWithSpacing(account, "voice", async () => {
|
||||
const buildVoiceOptionPayload = (): Omit<FreebitVoiceOptionRequest, "authKey"> => {
|
||||
const talkOption: FreebitVoiceOptionSettings = {};
|
||||
|
||||
const buildVoiceOptionPayload = (): Omit<FreebitVoiceOptionRequest, "authKey"> => {
|
||||
const talkOption: FreebitVoiceOptionSettings = {};
|
||||
if (typeof features.voiceMailEnabled === "boolean") {
|
||||
talkOption.voiceMail = features.voiceMailEnabled ? "10" : "20";
|
||||
}
|
||||
|
||||
if (typeof features.voiceMailEnabled === "boolean") {
|
||||
talkOption.voiceMail = features.voiceMailEnabled ? "10" : "20";
|
||||
}
|
||||
if (typeof features.callWaitingEnabled === "boolean") {
|
||||
talkOption.callWaiting = features.callWaitingEnabled ? "10" : "20";
|
||||
}
|
||||
|
||||
if (typeof features.callWaitingEnabled === "boolean") {
|
||||
talkOption.callWaiting = features.callWaitingEnabled ? "10" : "20";
|
||||
}
|
||||
if (typeof features.internationalRoamingEnabled === "boolean") {
|
||||
talkOption.worldWing = features.internationalRoamingEnabled ? "10" : "20";
|
||||
if (features.internationalRoamingEnabled) {
|
||||
talkOption.worldWingCreditLimit = "50000";
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof features.internationalRoamingEnabled === "boolean") {
|
||||
talkOption.worldWing = features.internationalRoamingEnabled ? "10" : "20";
|
||||
if (features.internationalRoamingEnabled) {
|
||||
talkOption.worldWingCreditLimit = "50000";
|
||||
if (Object.keys(talkOption).length === 0) {
|
||||
throw new BadRequestException("No voice options specified for update");
|
||||
}
|
||||
|
||||
return {
|
||||
account,
|
||||
userConfirmed: "10",
|
||||
aladinOperated: "10",
|
||||
talkOption,
|
||||
};
|
||||
};
|
||||
|
||||
const voiceOptionPayload = buildVoiceOptionPayload();
|
||||
|
||||
this.logger.debug(
|
||||
"Submitting voice option change via /mvno/talkoption/changeOrder/ (PA05-06)",
|
||||
{
|
||||
account,
|
||||
payload: voiceOptionPayload,
|
||||
}
|
||||
);
|
||||
|
||||
await this.client.makeAuthenticatedRequest<
|
||||
FreebitVoiceOptionResponse,
|
||||
typeof voiceOptionPayload
|
||||
>("/mvno/talkoption/changeOrder/", voiceOptionPayload);
|
||||
|
||||
this.logger.log("Voice option change completed via PA05-06", {
|
||||
account,
|
||||
voiceMailEnabled: features.voiceMailEnabled,
|
||||
callWaitingEnabled: features.callWaitingEnabled,
|
||||
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
||||
});
|
||||
|
||||
// Save to database for future retrieval
|
||||
if (this.voiceOptionsService) {
|
||||
try {
|
||||
await this.voiceOptionsService.saveVoiceOptions(account, features);
|
||||
} catch (dbError) {
|
||||
this.logger.warn("Failed to save voice options to database (non-fatal)", {
|
||||
account,
|
||||
error: extractErrorMessage(dbError),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(talkOption).length === 0) {
|
||||
throw new BadRequestException("No voice options specified for update");
|
||||
}
|
||||
|
||||
return {
|
||||
account,
|
||||
userConfirmed: "10",
|
||||
aladinOperated: "10",
|
||||
talkOption,
|
||||
};
|
||||
};
|
||||
|
||||
const voiceOptionPayload = buildVoiceOptionPayload();
|
||||
|
||||
this.logger.debug(
|
||||
"Submitting voice option change via /mvno/talkoption/changeOrder/ (PA05-06)",
|
||||
{
|
||||
account,
|
||||
payload: voiceOptionPayload,
|
||||
}
|
||||
);
|
||||
|
||||
await this.client.makeAuthenticatedRequest<
|
||||
FreebitVoiceOptionResponse,
|
||||
typeof voiceOptionPayload
|
||||
>("/mvno/talkoption/changeOrder/", voiceOptionPayload);
|
||||
|
||||
this.logger.log("Voice option change completed via PA05-06", {
|
||||
account,
|
||||
voiceMailEnabled: features.voiceMailEnabled,
|
||||
callWaitingEnabled: features.callWaitingEnabled,
|
||||
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
||||
});
|
||||
await this.rateLimiter.stampOperation(account, "voice");
|
||||
|
||||
// Save to database for future retrieval
|
||||
if (this.voiceOptionsService) {
|
||||
try {
|
||||
await this.voiceOptionsService.saveVoiceOptions(account, features);
|
||||
} catch (dbError) {
|
||||
this.logger.warn("Failed to save voice options to database (non-fatal)", {
|
||||
account,
|
||||
error: extractErrorMessage(dbError),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = extractErrorMessage(error);
|
||||
this.logger.error(`Failed to update voice features for account ${account}`, {
|
||||
@ -204,10 +203,10 @@ export class FreebitVoiceService {
|
||||
* NOTE: Must be called 30 minutes after PA05-06 if both are being updated
|
||||
*/
|
||||
async updateNetworkType(account: string, networkType: "4G" | "5G"): Promise<void> {
|
||||
let eid: string | undefined;
|
||||
let productNumber: string | undefined;
|
||||
|
||||
try {
|
||||
await this.rateLimiter.assertOperationSpacing(account, "network");
|
||||
let eid: string | undefined;
|
||||
let productNumber: string | undefined;
|
||||
try {
|
||||
const details = await this.accountService.getSimDetails(account);
|
||||
if (details.eid) {
|
||||
@ -238,44 +237,45 @@ export class FreebitVoiceService {
|
||||
});
|
||||
}
|
||||
|
||||
const request: Omit<FreebitContractLineChangeRequest, "authKey"> = {
|
||||
account,
|
||||
contractLine: networkType,
|
||||
...(eid ? { eid } : {}),
|
||||
...(productNumber ? { productNumber } : {}),
|
||||
};
|
||||
await this.rateLimiter.executeWithSpacing(account, "network", async () => {
|
||||
const request: Omit<FreebitContractLineChangeRequest, "authKey"> = {
|
||||
account,
|
||||
contractLine: networkType,
|
||||
...(eid ? { eid } : {}),
|
||||
...(productNumber ? { productNumber } : {}),
|
||||
};
|
||||
|
||||
this.logger.debug(`Updating network type via PA05-38 for account ${account}`, {
|
||||
account,
|
||||
networkType,
|
||||
request,
|
||||
});
|
||||
this.logger.debug(`Updating network type via PA05-38 for account ${account}`, {
|
||||
account,
|
||||
networkType,
|
||||
request,
|
||||
});
|
||||
|
||||
const response = await this.client.makeAuthenticatedJsonRequest<
|
||||
FreebitContractLineChangeResponse,
|
||||
typeof request
|
||||
>("/mvno/contractline/change/", request);
|
||||
const response = await this.client.makeAuthenticatedJsonRequest<
|
||||
FreebitContractLineChangeResponse,
|
||||
typeof request
|
||||
>("/mvno/contractline/change/", request);
|
||||
|
||||
this.logger.log(`Successfully updated network type for account ${account}`, {
|
||||
account,
|
||||
networkType,
|
||||
resultCode: response.resultCode,
|
||||
statusCode: response.status?.statusCode,
|
||||
message: response.status?.message,
|
||||
});
|
||||
await this.rateLimiter.stampOperation(account, "network");
|
||||
this.logger.log(`Successfully updated network type for account ${account}`, {
|
||||
account,
|
||||
networkType,
|
||||
resultCode: response.resultCode,
|
||||
statusCode: response.status?.statusCode,
|
||||
message: response.status?.message,
|
||||
});
|
||||
|
||||
// Save to database for future retrieval
|
||||
if (this.voiceOptionsService) {
|
||||
try {
|
||||
await this.voiceOptionsService.saveVoiceOptions(account, { networkType });
|
||||
} catch (dbError) {
|
||||
this.logger.warn("Failed to save network type to database (non-fatal)", {
|
||||
account,
|
||||
error: extractErrorMessage(dbError),
|
||||
});
|
||||
// Save to database for future retrieval
|
||||
if (this.voiceOptionsService) {
|
||||
try {
|
||||
await this.voiceOptionsService.saveVoiceOptions(account, { networkType });
|
||||
} catch (dbError) {
|
||||
this.logger.warn("Failed to save network type to database (non-fatal)", {
|
||||
account,
|
||||
error: extractErrorMessage(dbError),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const message = extractErrorMessage(error);
|
||||
this.logger.error(`Failed to update network type for account ${account}`, {
|
||||
|
||||
@ -10,8 +10,11 @@ import type {
|
||||
WhmcsAddClientResponse,
|
||||
WhmcsValidateLoginResponse,
|
||||
} from "@customer-portal/domain/customer/providers";
|
||||
import { transformWhmcsClientResponse } from "@customer-portal/domain/customer/providers";
|
||||
import type { WhmcsClient } from "@customer-portal/domain/customer";
|
||||
import {
|
||||
prepareWhmcsClientAddressUpdate,
|
||||
transformWhmcsClientResponse,
|
||||
} from "@customer-portal/domain/customer/providers";
|
||||
import { addressSchema, type Address, type WhmcsClient } from "@customer-portal/domain/customer";
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsClientService {
|
||||
@ -134,4 +137,24 @@ export class WhmcsClientService {
|
||||
await this.cacheService.invalidateUserCache(userId);
|
||||
this.logger.log(`Invalidated cache for user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper for address get on WHMCS client.
|
||||
* Keeps address parsing close to the integration boundary.
|
||||
*/
|
||||
async getClientAddress(clientId: number): Promise<Address> {
|
||||
const customer = await this.getClientDetails(clientId);
|
||||
return addressSchema.parse(customer.address ?? {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper for address update on WHMCS client.
|
||||
* Parses input + prepares WHMCS update payload, then updates the client.
|
||||
*/
|
||||
async updateClientAddress(clientId: number, address: Partial<Address>): Promise<void> {
|
||||
const parsed = addressSchema.partial().parse(address ?? {});
|
||||
const updateData = prepareWhmcsClientAddressUpdate(parsed);
|
||||
if (Object.keys(updateData).length === 0) return;
|
||||
await this.updateClient(clientId, updateData);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
],
|
||||
|
||||
@ -1,340 +0,0 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import type { Invoice, InvoiceList } from "@customer-portal/domain/billing";
|
||||
import type { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
|
||||
import type { PaymentMethodList, PaymentGatewayList } from "@customer-portal/domain/payments";
|
||||
import { addressSchema, type Address, type WhmcsClient } from "@customer-portal/domain/customer";
|
||||
import { prepareWhmcsClientAddressUpdate } from "@customer-portal/domain/customer/providers";
|
||||
import { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service.js";
|
||||
import { WhmcsInvoiceService } from "./services/whmcs-invoice.service.js";
|
||||
import type { InvoiceFilters } from "./services/whmcs-invoice.service.js";
|
||||
import { WhmcsSubscriptionService } from "./services/whmcs-subscription.service.js";
|
||||
import type { SubscriptionFilters } from "./services/whmcs-subscription.service.js";
|
||||
import { WhmcsClientService } from "./services/whmcs-client.service.js";
|
||||
import { WhmcsPaymentService } from "./services/whmcs-payment.service.js";
|
||||
import { WhmcsSsoService } from "./services/whmcs-sso.service.js";
|
||||
import { WhmcsOrderService } from "./services/whmcs-order.service.js";
|
||||
import type {
|
||||
WhmcsAddClientParams,
|
||||
WhmcsClientResponse,
|
||||
} from "@customer-portal/domain/customer/providers";
|
||||
import type {
|
||||
WhmcsGetClientsProductsParams,
|
||||
WhmcsProductListResponse,
|
||||
} from "@customer-portal/domain/subscriptions/providers";
|
||||
import type { WhmcsCatalogProductNormalized } from "@customer-portal/domain/services/providers";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsService {
|
||||
constructor(
|
||||
private readonly connectionService: WhmcsConnectionOrchestratorService,
|
||||
private readonly invoiceService: WhmcsInvoiceService,
|
||||
private readonly subscriptionService: WhmcsSubscriptionService,
|
||||
private readonly clientService: WhmcsClientService,
|
||||
private readonly paymentService: WhmcsPaymentService,
|
||||
private readonly ssoService: WhmcsSsoService,
|
||||
private readonly orderService: WhmcsOrderService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
// ==========================================
|
||||
// INVOICE OPERATIONS (delegate to InvoiceService)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Get paginated invoices for a client with caching
|
||||
*/
|
||||
async getInvoices(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
filters: InvoiceFilters = {}
|
||||
): Promise<InvoiceList> {
|
||||
return this.invoiceService.getInvoices(clientId, userId, filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoices with items (for subscription linking)
|
||||
*/
|
||||
async getInvoicesWithItems(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
filters: InvoiceFilters = {}
|
||||
): Promise<InvoiceList> {
|
||||
return this.invoiceService.getInvoicesWithItems(clientId, userId, filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get individual invoice by ID with caching
|
||||
*/
|
||||
async getInvoiceById(clientId: number, userId: string, invoiceId: number): Promise<Invoice> {
|
||||
return this.invoiceService.getInvoiceById(clientId, userId, invoiceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a specific invoice
|
||||
*/
|
||||
async invalidateInvoiceCache(userId: string, invoiceId: number): Promise<void> {
|
||||
return this.invoiceService.invalidateInvoiceCache(userId, invoiceId);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SUBSCRIPTION OPERATIONS (delegate to SubscriptionService)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Get client subscriptions/services with caching
|
||||
*/
|
||||
async getSubscriptions(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
filters: SubscriptionFilters = {}
|
||||
): Promise<SubscriptionList> {
|
||||
return this.subscriptionService.getSubscriptions(clientId, userId, filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get individual subscription by ID
|
||||
*/
|
||||
async getSubscriptionById(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
subscriptionId: number
|
||||
): Promise<Subscription> {
|
||||
return this.subscriptionService.getSubscriptionById(clientId, userId, subscriptionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a specific subscription
|
||||
*/
|
||||
async invalidateSubscriptionCache(userId: string, subscriptionId: number): Promise<void> {
|
||||
return this.subscriptionService.invalidateSubscriptionCache(userId, subscriptionId);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// CLIENT OPERATIONS (delegate to ClientService)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Validate client login credentials
|
||||
*/
|
||||
async validateLogin(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ userId: number; passwordHash: string }> {
|
||||
return this.clientService.validateLogin(email, password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client details by ID
|
||||
* Returns internal WhmcsClient (type inferred)
|
||||
*/
|
||||
async getClientDetails(clientId: number): Promise<WhmcsClient> {
|
||||
return this.clientService.getClientDetails(clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update client details in WHMCS
|
||||
*/
|
||||
async updateClient(
|
||||
clientId: number,
|
||||
updateData: Partial<WhmcsClientResponse["client"]>
|
||||
): Promise<void> {
|
||||
return this.clientService.updateClient(clientId, updateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helpers for address get/update on WHMCS client
|
||||
*/
|
||||
async getClientAddress(clientId: number): Promise<Address> {
|
||||
const customer = await this.clientService.getClientDetails(clientId);
|
||||
return addressSchema.parse(customer.address ?? {});
|
||||
}
|
||||
|
||||
async updateClientAddress(clientId: number, address: Partial<Address>): Promise<void> {
|
||||
const parsed = addressSchema.partial().parse(address ?? {});
|
||||
const updateData = prepareWhmcsClientAddressUpdate(parsed);
|
||||
if (Object.keys(updateData).length === 0) return;
|
||||
await this.clientService.updateClient(clientId, updateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new client
|
||||
*/
|
||||
async addClient(clientData: WhmcsAddClientParams): Promise<{ clientId: number }> {
|
||||
return this.clientService.addClient(clientData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a user
|
||||
*/
|
||||
async invalidateUserCache(userId: string): Promise<void> {
|
||||
return this.clientService.invalidateUserCache(userId);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PAYMENT OPERATIONS (delegate to PaymentService)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Get payment methods for a client
|
||||
*/
|
||||
async getPaymentMethods(clientId: number, userId: string): Promise<PaymentMethodList> {
|
||||
return this.paymentService.getPaymentMethods(clientId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available payment gateways
|
||||
*/
|
||||
async getPaymentGateways(): Promise<PaymentGatewayList> {
|
||||
return this.paymentService.getPaymentGateways();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate payment methods cache for a user
|
||||
*/
|
||||
async invalidatePaymentMethodsCache(userId: string): Promise<void> {
|
||||
return this.paymentService.invalidatePaymentMethodsCache(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SSO token with payment method for invoice payment
|
||||
*/
|
||||
async createPaymentSsoToken(
|
||||
clientId: number,
|
||||
invoiceId: number,
|
||||
paymentMethodId?: number,
|
||||
gatewayName?: string
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
return this.paymentService.createPaymentSsoToken(
|
||||
clientId,
|
||||
invoiceId,
|
||||
paymentMethodId,
|
||||
gatewayName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products catalog
|
||||
*/
|
||||
async getProducts(): Promise<WhmcsCatalogProductNormalized[]> {
|
||||
return this.paymentService.getProducts();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SSO OPERATIONS (delegate to SsoService)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Create SSO token for WHMCS access
|
||||
*/
|
||||
async createSsoToken(
|
||||
clientId: number,
|
||||
destination?: string,
|
||||
ssoRedirectPath?: string
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
return this.ssoService.createSsoToken(clientId, destination, ssoRedirectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create SSO links for invoices
|
||||
*/
|
||||
async whmcsSsoForInvoice(
|
||||
clientId: number,
|
||||
invoiceId: number,
|
||||
target: "view" | "download" | "pay"
|
||||
): Promise<string> {
|
||||
return this.ssoService.whmcsSsoForInvoice(clientId, invoiceId, target);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// CONNECTION & HEALTH (delegate to ConnectionService)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Health check for WHMCS API
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
return this.connectionService.healthCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WHMCS service is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return this.connectionService.isAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WHMCS system information
|
||||
*/
|
||||
async getSystemInfo(): Promise<unknown> {
|
||||
return this.connectionService.getSystemInfo();
|
||||
}
|
||||
|
||||
async getClientsProducts(
|
||||
params: WhmcsGetClientsProductsParams
|
||||
): Promise<WhmcsProductListResponse> {
|
||||
return this.connectionService.getClientsProducts(params);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ORDER OPERATIONS (delegate to OrderService)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Get order service for direct access to order operations
|
||||
* Used by OrderProvisioningService for complex order workflows
|
||||
*/
|
||||
getOrderService(): WhmcsOrderService {
|
||||
return this.orderService;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// INVOICE CREATION AND PAYMENT OPERATIONS (Used by SIM/Order services)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Create a new invoice for a client
|
||||
*/
|
||||
async createInvoice(params: {
|
||||
clientId: number;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
dueDate?: Date;
|
||||
notes?: string;
|
||||
}): Promise<{ id: number; number: string; total: number; status: string }> {
|
||||
return this.invoiceService.createInvoice(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing invoice
|
||||
*/
|
||||
async updateInvoice(params: {
|
||||
invoiceId: number;
|
||||
status?:
|
||||
| "Draft"
|
||||
| "Unpaid"
|
||||
| "Paid"
|
||||
| "Cancelled"
|
||||
| "Refunded"
|
||||
| "Collections"
|
||||
| "Payment Pending";
|
||||
dueDate?: Date;
|
||||
notes?: string;
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
return this.invoiceService.updateInvoice(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture payment for an invoice
|
||||
*/
|
||||
async capturePayment(params: {
|
||||
invoiceId: number;
|
||||
amount: number;
|
||||
currency?: string;
|
||||
userId?: string;
|
||||
}): Promise<{ success: boolean; transactionId?: string; error?: string }> {
|
||||
return this.invoiceService.capturePayment(params);
|
||||
}
|
||||
}
|
||||
@ -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) });
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -10,8 +10,8 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
|
||||
import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/whmcs-account-discovery.service.js";
|
||||
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
|
||||
@ -47,7 +47,7 @@ export interface WhmcsCreatedClient {
|
||||
@Injectable()
|
||||
export class SignupWhmcsService {
|
||||
constructor(
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly whmcsClientService: WhmcsClientService,
|
||||
private readonly discoveryService: WhmcsAccountDiscoveryService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
private readonly configService: ConfigService,
|
||||
@ -98,7 +98,7 @@ export class SignupWhmcsService {
|
||||
});
|
||||
|
||||
try {
|
||||
const whmcsClient = await this.whmcsService.addClient({
|
||||
const whmcsClient = await this.whmcsClientService.addClient({
|
||||
firstname: params.firstName,
|
||||
lastname: params.lastName,
|
||||
email: params.email,
|
||||
@ -139,7 +139,7 @@ export class SignupWhmcsService {
|
||||
*/
|
||||
async markClientForCleanup(clientId: number, email: string): Promise<void> {
|
||||
try {
|
||||
await this.whmcsService.updateClient(clientId, {
|
||||
await this.whmcsClientService.updateClient(clientId, {
|
||||
status: "Inactive",
|
||||
});
|
||||
|
||||
|
||||
@ -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.");
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Controller, Get, Post, Param, Query, Request, HttpCode, HttpStatus } from "@nestjs/common";
|
||||
import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.js";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
|
||||
import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-payment.service.js";
|
||||
import { WhmcsSsoService } from "@bff/integrations/whmcs/services/whmcs-sso.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
||||
@ -47,7 +48,8 @@ class InvoicePaymentLinkDto extends createZodDto(invoicePaymentLinkSchema) {}
|
||||
export class BillingController {
|
||||
constructor(
|
||||
private readonly invoicesService: InvoiceRetrievalService,
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly whmcsPaymentService: WhmcsPaymentService,
|
||||
private readonly whmcsSsoService: WhmcsSsoService,
|
||||
private readonly mappingsService: MappingsService
|
||||
) {}
|
||||
|
||||
@ -64,13 +66,13 @@ export class BillingController {
|
||||
@ZodResponse({ description: "List payment methods", type: PaymentMethodListDto })
|
||||
async getPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
|
||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
|
||||
return this.whmcsService.getPaymentMethods(whmcsClientId, req.user.id);
|
||||
return this.whmcsPaymentService.getPaymentMethods(whmcsClientId, req.user.id);
|
||||
}
|
||||
|
||||
@Get("payment-gateways")
|
||||
@ZodResponse({ description: "List payment gateways", type: PaymentGatewayListDto })
|
||||
async getPaymentGateways(): Promise<PaymentGatewayList> {
|
||||
return this.whmcsService.getPaymentGateways();
|
||||
return this.whmcsPaymentService.getPaymentGateways();
|
||||
}
|
||||
|
||||
@Post("payment-methods/refresh")
|
||||
@ -78,11 +80,11 @@ export class BillingController {
|
||||
@ZodResponse({ description: "Refresh payment methods", type: PaymentMethodListDto })
|
||||
async refreshPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
|
||||
// Invalidate cache first
|
||||
await this.whmcsService.invalidatePaymentMethodsCache(req.user.id);
|
||||
await this.whmcsPaymentService.invalidatePaymentMethodsCache(req.user.id);
|
||||
|
||||
// Return fresh payment methods
|
||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
|
||||
return this.whmcsService.getPaymentMethods(whmcsClientId, req.user.id);
|
||||
return this.whmcsPaymentService.getPaymentMethods(whmcsClientId, req.user.id);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ -104,7 +106,7 @@ export class BillingController {
|
||||
): Promise<InvoiceSsoLink> {
|
||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
|
||||
|
||||
const ssoUrl = await this.whmcsService.whmcsSsoForInvoice(
|
||||
const ssoUrl = await this.whmcsSsoService.whmcsSsoForInvoice(
|
||||
whmcsClientId,
|
||||
params.id,
|
||||
query.target
|
||||
@ -126,7 +128,7 @@ export class BillingController {
|
||||
): Promise<InvoicePaymentLink> {
|
||||
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(req.user.id);
|
||||
|
||||
const ssoResult = await this.whmcsService.createPaymentSsoToken(
|
||||
const ssoResult = await this.whmcsPaymentService.createPaymentSsoToken(
|
||||
whmcsClientId,
|
||||
params.id,
|
||||
query.paymentMethodId,
|
||||
|
||||
@ -6,7 +6,8 @@ import type {
|
||||
InvoiceListQuery,
|
||||
InvoiceStatus,
|
||||
} from "@customer-portal/domain/billing";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
|
||||
import { INVOICE_PAGINATION } from "@customer-portal/domain/billing";
|
||||
import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
|
||||
import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js";
|
||||
@ -20,7 +21,7 @@ import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js";
|
||||
@Injectable()
|
||||
export class InvoiceRetrievalService {
|
||||
constructor(
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly whmcsInvoiceService: WhmcsInvoiceService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
@ -29,7 +30,11 @@ export class InvoiceRetrievalService {
|
||||
* Get paginated invoices for a user
|
||||
*/
|
||||
async getInvoices(userId: string, options: InvoiceListQuery = {}): Promise<InvoiceList> {
|
||||
const { page = 1, limit = 10, status } = options;
|
||||
const {
|
||||
page = INVOICE_PAGINATION.DEFAULT_PAGE,
|
||||
limit = INVOICE_PAGINATION.DEFAULT_LIMIT,
|
||||
status,
|
||||
} = options;
|
||||
|
||||
// Validate userId first
|
||||
parseUuidOrThrow(userId, "Invalid user ID format");
|
||||
@ -37,7 +42,7 @@ export class InvoiceRetrievalService {
|
||||
|
||||
return withErrorHandling(
|
||||
async () => {
|
||||
const invoiceList = await this.whmcsService.getInvoices(whmcsClientId, userId, {
|
||||
const invoiceList = await this.whmcsInvoiceService.getInvoices(whmcsClientId, userId, {
|
||||
page,
|
||||
limit,
|
||||
status,
|
||||
@ -66,7 +71,11 @@ export class InvoiceRetrievalService {
|
||||
|
||||
return withErrorHandling(
|
||||
async () => {
|
||||
const invoice = await this.whmcsService.getInvoiceById(whmcsClientId, userId, invoiceId);
|
||||
const invoice = await this.whmcsInvoiceService.getInvoiceById(
|
||||
whmcsClientId,
|
||||
userId,
|
||||
invoiceId
|
||||
);
|
||||
this.logger.log(`Retrieved invoice ${invoiceId} for user ${userId}`);
|
||||
return invoice;
|
||||
},
|
||||
@ -86,7 +95,8 @@ export class InvoiceRetrievalService {
|
||||
status: InvoiceStatus,
|
||||
options: Partial<InvoiceListQuery> = {}
|
||||
): Promise<InvoiceList> {
|
||||
const { page = 1, limit = 10 } = options;
|
||||
const { page = INVOICE_PAGINATION.DEFAULT_PAGE, limit = INVOICE_PAGINATION.DEFAULT_LIMIT } =
|
||||
options;
|
||||
const queryStatus = status as "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
|
||||
|
||||
return withErrorHandling(
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 || "";
|
||||
|
||||
@ -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 || "";
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Inject, Injectable } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
|
||||
import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js";
|
||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
|
||||
export interface SimChargeInvoiceResult {
|
||||
@ -24,7 +24,7 @@ interface OneTimeChargeParams {
|
||||
@Injectable()
|
||||
export class SimBillingService {
|
||||
constructor(
|
||||
private readonly whmcs: WhmcsService,
|
||||
private readonly whmcsInvoiceService: WhmcsInvoiceService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@ -42,7 +42,7 @@ export class SimBillingService {
|
||||
metadata = {},
|
||||
} = params;
|
||||
|
||||
const invoice = await this.whmcs.createInvoice({
|
||||
const invoice = await this.whmcsInvoiceService.createInvoice({
|
||||
clientId,
|
||||
description,
|
||||
amount: amountJpy,
|
||||
@ -51,7 +51,7 @@ export class SimBillingService {
|
||||
notes,
|
||||
});
|
||||
|
||||
const paymentResult = await this.whmcs.capturePayment({
|
||||
const paymentResult = await this.whmcsInvoiceService.capturePayment({
|
||||
invoiceId: invoice.id,
|
||||
amount: amountJpy,
|
||||
currency,
|
||||
@ -90,7 +90,7 @@ export class SimBillingService {
|
||||
}
|
||||
|
||||
async appendInvoiceNote(invoiceId: number, note: string): Promise<void> {
|
||||
await this.whmcs.updateInvoice({
|
||||
await this.whmcsInvoiceService.updateInvoice({
|
||||
invoiceId,
|
||||
notes: note,
|
||||
});
|
||||
@ -98,7 +98,7 @@ export class SimBillingService {
|
||||
|
||||
private async cancelInvoice(invoiceId: number, reason: string): Promise<void> {
|
||||
try {
|
||||
await this.whmcs.updateInvoice({
|
||||
await this.whmcsInvoiceService.updateInvoice({
|
||||
invoiceId,
|
||||
status: "Cancelled",
|
||||
notes: reason,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 || "";
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -11,8 +11,11 @@ import type {
|
||||
SubscriptionStatus,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
|
||||
import { WhmcsCacheService } from "@bff/integrations/whmcs/cache/whmcs-cache.service.js";
|
||||
import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.js";
|
||||
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
||||
import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js";
|
||||
import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
|
||||
@ -27,7 +30,10 @@ export interface GetSubscriptionsOptions {
|
||||
@Injectable()
|
||||
export class SubscriptionsService {
|
||||
constructor(
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly whmcsSubscriptionService: WhmcsSubscriptionService,
|
||||
private readonly whmcsInvoiceService: WhmcsInvoiceService,
|
||||
private readonly whmcsClientService: WhmcsClientService,
|
||||
private readonly whmcsConnectionService: WhmcsConnectionOrchestratorService,
|
||||
private readonly cacheService: WhmcsCacheService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
@ -45,9 +51,11 @@ export class SubscriptionsService {
|
||||
|
||||
return withErrorHandling(
|
||||
async () => {
|
||||
const subscriptionList = await this.whmcsService.getSubscriptions(whmcsClientId, userId, {
|
||||
status,
|
||||
});
|
||||
const subscriptionList = await this.whmcsSubscriptionService.getSubscriptions(
|
||||
whmcsClientId,
|
||||
userId,
|
||||
{ status }
|
||||
);
|
||||
|
||||
const parsed = subscriptionListSchema.parse(subscriptionList);
|
||||
|
||||
@ -84,7 +92,7 @@ export class SubscriptionsService {
|
||||
|
||||
return withErrorHandling(
|
||||
async () => {
|
||||
const subscription = await this.whmcsService.getSubscriptionById(
|
||||
const subscription = await this.whmcsSubscriptionService.getSubscriptionById(
|
||||
whmcsClientId,
|
||||
userId,
|
||||
subscriptionId
|
||||
@ -338,10 +346,11 @@ export class SubscriptionsService {
|
||||
let totalPages = 1;
|
||||
|
||||
do {
|
||||
const invoiceBatch = await this.whmcsService.getInvoicesWithItems(whmcsClientId, userId, {
|
||||
page: currentPage,
|
||||
limit: batchSize,
|
||||
});
|
||||
const invoiceBatch = await this.whmcsInvoiceService.getInvoicesWithItems(
|
||||
whmcsClientId,
|
||||
userId,
|
||||
{ page: currentPage, limit: batchSize }
|
||||
);
|
||||
|
||||
totalPages = invoiceBatch.pagination.totalPages;
|
||||
|
||||
@ -414,9 +423,9 @@ export class SubscriptionsService {
|
||||
async invalidateCache(userId: string, subscriptionId?: number): Promise<void> {
|
||||
try {
|
||||
if (subscriptionId) {
|
||||
await this.whmcsService.invalidateSubscriptionCache(userId, subscriptionId);
|
||||
await this.whmcsSubscriptionService.invalidateSubscriptionCache(userId, subscriptionId);
|
||||
} else {
|
||||
await this.whmcsService.invalidateUserCache(userId);
|
||||
await this.whmcsClientService.invalidateUserCache(userId);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
@ -435,7 +444,7 @@ export class SubscriptionsService {
|
||||
*/
|
||||
async healthCheck(): Promise<{ status: string; details: unknown }> {
|
||||
try {
|
||||
const whmcsHealthy = await this.whmcsService.healthCheck();
|
||||
const whmcsHealthy = await this.whmcsConnectionService.healthCheck();
|
||||
|
||||
return {
|
||||
status: whmcsHealthy ? "healthy" : "unhealthy",
|
||||
@ -465,7 +474,7 @@ export class SubscriptionsService {
|
||||
return false;
|
||||
}
|
||||
|
||||
const productsResponse = await this.whmcsService.getClientsProducts({
|
||||
const productsResponse = await this.whmcsConnectionService.getClientsProducts({
|
||||
clientid: mapping.whmcsClientId,
|
||||
});
|
||||
const productContainer = productsResponse.products?.product;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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
|
||||
// ============================================================================
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { INVOICE_PAGINATION } from "./constants.js";
|
||||
|
||||
// ============================================================================
|
||||
// Currency (Domain Model)
|
||||
@ -128,8 +129,19 @@ export const billingSummarySchema = z.object({
|
||||
* Schema for invoice list query parameters
|
||||
*/
|
||||
export const invoiceQueryParamsSchema = z.object({
|
||||
page: z.coerce.number().int().positive().optional(),
|
||||
limit: z.coerce.number().int().positive().max(100).optional(),
|
||||
page: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(INVOICE_PAGINATION.DEFAULT_PAGE)
|
||||
.optional()
|
||||
.default(INVOICE_PAGINATION.DEFAULT_PAGE),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(INVOICE_PAGINATION.MIN_LIMIT)
|
||||
.max(INVOICE_PAGINATION.MAX_LIMIT)
|
||||
.optional()
|
||||
.default(INVOICE_PAGINATION.DEFAULT_LIMIT),
|
||||
status: invoiceStatusSchema.optional(),
|
||||
dateFrom: z.string().datetime().optional(),
|
||||
dateTo: z.string().datetime().optional(),
|
||||
@ -140,8 +152,19 @@ export type InvoiceQueryParams = z.infer<typeof invoiceQueryParamsSchema>;
|
||||
const invoiceListStatusSchema = z.enum(["Paid", "Unpaid", "Cancelled", "Overdue", "Collections"]);
|
||||
|
||||
export const invoiceListQuerySchema = z.object({
|
||||
page: z.coerce.number().int().positive().optional(),
|
||||
limit: z.coerce.number().int().positive().max(100).optional(),
|
||||
page: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(INVOICE_PAGINATION.DEFAULT_PAGE)
|
||||
.optional()
|
||||
.default(INVOICE_PAGINATION.DEFAULT_PAGE),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(INVOICE_PAGINATION.MIN_LIMIT)
|
||||
.max(INVOICE_PAGINATION.MAX_LIMIT)
|
||||
.optional()
|
||||
.default(INVOICE_PAGINATION.DEFAULT_LIMIT),
|
||||
status: invoiceListStatusSchema.optional(),
|
||||
});
|
||||
|
||||
|
||||
@ -377,6 +377,33 @@ export const ErrorMetadata: Record<ErrorCodeType, ErrorMetadata> = {
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Map HTTP status codes to domain error codes.
|
||||
* Shared between BFF (for responses) and Portal (for parsing).
|
||||
*/
|
||||
export function mapHttpStatusToErrorCode(status?: number): ErrorCodeType {
|
||||
if (!status) return ErrorCode.UNKNOWN;
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
return ErrorCode.SESSION_EXPIRED;
|
||||
case 403:
|
||||
return ErrorCode.FORBIDDEN;
|
||||
case 404:
|
||||
return ErrorCode.NOT_FOUND;
|
||||
case 409:
|
||||
return ErrorCode.ACCOUNT_EXISTS;
|
||||
case 400:
|
||||
return ErrorCode.VALIDATION_FAILED;
|
||||
case 429:
|
||||
return ErrorCode.RATE_LIMITED;
|
||||
case 503:
|
||||
return ErrorCode.SERVICE_UNAVAILABLE;
|
||||
default:
|
||||
return status >= 500 ? ErrorCode.INTERNAL_ERROR : ErrorCode.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly message for an error code
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user