diff --git a/apps/bff/src/app/bootstrap.ts b/apps/bff/src/app/bootstrap.ts index 5d0e5d4a..c58f98e5 100644 --- a/apps/bff/src/app/bootstrap.ts +++ b/apps/bff/src/app/bootstrap.ts @@ -6,7 +6,7 @@ import { Logger } from "nestjs-pino"; import helmet from "helmet"; import cookieParser from "cookie-parser"; import * as express from "express"; -import type { CookieOptions, Response, NextFunction } from "express"; +import type { CookieOptions, Response, NextFunction, Request } from "express"; /* eslint-disable @typescript-eslint/no-namespace */ declare global { @@ -79,7 +79,7 @@ export async function bootstrap(): Promise { secure: configService.get("NODE_ENV") === "production", }; - app.use((_req, res: Response, next: NextFunction) => { + app.use((_req: Request, res: Response, next: NextFunction) => { res.setSecureCookie = (name: string, value: string, options: CookieOptions = {}) => { res.cookie(name, value, { ...secureCookieDefaults, ...options }); }; @@ -134,7 +134,7 @@ export async function bootstrap(): Promise { // Global exception filters app.useGlobalFilters( new AuthErrorFilter(app.get(Logger)), // Handle auth errors first - new GlobalExceptionFilter(app.get(Logger)) // Handle all other errors + new GlobalExceptionFilter(app.get(Logger), configService) // Handle all other errors ); // Global authentication guard will be registered via APP_GUARD provider in AuthModule diff --git a/apps/bff/src/integrations/freebit/freebit.module.ts b/apps/bff/src/integrations/freebit/freebit.module.ts index 14350766..4656ebe5 100644 --- a/apps/bff/src/integrations/freebit/freebit.module.ts +++ b/apps/bff/src/integrations/freebit/freebit.module.ts @@ -9,7 +9,6 @@ import { FreebitOperationsService } from "./services/freebit-operations.service" FreebitMapperService, FreebitOperationsService, FreebitOrchestratorService, - ], exports: [ // Export orchestrator in case other services need direct access diff --git a/apps/bff/src/integrations/freebit/interfaces/freebit.types.ts b/apps/bff/src/integrations/freebit/interfaces/freebit.types.ts index 3237ce8a..b8ff72c4 100644 --- a/apps/bff/src/integrations/freebit/interfaces/freebit.types.ts +++ b/apps/bff/src/integrations/freebit/interfaces/freebit.types.ts @@ -27,20 +27,33 @@ export interface FreebitAccountDetail { kind: "MASTER" | "MVNO"; account: string | number; state: "active" | "suspended" | "temporary" | "waiting" | "obsolete"; + status?: "active" | "suspended" | "temporary" | "waiting" | "obsolete"; startDate?: string | number; relationCode?: string; resultCode?: string | number; planCode?: string; + planName?: string; iccid?: string | number; imsi?: string | number; eid?: string; contractLine?: string; size?: "standard" | "nano" | "micro" | "esim"; + simSize?: "standard" | "nano" | "micro" | "esim"; + msisdn?: string | number; sms?: number; // 10=active, 20=inactive talk?: number; // 10=active, 20=inactive ipv4?: string; ipv6?: string; quota?: number; // Remaining quota + remainingQuotaMb?: string | number | null; + remainingQuotaKb?: string | number | null; + voicemail?: "10" | "20" | number | null; + voiceMail?: "10" | "20" | number | null; + callwaiting?: "10" | "20" | number | null; + callWaiting?: "10" | "20" | number | null; + worldwing?: "10" | "20" | number | null; + worldWing?: "10" | "20" | number | null; + networkType?: string; async?: { func: string; date: string | number }; } diff --git a/apps/bff/src/integrations/freebit/services/freebit-auth.service.ts b/apps/bff/src/integrations/freebit/services/freebit-auth.service.ts index 69b94b7e..1cdd787f 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-auth.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-auth.service.ts @@ -2,10 +2,10 @@ import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import type { - FreebitConfig, - FreebitAuthRequest, - FreebitAuthResponse +import type { + FreebitConfig, + FreebitAuthRequest, + FreebitAuthResponse, } from "../interfaces/freebit.types"; import { FreebitError } from "./freebit-error.service"; diff --git a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts index 23e406a8..cf6d17e5 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts @@ -22,13 +22,13 @@ export class FreebitClientService { /** * Make an authenticated request to Freebit API with retry logic */ - async makeAuthenticatedRequest< - TResponse extends FreebitResponseBase, - TPayload extends object, - >(endpoint: string, payload: TPayload): Promise { + async makeAuthenticatedRequest( + endpoint: string, + payload: TPayload + ): Promise { const authKey = await this.authService.getAuthKey(); const config = this.authService.getConfig(); - + const requestPayload = { ...payload, authKey }; const url = `${config.baseUrl}${endpoint}`; @@ -176,10 +176,13 @@ export class FreebitClientService { if (attempt === config.retryAttempts) { const message = getErrorMessage(error); - this.logger.error(`Freebit JSON API request failed after ${config.retryAttempts} attempts`, { - url, - error: message, - }); + this.logger.error( + `Freebit JSON API request failed after ${config.retryAttempts} attempts`, + { + url, + error: message, + } + ); throw new FreebitError(`Request failed: ${message}`); } @@ -213,7 +216,7 @@ export class FreebitClientService { }); clearTimeout(timeout); - + return response.ok; } catch (error) { this.logger.debug("Simple request failed", { diff --git a/apps/bff/src/integrations/freebit/services/freebit-error.service.ts b/apps/bff/src/integrations/freebit/services/freebit-error.service.ts index 7f2d9e8f..1dc01c46 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-error.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-error.service.ts @@ -68,19 +68,19 @@ export class FreebitError extends Error { if (this.isAuthError()) { return "SIM service is temporarily unavailable. Please try again later."; } - + if (this.isRateLimitError()) { return "Service is busy. Please wait a moment and try again."; } - + if (this.message.toLowerCase().includes("account not found")) { return "SIM account not found. Please contact support to verify your SIM configuration."; } - + if (this.message.toLowerCase().includes("timeout")) { return "SIM service request timed out. Please try again."; } - + return "SIM operation failed. Please try again or contact support."; } } diff --git a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts index 7f04eea3..1f905215 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts @@ -42,7 +42,7 @@ export class FreebitMapperService { if (account.eid) { simType = "esim"; } else if (account.simSize) { - simType = account.simSize as "standard" | "nano" | "micro" | "esim"; + simType = account.simSize; } return { @@ -75,15 +75,11 @@ export class FreebitMapperService { } const todayUsageKb = parseInt(response.traffic.today, 10) || 0; - const recentDaysData = response.traffic.inRecentDays - .split(",") - .map((usage, index) => ({ - date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000) - .toISOString() - .split("T")[0], - usageKb: parseInt(usage, 10) || 0, - usageMb: Math.round(((parseInt(usage, 10) || 0) / 1024) * 100) / 100, - })); + const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({ + date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0], + usageKb: parseInt(usage, 10) || 0, + usageMb: Math.round(((parseInt(usage, 10) || 0) / 1024) * 100) / 100, + })); return { account: String(response.account ?? ""), @@ -106,7 +102,7 @@ export class FreebitMapperService { account, totalAdditions: Number(response.total) || 0, additionCount: Number(response.count) || 0, - history: response.quotaHistory.map((item) => ({ + history: response.quotaHistory.map(item => ({ quotaKb: parseInt(item.quota, 10), quotaMb: Math.round((parseInt(item.quota, 10) / 1024) * 100) / 100, addedDate: item.date, @@ -149,11 +145,11 @@ export class FreebitMapperService { if (!/^\d{8}$/.test(dateString)) { return null; } - + const year = parseInt(dateString.substring(0, 4), 10); const month = parseInt(dateString.substring(4, 6), 10) - 1; // Month is 0-indexed const day = parseInt(dateString.substring(6, 8), 10); - + return new Date(year, month, day); } } diff --git a/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts b/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts index cd95c18d..7be7f482 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts @@ -74,7 +74,7 @@ export class FreebitOperationsService { let response: FreebitAccountDetailsResponse | undefined; let lastError: unknown; - + for (const ep of candidates) { try { if (ep !== candidates[0]) { @@ -92,7 +92,7 @@ export class FreebitOperationsService { } } } - + if (!response) { if (lastError instanceof Error) { throw lastError; @@ -189,10 +189,10 @@ export class FreebitOperationsService { toDate: string ): Promise { try { - const request: Omit = { - account, - fromDate, - toDate + const request: Omit = { + account, + fromDate, + toDate, }; const response = await this.client.makeAuthenticatedRequest< @@ -240,7 +240,7 @@ export class FreebitOperationsService { assignGlobalIp: options.assignGlobalIp, scheduled: !!options.scheduledAt, }); - + return { ipv4: response.ipv4, ipv6: response.ipv6, @@ -289,7 +289,7 @@ export class FreebitOperationsService { } await this.client.makeAuthenticatedRequest( - "/master/addSpec/", + "/master/addSpec/", request ); @@ -316,9 +316,9 @@ export class FreebitOperationsService { */ async cancelSim(account: string, scheduledAt?: string): Promise { try { - const request: Omit = { - account, - runTime: scheduledAt + const request: Omit = { + account, + runTime: scheduledAt, }; await this.client.makeAuthenticatedRequest( @@ -326,9 +326,9 @@ export class FreebitOperationsService { request ); - this.logger.log(`Successfully cancelled SIM for account ${account}`, { - account, - runTime: scheduledAt + this.logger.log(`Successfully cancelled SIM for account ${account}`, { + account, + runTime: scheduledAt, }); } catch (error) { const message = getErrorMessage(error); @@ -425,7 +425,16 @@ export class FreebitOperationsService { birthday?: string; }; }): Promise { - const { account, eid, planCode, contractLine, aladinOperated = "10", shipDate, mnp, identity } = params; + const { + account, + eid, + planCode, + contractLine, + aladinOperated = "10", + shipDate, + mnp, + identity, + } = params; if (!account || !eid) { throw new BadRequestException("activateEsimAccountNew requires account and eid"); @@ -450,10 +459,7 @@ export class FreebitOperationsService { await this.client.makeAuthenticatedJsonRequest< FreebitEsimAccountActivationResponse, FreebitEsimAccountActivationRequest - >( - "/mvno/esim/addAcct/", - payload - ); + >("/mvno/esim/addAcct/", payload); this.logger.log("Successfully activated new eSIM account via PA05-41", { account, diff --git a/apps/bff/src/integrations/freebit/services/index.ts b/apps/bff/src/integrations/freebit/services/index.ts index 4e6098c1..e69a92aa 100644 --- a/apps/bff/src/integrations/freebit/services/index.ts +++ b/apps/bff/src/integrations/freebit/services/index.ts @@ -1,4 +1,4 @@ // Export all Freebit services -export { FreebitOrchestratorService } from './freebit-orchestrator.service'; -export { FreebitMapperService } from './freebit-mapper.service'; -export { FreebitOperationsService } from './freebit-operations.service'; +export { FreebitOrchestratorService } from "./freebit-orchestrator.service"; +export { FreebitMapperService } from "./freebit-mapper.service"; +export { FreebitOperationsService } from "./freebit-operations.service"; diff --git a/apps/bff/src/integrations/salesforce/events/pubsub.subscriber.ts b/apps/bff/src/integrations/salesforce/events/pubsub.subscriber.ts index 49d99004..e58fb0d3 100644 --- a/apps/bff/src/integrations/salesforce/events/pubsub.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/pubsub.subscriber.ts @@ -188,7 +188,9 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy const errorData = data as SalesforcePubSubError; const details = errorData.details || ""; const metadata = errorData.metadata || {}; - const errorCodes = Array.isArray(metadata["error-code"]) ? metadata["error-code"] : []; + const errorCodes = Array.isArray(metadata["error-code"]) + ? metadata["error-code"] + : []; const hasCorruptionCode = errorCodes.some(code => String(code).includes("replayid.corrupted") ); diff --git a/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts b/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts index 091ea31f..1915d112 100644 --- a/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts @@ -61,15 +61,15 @@ export class WhmcsConfigService { * Validate that required configuration is present */ validateConfig(): void { - const required = ['baseUrl', 'identifier', 'secret']; + const required = ["baseUrl", "identifier", "secret"]; const missing = required.filter(key => !this.config[key as keyof WhmcsApiConfig]); - + if (missing.length > 0) { - throw new Error(`Missing required WHMCS configuration: ${missing.join(', ')}`); + throw new Error(`Missing required WHMCS configuration: ${missing.join(", ")}`); } - if (!this.config.baseUrl.startsWith('http')) { - throw new Error('WHMCS baseUrl must start with http:// or https://'); + if (!this.config.baseUrl.startsWith("http")) { + throw new Error("WHMCS baseUrl must start with http:// or https://"); } } @@ -81,21 +81,15 @@ export class WhmcsConfigService { const isDev = nodeEnv !== "production"; // Resolve and normalize base URL (trim trailing slashes) - const rawBaseUrl = this.getFirst([ - isDev ? "WHMCS_DEV_BASE_URL" : undefined, - "WHMCS_BASE_URL" - ]) || ""; + const rawBaseUrl = + this.getFirst([isDev ? "WHMCS_DEV_BASE_URL" : undefined, "WHMCS_BASE_URL"]) || ""; const baseUrl = rawBaseUrl.replace(/\/+$/, ""); - const identifier = this.getFirst([ - isDev ? "WHMCS_DEV_API_IDENTIFIER" : undefined, - "WHMCS_API_IDENTIFIER" - ]) || ""; + const identifier = + this.getFirst([isDev ? "WHMCS_DEV_API_IDENTIFIER" : undefined, "WHMCS_API_IDENTIFIER"]) || ""; - const secret = this.getFirst([ - isDev ? "WHMCS_DEV_API_SECRET" : undefined, - "WHMCS_API_SECRET" - ]) || ""; + const secret = + this.getFirst([isDev ? "WHMCS_DEV_API_SECRET" : undefined, "WHMCS_API_SECRET"]) || ""; const adminUsername = this.getFirst([ isDev ? "WHMCS_DEV_ADMIN_USERNAME" : undefined, @@ -127,10 +121,7 @@ export class WhmcsConfigService { const nodeEnv = this.configService.get("NODE_ENV", "development"); const isDev = nodeEnv !== "production"; - return this.getFirst([ - isDev ? "WHMCS_DEV_API_ACCESS_KEY" : undefined, - "WHMCS_API_ACCESS_KEY", - ]); + return this.getFirst([isDev ? "WHMCS_DEV_API_ACCESS_KEY" : undefined, "WHMCS_API_ACCESS_KEY"]); } /** @@ -153,7 +144,7 @@ export class WhmcsConfigService { private getNumberConfig(key: string, defaultValue: number): number { const value = this.configService.get(key); if (!value) return defaultValue; - + const parsed = parseInt(value, 10); return isNaN(parsed) ? defaultValue : parsed; } diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts index bb3d9c44..8c27ba90 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts @@ -116,7 +116,6 @@ export class WhmcsApiMethodsService { return this.makeRequest("GetInvoice", { invoiceid: invoiceId }); } - // ========================================== // PRODUCT/SUBSCRIPTION API METHODS // ========================================== @@ -137,7 +136,6 @@ export class WhmcsApiMethodsService { return this.makeRequest("GetPayMethods", params); } - async getPaymentGateways(): Promise { return this.makeRequest("GetPaymentMethods", {}); } @@ -176,11 +174,11 @@ export class WhmcsApiMethodsService { } return this.makeRequest<{ result: string }>( - "AcceptOrder", - { + "AcceptOrder", + { orderid: orderId.toString(), autosetup: true, - sendemail: false + sendemail: false, }, { useAdminAuth: true } ); @@ -192,13 +190,12 @@ export class WhmcsApiMethodsService { } return this.makeRequest<{ result: string }>( - "CancelOrder", + "CancelOrder", { orderid: orderId }, { useAdminAuth: true } ); } - // ========================================== // SSO API METHODS // ========================================== @@ -207,7 +204,6 @@ export class WhmcsApiMethodsService { return this.makeRequest("CreateSsoToken", params); } - async getProducts() { return this.makeRequest("GetProducts", {}); } diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts index 639c7527..6a504ad7 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts @@ -5,8 +5,8 @@ import { WhmcsConfigService } from "../config/whmcs-config.service"; import { WhmcsHttpClientService } from "./whmcs-http-client.service"; import { WhmcsErrorHandlerService } from "./whmcs-error-handler.service"; import { WhmcsApiMethodsService } from "./whmcs-api-methods.service"; -import type { - WhmcsApiResponse, +import type { + WhmcsApiResponse, WhmcsErrorResponse, WhmcsAddClientParams, WhmcsValidateLoginParams, @@ -18,10 +18,7 @@ import type { WhmcsUpdateInvoiceParams, WhmcsCapturePaymentParams, } from "../../types/whmcs-api.types"; -import type { - WhmcsRequestOptions, - WhmcsConnectionStats -} from "../types/connection.types"; +import type { WhmcsRequestOptions, WhmcsConnectionStats } from "../types/connection.types"; /** * Main orchestrator service for WHMCS connections @@ -41,7 +38,7 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { try { // Validate configuration on startup this.configService.validateConfig(); - + // Test connection const isAvailable = await this.apiMethods.isAvailable(); if (isAvailable) { @@ -71,7 +68,7 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { try { const config = this.configService.getConfig(); const response = await this.httpClient.makeRequest(config, action, params, options); - + if (response.result === "error") { const errorResponse = response as WhmcsErrorResponse; this.errorHandler.handleApiError(errorResponse, action, params); @@ -180,7 +177,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { return this.apiMethods.cancelOrder(orderId); } - // ========================================== // PRODUCT/SUBSCRIPTION API METHODS // ========================================== @@ -193,7 +189,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { return this.apiMethods.getCatalogProducts(); } - async getProducts() { return this.apiMethods.getProducts(); } @@ -206,7 +201,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { return this.apiMethods.getPaymentMethods(params); } - async getPaymentGateways() { return this.apiMethods.getPaymentGateways(); } @@ -227,7 +221,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { return this.configService.getBaseUrl(); } - // ========================================== // UTILITY METHODS // ========================================== @@ -272,7 +265,9 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { * Check if error is already a handled exception */ private isHandledException(error: unknown): boolean { - return error instanceof Error && - (error.name.includes('Exception') || error.message.includes('WHMCS')); + return ( + error instanceof Error && + (error.name.includes("Exception") || error.message.includes("WHMCS")) + ); } } diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts index 386567ae..ead43d6e 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-error-handler.service.ts @@ -1,4 +1,9 @@ -import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from "@nestjs/common"; +import { + Injectable, + NotFoundException, + BadRequestException, + UnauthorizedException, +} from "@nestjs/common"; import { getErrorMessage } from "@bff/core/utils/error.util"; import type { WhmcsErrorResponse } from "../../types/whmcs-api.types"; @@ -33,7 +38,7 @@ export class WhmcsErrorHandlerService { } // Generic WHMCS API error - throw new Error(`WHMCS API Error [${action}]: ${message} (${errorCode || 'unknown'})`); + throw new Error(`WHMCS API Error [${action}]: ${message} (${errorCode || "unknown"})`); } /** @@ -64,15 +69,17 @@ export class WhmcsErrorHandlerService { */ private isNotFoundError(action: string, message: string): boolean { const lowerMessage = message.toLowerCase(); - + // Client not found errors if (action === "GetClientsDetails" && lowerMessage.includes("client not found")) { return true; } // Invoice not found errors - if ((action === "GetInvoice" || action === "UpdateInvoice") && - lowerMessage.includes("invoice not found")) { + if ( + (action === "GetInvoice" || action === "UpdateInvoice") && + lowerMessage.includes("invoice not found") + ) { return true; } @@ -89,12 +96,14 @@ export class WhmcsErrorHandlerService { */ private isAuthenticationError(message: string, errorCode?: string): boolean { const lowerMessage = message.toLowerCase(); - - return lowerMessage.includes("authentication") || - lowerMessage.includes("unauthorized") || - lowerMessage.includes("invalid credentials") || - lowerMessage.includes("access denied") || - errorCode === "AUTHENTICATION_FAILED"; + + return ( + lowerMessage.includes("authentication") || + lowerMessage.includes("unauthorized") || + lowerMessage.includes("invalid credentials") || + lowerMessage.includes("access denied") || + errorCode === "AUTHENTICATION_FAILED" + ); } /** @@ -102,12 +111,14 @@ export class WhmcsErrorHandlerService { */ private isValidationError(message: string, errorCode?: string): boolean { const lowerMessage = message.toLowerCase(); - - return lowerMessage.includes("required") || - lowerMessage.includes("invalid") || - lowerMessage.includes("missing") || - lowerMessage.includes("validation") || - errorCode === "VALIDATION_ERROR"; + + return ( + lowerMessage.includes("required") || + lowerMessage.includes("invalid") || + lowerMessage.includes("missing") || + lowerMessage.includes("validation") || + errorCode === "VALIDATION_ERROR" + ); } /** @@ -125,21 +136,21 @@ export class WhmcsErrorHandlerService { } const clientIdParam = params["clientid"]; - const identifier = + const identifier = typeof clientIdParam === "string" || typeof clientIdParam === "number" ? clientIdParam : "unknown"; - + return new NotFoundException(`Client with ID ${identifier} not found`); } if (action === "GetInvoice" || action === "UpdateInvoice") { const invoiceIdParam = params["invoiceid"]; - const identifier = + const identifier = typeof invoiceIdParam === "string" || typeof invoiceIdParam === "number" ? invoiceIdParam : "unknown"; - + return new NotFoundException(`Invoice with ID ${identifier} not found`); } @@ -152,9 +163,11 @@ export class WhmcsErrorHandlerService { */ private isTimeoutError(error: unknown): boolean { const message = getErrorMessage(error).toLowerCase(); - return message.includes("timeout") || - message.includes("aborted") || - (error instanceof Error && error.name === "AbortError"); + return ( + message.includes("timeout") || + message.includes("aborted") || + (error instanceof Error && error.name === "AbortError") + ); } /** @@ -162,20 +175,24 @@ export class WhmcsErrorHandlerService { */ private isNetworkError(error: unknown): boolean { const message = getErrorMessage(error).toLowerCase(); - return message.includes("network") || - message.includes("connection") || - message.includes("econnrefused") || - message.includes("enotfound") || - message.includes("fetch"); + return ( + message.includes("network") || + message.includes("connection") || + message.includes("econnrefused") || + message.includes("enotfound") || + message.includes("fetch") + ); } /** * Check if error is already a known NestJS exception */ private isKnownException(error: unknown): boolean { - return error instanceof NotFoundException || - error instanceof BadRequestException || - error instanceof UnauthorizedException; + return ( + error instanceof NotFoundException || + error instanceof BadRequestException || + error instanceof UnauthorizedException + ); } /** diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts index aff71983..17f0a8e4 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts @@ -1,14 +1,11 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import type { - WhmcsApiResponse, - WhmcsErrorResponse -} from "../../types/whmcs-api.types"; -import type { - WhmcsApiConfig, +import type { WhmcsApiResponse, WhmcsErrorResponse } from "../../types/whmcs-api.types"; +import type { + WhmcsApiConfig, WhmcsRequestOptions, - WhmcsConnectionStats + WhmcsConnectionStats, } from "../types/connection.types"; /** @@ -41,22 +38,22 @@ export class WhmcsHttpClientService { try { const response = await this.executeRequest(config, action, params, options); - + const responseTime = Date.now() - startTime; this.updateSuccessStats(responseTime); - + return response; } catch (error) { this.stats.failedRequests++; this.stats.lastErrorTime = new Date(); - + this.logger.error(`WHMCS HTTP request failed [${action}]`, { error: getErrorMessage(error), action, params: this.sanitizeLogParams(params), responseTime: Date.now() - startTime, }); - + throw error; } } @@ -97,7 +94,7 @@ export class WhmcsHttpClientService { return await this.performSingleRequest(config, action, params, options); } catch (error) { lastError = error as Error; - + if (attempt === maxAttempts) { break; } @@ -176,7 +173,7 @@ export class WhmcsHttpClientService { options: WhmcsRequestOptions ): string { const formData = new URLSearchParams(); - + // Add authentication if (options.useAdminAuth && config.adminUsername && config.adminPasswordHash) { formData.append("username", config.adminUsername); @@ -223,7 +220,9 @@ export class WhmcsHttpClientService { if (data.result === "error") { const errorResponse = data as WhmcsErrorResponse; - throw new Error(`WHMCS API Error: ${errorResponse.message} (${errorResponse.errorcode || 'unknown'})`); + throw new Error( + `WHMCS API Error: ${errorResponse.message} (${errorResponse.errorcode || "unknown"})` + ); } return data; @@ -234,19 +233,19 @@ export class WhmcsHttpClientService { */ private shouldNotRetry(error: unknown): boolean { const message = getErrorMessage(error).toLowerCase(); - + // Don't retry authentication errors - if (message.includes('authentication') || message.includes('unauthorized')) { + if (message.includes("authentication") || message.includes("unauthorized")) { return true; } // Don't retry validation errors - if (message.includes('invalid') || message.includes('required')) { + if (message.includes("invalid") || message.includes("required")) { return true; } // Don't retry not found errors - if (message.includes('not found')) { + if (message.includes("not found")) { return true; } @@ -274,10 +273,10 @@ export class WhmcsHttpClientService { */ private updateSuccessStats(responseTime: number): void { this.stats.successfulRequests++; - + // Update average response time const totalSuccessful = this.stats.successfulRequests; - this.stats.averageResponseTime = + this.stats.averageResponseTime = (this.stats.averageResponseTime * (totalSuccessful - 1) + responseTime) / totalSuccessful; } @@ -286,18 +285,25 @@ export class WhmcsHttpClientService { */ private sanitizeLogParams(params: Record): Record { const sensitiveKeys = [ - 'password', 'secret', 'token', 'key', 'auth', - 'credit_card', 'cvv', 'ssn', 'social_security' + "password", + "secret", + "token", + "key", + "auth", + "credit_card", + "cvv", + "ssn", + "social_security", ]; const sanitized: Record = {}; - + for (const [key, value] of Object.entries(params)) { const keyLower = key.toLowerCase(); const isSensitive = sensitiveKeys.some(sensitive => keyLower.includes(sensitive)); - + if (isSensitive) { - sanitized[key] = '[REDACTED]'; + sanitized[key] = "[REDACTED]"; } else { sanitized[key] = value; } diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index 2aef06fc..14b66704 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -1,7 +1,11 @@ import { getErrorMessage } from "@bff/core/utils/error.util"; import { Logger } from "nestjs-pino"; import { Injectable, NotFoundException, Inject } from "@nestjs/common"; -import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema } from "@customer-portal/domain"; +import { Invoice, InvoiceList } from "@customer-portal/domain"; +import { + invoiceListSchema, + invoiceSchema as invoiceEntitySchema, +} from "@customer-portal/domain/validation/shared/entities"; import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service"; import { InvoiceTransformerService } from "../transformers/services/invoice-transformer.service"; import { WhmcsCacheService } from "../cache/whmcs-cache.service"; @@ -113,7 +117,7 @@ export class WhmcsInvoiceService { try { // Get detailed invoice with items const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id); - const parseResult = invoiceSchema.safeParse(detailedInvoice); + const parseResult = invoiceEntitySchema.safeParse(detailedInvoice); if (!parseResult.success) { this.logger.error("Failed to parse detailed invoice", { error: parseResult.error.issues, @@ -180,7 +184,7 @@ export class WhmcsInvoiceService { // Transform invoice const invoice = this.invoiceTransformer.transformInvoice(response); - const parseResult = invoiceSchema.safeParse(invoice); + const parseResult = invoiceEntitySchema.safeParse(invoice); if (!parseResult.success) { throw new Error(`Invalid invoice data after transformation`); } @@ -206,7 +210,6 @@ export class WhmcsInvoiceService { this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`); } - private transformInvoicesResponse( response: WhmcsInvoicesResponse, clientId: number, @@ -225,18 +228,18 @@ export class WhmcsInvoiceService { } satisfies InvoiceList; } - const invoices = response.invoices.invoice - .map(whmcsInvoice => { - try { - return this.invoiceTransformer.transformInvoice(whmcsInvoice); - } catch (error) { - this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, { - error: getErrorMessage(error), - }); - return null; - } - }) - .filter((invoice): invoice is Invoice => invoice !== null); + const invoices: Invoice[] = []; + for (const whmcsInvoice of response.invoices.invoice) { + try { + const transformed = this.invoiceTransformer.transformInvoice(whmcsInvoice); + const parsed = invoiceEntitySchema.parse(transformed); + invoices.push(parsed); + } catch (error) { + this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, { + error: getErrorMessage(error), + }); + } + } this.logger.debug(`WHMCS GetInvoices Response Analysis for Client ${clientId}:`, { totalresults: response.totalresults, diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts index 6a197e2a..fd2b09c0 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts @@ -243,7 +243,6 @@ export class WhmcsPaymentService { } } - /** * Normalize WHMCS SSO redirect URLs to absolute using configured base URL. */ diff --git a/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts index d727cd61..cbbc03e3 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts @@ -1,9 +1,6 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { - Invoice, - InvoiceItem as BaseInvoiceItem, -} from "@customer-portal/domain"; +import { Invoice, InvoiceItem as BaseInvoiceItem } from "@customer-portal/domain"; import type { WhmcsInvoice, WhmcsInvoiceItems, @@ -33,7 +30,7 @@ export class InvoiceTransformerService { */ transformInvoice(whmcsInvoice: WhmcsInvoice): Invoice { const invoiceId = whmcsInvoice.invoiceid || whmcsInvoice.id; - + if (!this.validator.validateWhmcsInvoiceData(whmcsInvoice)) { throw new Error("Invalid invoice data from WHMCS"); } @@ -45,7 +42,7 @@ export class InvoiceTransformerService { status: StatusNormalizer.normalizeInvoiceStatus(whmcsInvoice.status), currency: whmcsInvoice.currencycode || "JPY", currencySymbol: - whmcsInvoice.currencyprefix || + whmcsInvoice.currencyprefix || DataUtils.getCurrencySymbol(whmcsInvoice.currencycode || "JPY"), total: DataUtils.parseAmount(whmcsInvoice.total), subtotal: DataUtils.parseAmount(whmcsInvoice.subtotal), @@ -90,14 +87,14 @@ export class InvoiceTransformerService { // WHMCS API returns either an array or single item const itemsArray = Array.isArray(items.item) ? items.item : [items.item]; - + return itemsArray.map(item => this.transformSingleInvoiceItem(item)); } /** * Transform a single invoice item using exact WHMCS API structure */ - private transformSingleInvoiceItem(item: WhmcsInvoiceItems['item'][0]): InvoiceItem { + private transformSingleInvoiceItem(item: WhmcsInvoiceItems["item"][0]): InvoiceItem { const transformedItem: InvoiceItem = { id: item.id, description: item.description, diff --git a/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts index 27222ee7..c5c9712f 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts @@ -85,8 +85,6 @@ export class PaymentTransformerService { return transformed; } - - /** * Normalize expiry date to MM/YY format */ @@ -95,12 +93,12 @@ export class PaymentTransformerService { // Handle various formats: MM/YY, MM/YYYY, MMYY, MMYYYY const cleaned = expiryDate.replace(/\D/g, ""); - + if (cleaned.length === 4) { // MMYY format return `${cleaned.substring(0, 2)}/${cleaned.substring(2, 4)}`; } - + if (cleaned.length === 6) { // MMYYYY format - convert to MM/YY return `${cleaned.substring(0, 2)}/${cleaned.substring(4, 6)}`; @@ -185,7 +183,9 @@ export class PaymentTransformerService { /** * Normalize gateway type to match our enum */ - private normalizeGatewayType(type: string): "merchant" | "thirdparty" | "tokenization" | "manual" { + private normalizeGatewayType( + type: string + ): "merchant" | "thirdparty" | "tokenization" | "manual" { const normalizedType = type.toLowerCase(); switch (normalizedType) { case "merchant": @@ -207,14 +207,25 @@ export class PaymentTransformerService { /** * Normalize payment method type to match our enum */ - private normalizePaymentType(gatewayName?: string): "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount" | "Manual" { + private normalizePaymentType( + gatewayName?: string + ): "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount" | "Manual" { if (!gatewayName) return "Manual"; - + const normalized = gatewayName.toLowerCase(); - if (normalized.includes("credit") || normalized.includes("card") || normalized.includes("visa") || normalized.includes("mastercard")) { + if ( + normalized.includes("credit") || + normalized.includes("card") || + normalized.includes("visa") || + normalized.includes("mastercard") + ) { return "CreditCard"; } - if (normalized.includes("bank") || normalized.includes("ach") || normalized.includes("account")) { + if ( + normalized.includes("bank") || + normalized.includes("ach") || + normalized.includes("account") + ) { return "BankAccount"; } if (normalized.includes("remote") || normalized.includes("token")) { diff --git a/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts index d5fa31f6..43248b4b 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts @@ -65,7 +65,9 @@ export class SubscriptionTransformerService { cycle: subscription.cycle, amount: subscription.amount, currency: subscription.currency, - hasCustomFields: Boolean(subscription.customFields && Object.keys(subscription.customFields).length > 0), + hasCustomFields: Boolean( + subscription.customFields && Object.keys(subscription.customFields).length > 0 + ), }); return subscription; @@ -95,7 +97,9 @@ export class SubscriptionTransformerService { /** * Extract and normalize custom fields from WHMCS format */ - private extractCustomFields(customFields: WhmcsCustomField[] | undefined): Record | undefined { + private extractCustomFields( + customFields: WhmcsCustomField[] | undefined + ): Record | undefined { if (!customFields || !Array.isArray(customFields) || customFields.length === 0) { return undefined; } diff --git a/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts index 5eedd56e..48f4d849 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts @@ -1,11 +1,6 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { - Invoice, - Subscription, - PaymentMethod, - PaymentGateway, -} from "@customer-portal/domain"; +import { Invoice, Subscription, PaymentMethod, PaymentGateway } from "@customer-portal/domain"; import type { WhmcsInvoice, WhmcsProduct, @@ -174,7 +169,7 @@ export class WhmcsTransformerOrchestratorService { } } - this.logger.info("Batch invoice transformation completed", { + this.logger.log("Batch invoice transformation completed", { total: whmcsInvoices.length, successful: successful.length, failed: failed.length, @@ -205,7 +200,7 @@ export class WhmcsTransformerOrchestratorService { } } - this.logger.info("Batch subscription transformation completed", { + this.logger.log("Batch subscription transformation completed", { total: whmcsProducts.length, successful: successful.length, failed: failed.length, @@ -236,7 +231,7 @@ export class WhmcsTransformerOrchestratorService { } } - this.logger.info("Batch payment method transformation completed", { + this.logger.log("Batch payment method transformation completed", { total: whmcsPayMethods.length, successful: successful.length, failed: failed.length, @@ -267,7 +262,7 @@ export class WhmcsTransformerOrchestratorService { } } - this.logger.info("Batch payment gateway transformation completed", { + this.logger.log("Batch payment gateway transformation completed", { total: whmcsGateways.length, successful: successful.length, failed: failed.length, @@ -336,17 +331,12 @@ export class WhmcsTransformerOrchestratorService { validationRules: string[]; } { return { - supportedTypes: [ - "invoices", - "subscriptions", - "payment_methods", - "payment_gateways" - ], + supportedTypes: ["invoices", "subscriptions", "payment_methods", "payment_gateways"], validationRules: [ "required_fields_validation", "data_type_validation", "format_validation", - "business_rule_validation" + "business_rule_validation", ], }; } diff --git a/apps/bff/src/integrations/whmcs/transformers/utils/data-utils.ts b/apps/bff/src/integrations/whmcs/transformers/utils/data-utils.ts index 7c427b44..43a23aad 100644 --- a/apps/bff/src/integrations/whmcs/transformers/utils/data-utils.ts +++ b/apps/bff/src/integrations/whmcs/transformers/utils/data-utils.ts @@ -140,5 +140,4 @@ export class DataUtils { return undefined; } - } diff --git a/apps/bff/src/integrations/whmcs/transformers/utils/status-normalizer.ts b/apps/bff/src/integrations/whmcs/transformers/utils/status-normalizer.ts index 73401f18..900178dd 100644 --- a/apps/bff/src/integrations/whmcs/transformers/utils/status-normalizer.ts +++ b/apps/bff/src/integrations/whmcs/transformers/utils/status-normalizer.ts @@ -1,4 +1,4 @@ -import { +import type { InvoiceStatus, SubscriptionStatus, SubscriptionBillingCycle, diff --git a/apps/bff/src/integrations/whmcs/transformers/validators/transformation-validator.ts b/apps/bff/src/integrations/whmcs/transformers/validators/transformation-validator.ts index c9a2e789..955dafd6 100644 --- a/apps/bff/src/integrations/whmcs/transformers/validators/transformation-validator.ts +++ b/apps/bff/src/integrations/whmcs/transformers/validators/transformation-validator.ts @@ -1,10 +1,5 @@ import { Injectable } from "@nestjs/common"; -import { - Invoice, - Subscription, - PaymentMethod, - PaymentGateway, -} from "@customer-portal/domain"; +import { Invoice, Subscription, PaymentMethod, PaymentGateway } from "@customer-portal/domain"; import type { WhmcsInvoice, WhmcsProduct } from "../../types/whmcs-api.types"; /** @@ -18,15 +13,15 @@ export class TransformationValidator { validateInvoice(invoice: Invoice): boolean { const requiredFields = [ "id", - "number", + "number", "status", "currency", "total", "subtotal", "tax", - "issuedAt" + "issuedAt", ]; - + return requiredFields.every(field => { const value = invoice[field as keyof Invoice]; return value !== undefined && value !== null; @@ -37,14 +32,8 @@ export class TransformationValidator { * Validate subscription transformation result */ validateSubscription(subscription: Subscription): boolean { - const requiredFields = [ - "id", - "serviceId", - "productName", - "status", - "currency" - ]; - + const requiredFields = ["id", "serviceId", "productName", "status", "currency"]; + return requiredFields.every(field => { const value = subscription[field as keyof Subscription]; return value !== undefined && value !== null; @@ -56,7 +45,7 @@ export class TransformationValidator { */ validatePaymentMethod(paymentMethod: PaymentMethod): boolean { const requiredFields = ["id", "type", "description"]; - + return requiredFields.every(field => { const value = paymentMethod[field as keyof PaymentMethod]; return value !== undefined && value !== null; @@ -68,7 +57,7 @@ export class TransformationValidator { */ validatePaymentGateway(gateway: PaymentGateway): boolean { const requiredFields = ["name", "displayName", "type", "isActive"]; - + return requiredFields.every(field => { const value = gateway[field as keyof PaymentGateway]; return value !== undefined && value !== null; @@ -78,9 +67,11 @@ export class TransformationValidator { /** * Validate invoice items array */ - validateInvoiceItems(items: Array<{ description: string; amount: string; id: number; type: string; relid: number }>): boolean { + validateInvoiceItems( + items: Array<{ description: string; amount: string; id: number; type: string; relid: number }> + ): boolean { if (!Array.isArray(items)) return false; - + return items.every(item => { return Boolean(item.description && item.amount && item.id); }); @@ -105,7 +96,7 @@ export class TransformationValidator { */ validateCurrencyCode(currency: string): boolean { if (!currency || typeof currency !== "string") return false; - + // Check if it's a valid 3-letter currency code return /^[A-Z]{3}$/.test(currency.toUpperCase()); } @@ -117,12 +108,12 @@ export class TransformationValidator { if (typeof amount === "number") { return !isNaN(amount) && isFinite(amount); } - + if (typeof amount === "string") { const parsed = parseFloat(amount); return !isNaN(parsed) && isFinite(parsed); } - + return false; } @@ -131,7 +122,7 @@ export class TransformationValidator { */ validateDateString(dateStr: string): boolean { if (!dateStr) return false; - + const date = new Date(dateStr); return !isNaN(date.getTime()); } diff --git a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts index d82e5e0f..0b0c795c 100644 --- a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts +++ b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts @@ -288,6 +288,7 @@ export interface WhmcsCatalogProductsResponse { // Payment Method Types export interface WhmcsPaymentMethod { id: number; + paymethodid?: number; type: "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount"; description: string; gateway_name?: string; @@ -314,7 +315,6 @@ export interface WhmcsGetPayMethodsParams { [key: string]: unknown; } - // Payment Gateway Types export interface WhmcsPaymentGateway { name: string; @@ -421,4 +421,3 @@ export interface WhmcsCapturePaymentResponse { message?: string; error?: string; } - diff --git a/apps/bff/src/integrations/whmcs/whmcs.service.ts b/apps/bff/src/integrations/whmcs/whmcs.service.ts index 9b4a54e1..40128da2 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.service.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.service.ts @@ -28,7 +28,6 @@ import { } from "./types/whmcs-api.types"; import { Logger } from "nestjs-pino"; - @Injectable() export class WhmcsService { constructor( @@ -279,7 +278,6 @@ export class WhmcsService { return this.paymentService.getProducts() as Promise; } - // ========================================== // SSO OPERATIONS (delegate to SsoService) // ========================================== @@ -335,7 +333,6 @@ export class WhmcsService { return this.connectionService.getClientsProducts(params); } - // ========================================== // ORDER OPERATIONS (delegate to OrderService) // ========================================== diff --git a/apps/bff/src/modules/catalog/services/base-catalog.service.ts b/apps/bff/src/modules/catalog/services/base-catalog.service.ts index fd04e693..d5d967fd 100644 --- a/apps/bff/src/modules/catalog/services/base-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/base-catalog.service.ts @@ -9,6 +9,7 @@ import { import { getErrorMessage } from "@bff/core/utils/error.util"; import type { SalesforceProduct2WithPricebookEntries, + SalesforcePricebookEntryRecord, SalesforceQueryResult, } from "@customer-portal/domain"; @@ -45,16 +46,15 @@ export class BaseCatalogService { } } - protected extractPricebookEntry(record: SalesforceProduct2WithPricebookEntries) { - const pricebookEntries = - record.PricebookEntries && typeof record.PricebookEntries === "object" - ? (record.PricebookEntries as { records?: unknown[] }) - : { records: undefined }; - const entry = Array.isArray(pricebookEntries.records) ? pricebookEntries.records[0] : undefined; + protected extractPricebookEntry( + record: SalesforceProduct2WithPricebookEntries + ): SalesforcePricebookEntryRecord | undefined { + const pricebookEntries = record.PricebookEntries?.records; + const entry = Array.isArray(pricebookEntries) ? pricebookEntries[0] : undefined; if (!entry) { const fields = this.getFields(); const skuField = fields.product.sku; - const skuRaw = (record as Record)[skuField]; + const skuRaw = Reflect.get(record, skuField) as unknown; const sku = typeof skuRaw === "string" ? skuRaw : undefined; this.logger.warn( `No pricebook entry found for product ${String(record.Name)} (SKU: ${String(sku ?? "")}). Pricebook ID: ${this.portalPriceBookId}.` diff --git a/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts b/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts index 54487d40..31a56415 100644 --- a/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/vpn-catalog.service.ts @@ -32,7 +32,10 @@ export class VpnCatalogService extends BaseCatalogService { async getActivationFees(): Promise { const fields = this.getFields(); const soql = this.buildProductQuery("VPN", "Activation", [fields.product.vpnRegion]); - const records = await this.executeQuery(soql, "VPN Activation Fees"); + const records = await this.executeQuery( + soql, + "VPN Activation Fees" + ); return records.map(record => { const pricebookEntry = this.extractPricebookEntry(record); diff --git a/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts b/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts index edc5b0a2..735a88a6 100644 --- a/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts +++ b/apps/bff/src/modules/catalog/utils/salesforce-product.mapper.ts @@ -57,7 +57,8 @@ function getTierTemplate(tier?: string): InternetPlanTemplate { case "platinum": return { tierDescription: "Tailored set up with premier Wi-Fi management support", - description: "Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²", + description: + "Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²", features: [ "NTT modem + Netgear INSIGHT Wi-Fi routers", "Cloud management support for remote router management", @@ -146,7 +147,6 @@ function resolveBundledAddon(product: SalesforceCatalogProductRecord) { }; } - function derivePrices( product: SalesforceCatalogProductRecord, pricebookEntry?: SalesforcePricebookEntryRecord diff --git a/apps/bff/src/modules/id-mappings/mappings.service.ts b/apps/bff/src/modules/id-mappings/mappings.service.ts index 9c4523d8..dca5423d 100644 --- a/apps/bff/src/modules/id-mappings/mappings.service.ts +++ b/apps/bff/src/modules/id-mappings/mappings.service.ts @@ -16,9 +16,8 @@ import { UpdateMappingRequest, MappingSearchFilters, MappingStats, - _BulkMappingResult, } from "./types/mapping.types"; -import type { IdMapping as PrismaIdMapping } from "@prisma/client"; +import type { Prisma, IdMapping as PrismaIdMapping } from "@prisma/client"; @Injectable() export class MappingsService { @@ -273,15 +272,22 @@ export class MappingsService { async searchMappings(filters: MappingSearchFilters): Promise { try { - const whereClause: Record = {}; + const whereClause: Prisma.IdMappingWhereInput = {}; if (filters.userId) whereClause.userId = filters.userId; if (filters.whmcsClientId) whereClause.whmcsClientId = filters.whmcsClientId; if (filters.sfAccountId) whereClause.sfAccountId = filters.sfAccountId; if (filters.hasWhmcsMapping !== undefined) { - whereClause.whmcsClientId = filters.hasWhmcsMapping ? { not: null } : null; + if (filters.hasWhmcsMapping) { + whereClause.whmcsClientId = { gt: 0 }; + } else { + this.logger.debug( + "Filtering mappings without WHMCS client IDs (expected to be empty until optional linking ships)" + ); + whereClause.NOT = { whmcsClientId: { gt: 0 } }; + } } if (filters.hasSfMapping !== undefined) { - whereClause.sfAccountId = filters.hasSfMapping ? { not: null } : null; + whereClause.sfAccountId = filters.hasSfMapping ? { not: null } : { equals: null }; } const dbMappings = await this.prisma.idMapping.findMany({ @@ -301,10 +307,10 @@ export class MappingsService { try { const [totalCount, whmcsCount, sfCount, completeCount] = await Promise.all([ this.prisma.idMapping.count(), - this.prisma.idMapping.count({ where: { whmcsClientId: { not: null } } }), + this.prisma.idMapping.count({ where: { whmcsClientId: { gt: 0 } } }), this.prisma.idMapping.count({ where: { sfAccountId: { not: null } } }), this.prisma.idMapping.count({ - where: { whmcsClientId: { not: null }, sfAccountId: { not: null } }, + where: { whmcsClientId: { gt: 0 }, sfAccountId: { not: null } }, }), ]); diff --git a/apps/bff/src/modules/invoices/invoices.controller.ts b/apps/bff/src/modules/invoices/invoices.controller.ts index 2cf00381..360ccc32 100644 --- a/apps/bff/src/modules/invoices/invoices.controller.ts +++ b/apps/bff/src/modules/invoices/invoices.controller.ts @@ -219,12 +219,12 @@ export class InvoicesController { if (!mapping?.whmcsClientId) { throw new Error("WHMCS client mapping not found"); } - + const ssoResult = await this.whmcsService.createSsoToken( mapping.whmcsClientId, invoiceId ? `index.php?rp=/invoice/${invoiceId}` : undefined ); - + return { url: ssoResult.url, expiresAt: ssoResult.expiresAt, @@ -272,14 +272,14 @@ export class InvoicesController { if (!mapping?.whmcsClientId) { throw new Error("WHMCS client mapping not found"); } - + const ssoResult = await this.whmcsService.createPaymentSsoToken( mapping.whmcsClientId, invoiceId, paymentMethodIdNum, gatewayName || "stripe" ); - + return { url: ssoResult.url, expiresAt: ssoResult.expiresAt, diff --git a/apps/bff/src/modules/invoices/services/invoice-health.service.ts b/apps/bff/src/modules/invoices/services/invoice-health.service.ts index 4843d619..388b8506 100644 --- a/apps/bff/src/modules/invoices/services/invoice-health.service.ts +++ b/apps/bff/src/modules/invoices/services/invoice-health.service.ts @@ -36,15 +36,21 @@ export class InvoiceHealthService { const whmcsResult = checks[0]; const mappingsResult = checks[1]; - const isHealthy = - whmcsResult.status === "fulfilled" && whmcsResult.value && - mappingsResult.status === "fulfilled" && mappingsResult.value; + const isHealthy = + whmcsResult.status === "fulfilled" && + whmcsResult.value && + mappingsResult.status === "fulfilled" && + mappingsResult.value; return { status: isHealthy ? "healthy" : "unhealthy", details: { - whmcsApi: whmcsResult.status === "fulfilled" && whmcsResult.value ? "connected" : "disconnected", - mappingsService: mappingsResult.status === "fulfilled" && mappingsResult.value ? "available" : "unavailable", + whmcsApi: + whmcsResult.status === "fulfilled" && whmcsResult.value ? "connected" : "disconnected", + mappingsService: + mappingsResult.status === "fulfilled" && mappingsResult.value + ? "available" + : "unavailable", timestamp: new Date().toISOString(), }, }; @@ -142,7 +148,7 @@ export class InvoiceHealthService { } catch (error) { // We expect this to fail for a non-existent user, but if the service responds, it's healthy const errorMessage = getErrorMessage(error); - + // If it's a "not found" error, the service is working if (errorMessage.toLowerCase().includes("not found")) { return true; @@ -159,15 +165,15 @@ export class InvoiceHealthService { * Update average response time */ private updateAverageResponseTime(responseTime: number): void { - const totalRequests = - this.stats.totalInvoicesRetrieved + - this.stats.totalPaymentLinksCreated + + const totalRequests = + this.stats.totalInvoicesRetrieved + + this.stats.totalPaymentLinksCreated + this.stats.totalSsoLinksCreated; if (totalRequests === 1) { this.stats.averageResponseTime = responseTime; } else { - this.stats.averageResponseTime = + this.stats.averageResponseTime = (this.stats.averageResponseTime * (totalRequests - 1) + responseTime) / totalRequests; } } @@ -182,7 +188,7 @@ export class InvoiceHealthService { lastCheck: string; }> { const health = await this.healthCheck(); - + return { status: health.status, uptime: process.uptime(), diff --git a/apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts b/apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts index cae6dc67..4318b29d 100644 --- a/apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts +++ b/apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts @@ -1,15 +1,20 @@ -import { Injectable, NotFoundException, InternalServerErrorException, Inject } from "@nestjs/common"; +import { + Injectable, + NotFoundException, + InternalServerErrorException, + Inject, +} from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { Invoice, InvoiceList } from "@customer-portal/domain"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { InvoiceValidatorService } from "../validators/invoice-validator.service"; -import type { - GetInvoicesOptions, +import type { + GetInvoicesOptions, InvoiceStatus, PaginationOptions, - UserMappingInfo + UserMappingInfo, } from "../types/invoice-service.types"; /** @@ -34,7 +39,7 @@ export class InvoiceRetrievalService { // Validate inputs this.validator.validateUserId(userId); this.validator.validatePagination({ page, limit }); - + if (status) { this.validator.validateInvoiceStatus(status); } @@ -160,14 +165,20 @@ export class InvoiceRetrievalService { /** * Get cancelled invoices for a user */ - async getCancelledInvoices(userId: string, options: PaginationOptions = {}): Promise { + async getCancelledInvoices( + userId: string, + options: PaginationOptions = {} + ): Promise { return this.getInvoicesByStatus(userId, "Cancelled", options); } /** * Get invoices in collections for a user */ - async getCollectionsInvoices(userId: string, options: PaginationOptions = {}): Promise { + async getCollectionsInvoices( + userId: string, + options: PaginationOptions = {} + ): Promise { return this.getInvoicesByStatus(userId, "Collections", options); } @@ -176,7 +187,7 @@ export class InvoiceRetrievalService { */ private async getUserMapping(userId: string): Promise { const mapping = await this.mappingsService.findByUserId(userId); - + if (!mapping?.whmcsClientId) { throw new NotFoundException("WHMCS client mapping not found"); } diff --git a/apps/bff/src/modules/invoices/services/invoices-orchestrator.service.ts b/apps/bff/src/modules/invoices/services/invoices-orchestrator.service.ts index a616eeb3..267f2e16 100644 --- a/apps/bff/src/modules/invoices/services/invoices-orchestrator.service.ts +++ b/apps/bff/src/modules/invoices/services/invoices-orchestrator.service.ts @@ -1,22 +1,22 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { - Invoice, - InvoiceList, - InvoiceSsoLink, +import { + Invoice, + InvoiceList, + InvoiceSsoLink, InvoicePaymentLink, PaymentMethodList, - PaymentGatewayList + PaymentGatewayList, } from "@customer-portal/domain"; import { InvoiceRetrievalService } from "./invoice-retrieval.service"; import { InvoiceHealthService } from "./invoice-health.service"; import { InvoiceValidatorService } from "../validators/invoice-validator.service"; -import type { - GetInvoicesOptions, +import type { + GetInvoicesOptions, InvoiceStatus, PaginationOptions, InvoiceHealthStatus, - InvoiceServiceStats + InvoiceServiceStats, } from "../types/invoice-service.types"; /** @@ -41,7 +41,7 @@ export class InvoicesOrchestratorService { */ async getInvoices(userId: string, options: GetInvoicesOptions = {}): Promise { const startTime = Date.now(); - + try { const result = await this.retrievalService.getInvoices(userId, options); this.healthService.recordInvoiceRetrieval(Date.now() - startTime); @@ -57,7 +57,7 @@ export class InvoicesOrchestratorService { */ async getInvoiceById(userId: string, invoiceId: number): Promise { const startTime = Date.now(); - + try { const result = await this.retrievalService.getInvoiceById(userId, invoiceId); this.healthService.recordInvoiceRetrieval(Date.now() - startTime); @@ -77,7 +77,7 @@ export class InvoicesOrchestratorService { options: PaginationOptions = {} ): Promise { const startTime = Date.now(); - + try { const result = await this.retrievalService.getInvoicesByStatus(userId, status, options); this.healthService.recordInvoiceRetrieval(Date.now() - startTime); @@ -112,14 +112,20 @@ export class InvoicesOrchestratorService { /** * Get cancelled invoices for a user */ - async getCancelledInvoices(userId: string, options: PaginationOptions = {}): Promise { + async getCancelledInvoices( + userId: string, + options: PaginationOptions = {} + ): Promise { return this.retrievalService.getCancelledInvoices(userId, options); } /** * Get invoices in collections for a user */ - async getCollectionsInvoices(userId: string, options: PaginationOptions = {}): Promise { + async getCollectionsInvoices( + userId: string, + options: PaginationOptions = {} + ): Promise { return this.retrievalService.getCollectionsInvoices(userId, options); } @@ -127,11 +133,6 @@ export class InvoicesOrchestratorService { // INVOICE OPERATIONS METHODS // ========================================== - - - - - // ========================================== // UTILITY METHODS // ========================================== diff --git a/apps/bff/src/modules/invoices/validators/invoice-validator.service.ts b/apps/bff/src/modules/invoices/validators/invoice-validator.service.ts index a520d646..e2847d0f 100644 --- a/apps/bff/src/modules/invoices/validators/invoice-validator.service.ts +++ b/apps/bff/src/modules/invoices/validators/invoice-validator.service.ts @@ -1,9 +1,9 @@ import { Injectable, BadRequestException } from "@nestjs/common"; -import type { - GetInvoicesOptions, +import type { + GetInvoicesOptions, InvoiceValidationResult, InvoiceStatus, - PaginationOptions + PaginationOptions, } from "../types/invoice-service.types"; /** @@ -12,7 +12,11 @@ import type { @Injectable() export class InvoiceValidatorService { private readonly validStatuses: readonly InvoiceStatus[] = [ - "Paid", "Unpaid", "Cancelled", "Overdue", "Collections" + "Paid", + "Unpaid", + "Cancelled", + "Overdue", + "Collections", ] as const; private readonly maxLimit = 100; @@ -148,7 +152,7 @@ export class InvoiceValidatorService { */ sanitizePaginationOptions(options: PaginationOptions): Required { const { page = 1, limit = 10 } = options; - + return { page: Math.max(1, Math.floor(page)), limit: Math.max(this.minLimit, Math.min(this.maxLimit, Math.floor(limit))), diff --git a/apps/bff/src/modules/orders/queue/provisioning.processor.ts b/apps/bff/src/modules/orders/queue/provisioning.processor.ts index a33ca288..afffa845 100644 --- a/apps/bff/src/modules/orders/queue/provisioning.processor.ts +++ b/apps/bff/src/modules/orders/queue/provisioning.processor.ts @@ -35,9 +35,13 @@ export class ProvisioningProcessor extends WorkerHost { // Guard: Only process if Salesforce Order is currently 'Activating' const fields = getSalesforceFieldMap(); const order = await this.salesforceService.getOrder(sfOrderId); - const status = (order?.[fields.order.activationStatus] as string) || ""; + const status = order + ? ((Reflect.get(order, fields.order.activationStatus) as string | undefined) ?? "") + : ""; const lastErrorCodeField = fields.order.lastErrorCode; - const lastErrorCode = lastErrorCodeField ? (order?.[lastErrorCodeField] as string) || "" : ""; + const lastErrorCode = lastErrorCodeField + ? ((order ? (Reflect.get(order, lastErrorCodeField) as string | undefined) : undefined) ?? "") + : ""; if (status !== "Activating") { this.logger.log("Skipping provisioning job: Order not in Activating state", { sfOrderId, diff --git a/apps/bff/src/modules/orders/services/order-builder.service.ts b/apps/bff/src/modules/orders/services/order-builder.service.ts index 386361f0..926bf0f9 100644 --- a/apps/bff/src/modules/orders/services/order-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-builder.service.ts @@ -5,6 +5,15 @@ import { getSalesforceFieldMap } from "@bff/core/config/field-map"; import { UsersService } from "@bff/modules/users/users.service"; const fieldMap = getSalesforceFieldMap(); +type OrderBuilderFieldKey = + | "orderType" + | "activationType" + | "activationScheduledAt" + | "activationStatus" + | "accessMode" + | "simType" + | "eid" + | "addressChanged"; function assignIfString(target: Record, key: string, value: unknown): void { if (typeof value === "string" && value.trim().length > 0) { @@ -12,16 +21,28 @@ function assignIfString(target: Record, key: string, value: unk } } -function orderField(key: keyof typeof fieldMap.order): string { - return fieldMap.order[key]; +function orderField(key: OrderBuilderFieldKey): string { + const fieldName = fieldMap.order[key]; + if (typeof fieldName !== "string") { + throw new Error(`Missing Salesforce order field mapping for key ${String(key)}`); + } + return fieldName; } function mnpField(key: keyof typeof fieldMap.order.mnp): string { - return fieldMap.order.mnp[key]; + const fieldName = fieldMap.order.mnp[key]; + if (typeof fieldName !== "string") { + throw new Error(`Missing Salesforce order MNP field mapping for key ${String(key)}`); + } + return fieldName; } function billingField(key: keyof typeof fieldMap.order.billing): string { - return fieldMap.order.billing[key]; + const fieldName = fieldMap.order.billing[key]; + if (typeof fieldName !== "string") { + throw new Error(`Missing Salesforce order billing field mapping for key ${String(key)}`); + } + return fieldName; } @Injectable() diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts index 383f37eb..146a4766 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts @@ -8,6 +8,7 @@ import type { SalesforceOrderRecord } from "@customer-portal/domain"; import { getSalesforceFieldMap } from "@bff/core/config/field-map"; const fieldMap = getSalesforceFieldMap(); +type OrderStringFieldKey = "activationStatus"; export interface OrderFulfillmentValidationResult { sfOrder: SalesforceOrderRecord; @@ -47,7 +48,7 @@ export class OrderFulfillmentValidator { const sfOrder = await this.validateSalesforceOrder(sfOrderId); // 2. Check if already provisioned (idempotency) - const rawWhmcs = (sfOrder as Record)[fieldMap.order.whmcsOrderId]; + const rawWhmcs = Reflect.get(sfOrder, fieldMap.order.whmcsOrderId) as unknown; const existingWhmcsOrderId = typeof rawWhmcs === "string" ? rawWhmcs : undefined; if (existingWhmcsOrderId) { this.logger.log("Order already provisioned", { @@ -157,9 +158,12 @@ export class OrderFulfillmentValidator { function pickOrderString( order: SalesforceOrderRecord, - key: keyof typeof fieldMap.order + key: OrderStringFieldKey ): string | undefined { - const field = fieldMap.order[key] as keyof SalesforceOrderRecord; - const raw = order[field]; + const field = fieldMap.order[key]; + if (typeof field !== "string") { + return undefined; + } + const raw = Reflect.get(order, field) as unknown; return typeof raw === "string" ? raw : undefined; } diff --git a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts index 30bd77c1..a3ea911b 100644 --- a/apps/bff/src/modules/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-orchestrator.service.ts @@ -23,16 +23,22 @@ import { assertSalesforceId, buildInClause } from "@bff/integrations/salesforce/ import { getErrorMessage } from "@bff/core/utils/error.util"; const fieldMap = getSalesforceFieldMap(); +type OrderFieldKey = + | "orderType" + | "activationType" + | "activationStatus" + | "activationScheduledAt" + | "whmcsOrderId"; type OrderDetailsResponse = z.infer; type OrderSummaryResponse = z.infer; -function getOrderStringField( - order: SalesforceOrderRecord, - key: keyof typeof fieldMap.order -): string | undefined { - const fieldName = fieldMap.order[key] as keyof SalesforceOrderRecord; - const raw = order[fieldName]; +function getOrderStringField(order: SalesforceOrderRecord, key: OrderFieldKey): string | undefined { + const fieldName = fieldMap.order[key]; + if (typeof fieldName !== "string") { + return undefined; + } + const raw = Reflect.get(order, fieldName) as unknown; return typeof raw === "string" ? raw : undefined; } @@ -53,8 +59,8 @@ function mapOrderItemRecord(record: SalesforceOrderItemRecord): ParsedOrderItemD id: record.Id ?? "", orderId: record.OrderId ?? "", quantity: record.Quantity ?? 0, - unitPrice: typeof record.UnitPrice === "number" ? record.UnitPrice : undefined, - totalPrice: typeof record.TotalPrice === "number" ? record.TotalPrice : undefined, + unitPrice: coerceNumber(record.UnitPrice), + totalPrice: coerceNumber(record.TotalPrice), billingCycle: typeof record.Billing_Cycle__c === "string" ? record.Billing_Cycle__c : undefined, product: { id: product?.Id, @@ -71,13 +77,10 @@ function mapOrderItemRecord(record: SalesforceOrderItemRecord): ParsedOrderItemD function toOrderItemSummary(details: ParsedOrderItemDetails): OrderItemSummary { return { - orderId: details.orderId, - product: { - name: details.product.name, - sku: details.product.sku, - itemClass: details.product.itemClass, - }, quantity: details.quantity, + name: details.product.name, + sku: details.product.sku, + itemClass: details.product.itemClass, unitPrice: details.unitPrice, totalPrice: details.totalPrice, billingCycle: details.billingCycle, @@ -247,8 +250,8 @@ export class OrderOrchestrator { id: detail.id, orderId: detail.orderId, quantity: detail.quantity, - unitPrice: detail.unitPrice, - totalPrice: detail.totalPrice, + unitPrice: detail.unitPrice ?? 0, + totalPrice: detail.totalPrice ?? 0, billingCycle: detail.billingCycle, product: { id: detail.product.id, @@ -360,3 +363,11 @@ export class OrderOrchestrator { } } } +const coerceNumber = (value: unknown): number | undefined => { + if (typeof value === "number") return value; + if (typeof value === "string") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +}; diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index d6403bad..13c6b7a0 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -126,9 +126,7 @@ export class OrderValidator { const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId }); const existing = products?.products?.product || []; const hasInternet = existing.some(product => - (product.groupname || "") - .toLowerCase() - .includes("internet") + (product.groupname || "").toLowerCase().includes("internet") ); if (hasInternet) { throw new BadRequestException("An Internet service already exists for this account"); diff --git a/apps/bff/src/modules/subscriptions/sim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management.service.ts index 4bb9ab75..b63ab6b5 100644 --- a/apps/bff/src/modules/subscriptions/sim-management.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management.service.ts @@ -14,7 +14,6 @@ import type { SimFeaturesUpdateRequest, } from "./sim-management/types/sim-requests.types"; - @Injectable() export class SimManagementService { constructor( diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts index 2df52c51..99bbd0b9 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts @@ -4,10 +4,7 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f import { SimValidationService } from "./sim-validation.service"; import { SimNotificationService } from "./sim-notification.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import type { - SimPlanChangeRequest, - SimFeaturesUpdateRequest -} from "../types/sim-requests.types"; +import type { SimPlanChangeRequest, SimFeaturesUpdateRequest } from "../types/sim-requests.types"; @Injectable() export class SimPlanService { diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts index 0fe1c3bd..69aaae22 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts @@ -25,7 +25,7 @@ export class SimTopUpService { */ async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise { let account: string = ""; - + try { const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); account = validation.account; @@ -52,7 +52,7 @@ export class SimTopUpService { if (!mapping?.whmcsClientId) { throw new BadRequestException("WHMCS client mapping not found"); } - + const whmcsClientId = mapping.whmcsClientId; this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, { diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts index bf045256..6d50870c 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts @@ -4,10 +4,7 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f import { SimValidationService } from "./sim-validation.service"; import { SimUsageStoreService } from "../../sim-usage-store.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import type { - SimUsage, - SimTopUpHistory, -} from "@bff/integrations/freebit/interfaces/freebit.types"; +import type { SimUsage, SimTopUpHistory } from "@bff/integrations/freebit/interfaces/freebit.types"; import type { SimTopUpHistoryRequest } from "../types/sim-requests.types"; import { BadRequestException } from "@nestjs/common"; diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts index 0aa70fd6..1686d57d 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts @@ -18,17 +18,12 @@ import { SimValidationService } from "./services/sim-validation.service"; import { SimNotificationService } from "./services/sim-notification.service"; @Module({ - imports: [ - FreebitModule, - WhmcsModule, - MappingsModule, - EmailModule, - ], + imports: [FreebitModule, WhmcsModule, MappingsModule, EmailModule], providers: [ // Core services that the SIM services depend on SimUsageStoreService, SubscriptionsService, - + // SIM management services SimValidationService, SimNotificationService, diff --git a/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts b/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts index 24ba9498..684f292c 100644 --- a/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts @@ -91,11 +91,11 @@ export class SimOrderActivationService { contractLine: "5G", shipDate: req.activationType === "Scheduled" ? req.scheduledAt : undefined, mnp: req.mnp - ? { + ? { reserveNumber: req.mnp.reserveNumber || "", - reserveExpireDate: req.mnp.reserveExpireDate || "" + reserveExpireDate: req.mnp.reserveExpireDate || "", } - : undefined + : undefined, }); } else { this.logger.warn("Physical SIM activation path is not implemented; skipping Freebit call", { diff --git a/apps/bff/src/modules/subscriptions/subscriptions.module.ts b/apps/bff/src/modules/subscriptions/subscriptions.module.ts index 0cd94a43..125f48d6 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.module.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.module.ts @@ -12,13 +12,7 @@ import { EmailModule } from "@bff/infra/email/email.module"; import { SimManagementModule } from "./sim-management/sim-management.module"; @Module({ - imports: [ - WhmcsModule, - MappingsModule, - FreebitModule, - EmailModule, - SimManagementModule - ], + imports: [WhmcsModule, MappingsModule, FreebitModule, EmailModule, SimManagementModule], controllers: [SubscriptionsController, SimOrdersController], providers: [ SubscriptionsService, diff --git a/apps/bff/src/modules/subscriptions/subscriptions.service.ts b/apps/bff/src/modules/subscriptions/subscriptions.service.ts index c04584ce..4b750f2d 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.service.ts @@ -6,9 +6,7 @@ import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { Logger } from "nestjs-pino"; import { z } from "zod"; -import { - subscriptionSchema, -} from "@customer-portal/domain/validation/shared/entities"; +import { subscriptionSchema } from "@customer-portal/domain/validation/shared/entities"; import type { WhmcsProduct, WhmcsProductsResponse, diff --git a/apps/bff/src/modules/users/users.service.ts b/apps/bff/src/modules/users/users.service.ts index 00bcd011..809aaeda 100644 --- a/apps/bff/src/modules/users/users.service.ts +++ b/apps/bff/src/modules/users/users.service.ts @@ -53,7 +53,6 @@ export class UsersService { }; } - private validateEmail(email: string): string { return normalizeAndValidateEmail(email); } @@ -300,10 +299,15 @@ export class UsersService { ).length; recentSubscriptions = subscriptions .filter((sub: Subscription) => sub.status === "Active") - .sort( - (a: Subscription, b: Subscription) => - new Date(b.registrationDate).getTime() - new Date(a.registrationDate).getTime() - ) + .sort((a: Subscription, b: Subscription) => { + const aTime = a.registrationDate + ? new Date(a.registrationDate).getTime() + : Number.NEGATIVE_INFINITY; + const bTime = b.registrationDate + ? new Date(b.registrationDate).getTime() + : Number.NEGATIVE_INFINITY; + return bTime - aTime; + }) .slice(0, 3) .map((sub: Subscription) => ({ id: sub.id.toString(), @@ -343,10 +347,11 @@ export class UsersService { .filter( (inv: Invoice) => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate ) - .sort( - (a: Invoice, b: Invoice) => - new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime() - ); + .sort((a: Invoice, b: Invoice) => { + const aTime = a.dueDate ? new Date(a.dueDate).getTime() : Number.POSITIVE_INFINITY; + const bTime = b.dueDate ? new Date(b.dueDate).getTime() : Number.POSITIVE_INFINITY; + return aTime - bTime; + }); if (upcomingInvoices.length > 0) { const invoice = upcomingInvoices[0]; @@ -360,10 +365,11 @@ export class UsersService { // Recent invoices for activity recentInvoices = invoices - .sort( - (a: Invoice, b: Invoice) => - new Date(b.issuedAt || "").getTime() - new Date(a.issuedAt || "").getTime() - ) + .sort((a: Invoice, b: Invoice) => { + const aTime = a.issuedAt ? new Date(a.issuedAt).getTime() : Number.NEGATIVE_INFINITY; + const bTime = b.issuedAt ? new Date(b.issuedAt).getTime() : Number.NEGATIVE_INFINITY; + return bTime - aTime; + }) .slice(0, 5) .map((inv: Invoice) => ({ id: inv.id.toString(), diff --git a/apps/portal/scripts/stubs/core-api.ts b/apps/portal/scripts/stubs/core-api.ts index b1a5efd9..df6d3690 100644 --- a/apps/portal/scripts/stubs/core-api.ts +++ b/apps/portal/scripts/stubs/core-api.ts @@ -7,10 +7,10 @@ export const apiClient = { postCalls.push([path, options]); return { data: null } as const; }, - GET: async () => ({ data: null } as const), - PUT: async () => ({ data: null } as const), - PATCH: async () => ({ data: null } as const), - DELETE: async () => ({ data: null } as const), + GET: async () => ({ data: null }) as const, + PUT: async () => ({ data: null }) as const, + PATCH: async () => ({ data: null }) as const, + DELETE: async () => ({ data: null }) as const, }; export const configureApiClientAuth = () => undefined; diff --git a/apps/portal/scripts/test-request-password-reset.cjs b/apps/portal/scripts/test-request-password-reset.cjs index cc35a88c..9f2e75b3 100755 --- a/apps/portal/scripts/test-request-password-reset.cjs +++ b/apps/portal/scripts/test-request-password-reset.cjs @@ -93,7 +93,9 @@ const { useAuthStore } = require("../src/features/auth/services/auth.store.ts"); const [endpoint, options] = coreApiStub.postCalls[0]; if (endpoint !== "/auth/request-password-reset") { - throw new Error(`Expected endpoint \"/auth/request-password-reset\" but received \"${endpoint}\"`); + throw new Error( + `Expected endpoint \"/auth/request-password-reset\" but received \"${endpoint}\"` + ); } if (!options || typeof options !== "object") { diff --git a/apps/portal/src/app/(authenticated)/account/loading.tsx b/apps/portal/src/app/(authenticated)/account/loading.tsx index 2b25ba28..2e925894 100644 --- a/apps/portal/src/app/(authenticated)/account/loading.tsx +++ b/apps/portal/src/app/(authenticated)/account/loading.tsx @@ -39,4 +39,3 @@ export default function AccountLoading() { ); } - diff --git a/apps/portal/src/app/(authenticated)/catalog/loading.tsx b/apps/portal/src/app/(authenticated)/catalog/loading.tsx index a41cf550..388d7699 100644 --- a/apps/portal/src/app/(authenticated)/catalog/loading.tsx +++ b/apps/portal/src/app/(authenticated)/catalog/loading.tsx @@ -18,4 +18,3 @@ export default function CatalogLoading() { ); } - diff --git a/apps/portal/src/app/(authenticated)/checkout/loading.tsx b/apps/portal/src/app/(authenticated)/checkout/loading.tsx index ae8a9dee..2a272cfb 100644 --- a/apps/portal/src/app/(authenticated)/checkout/loading.tsx +++ b/apps/portal/src/app/(authenticated)/checkout/loading.tsx @@ -21,5 +21,3 @@ export default function CheckoutLoading() { ); } - - diff --git a/apps/portal/src/app/(authenticated)/dashboard/loading.tsx b/apps/portal/src/app/(authenticated)/dashboard/loading.tsx index 75006b66..72cc930e 100644 --- a/apps/portal/src/app/(authenticated)/dashboard/loading.tsx +++ b/apps/portal/src/app/(authenticated)/dashboard/loading.tsx @@ -26,4 +26,3 @@ export default function DashboardLoading() { ); } - diff --git a/apps/portal/src/app/(authenticated)/layout.tsx b/apps/portal/src/app/(authenticated)/layout.tsx index 15417319..07c4e640 100644 --- a/apps/portal/src/app/(authenticated)/layout.tsx +++ b/apps/portal/src/app/(authenticated)/layout.tsx @@ -4,4 +4,3 @@ import { AppShell } from "@/components/organisms"; export default function PortalLayout({ children }: { children: ReactNode }) { return {children}; } - diff --git a/apps/portal/src/app/(authenticated)/support/cases/loading.tsx b/apps/portal/src/app/(authenticated)/support/cases/loading.tsx index b0053fd3..3c933597 100644 --- a/apps/portal/src/app/(authenticated)/support/cases/loading.tsx +++ b/apps/portal/src/app/(authenticated)/support/cases/loading.tsx @@ -14,5 +14,3 @@ export default function SupportCasesLoading() { ); } - - diff --git a/apps/portal/src/app/(authenticated)/support/new/loading.tsx b/apps/portal/src/app/(authenticated)/support/new/loading.tsx index 610ad49f..6da32d5c 100644 --- a/apps/portal/src/app/(authenticated)/support/new/loading.tsx +++ b/apps/portal/src/app/(authenticated)/support/new/loading.tsx @@ -27,5 +27,3 @@ export default function NewSupportLoading() { ); } - - diff --git a/apps/portal/src/app/(public)/auth/loading.tsx b/apps/portal/src/app/(public)/auth/loading.tsx index 4fffdb60..7def25bd 100644 --- a/apps/portal/src/app/(public)/auth/loading.tsx +++ b/apps/portal/src/app/(public)/auth/loading.tsx @@ -12,5 +12,3 @@ export default function AuthSegmentLoading() { ); } - - diff --git a/apps/portal/src/components/atoms/button.tsx b/apps/portal/src/components/atoms/button.tsx index caaaa696..cb3bcde1 100644 --- a/apps/portal/src/components/atoms/button.tsx +++ b/apps/portal/src/components/atoms/button.tsx @@ -78,15 +78,24 @@ const Button = forwardRef((p {loading ? (
- ) : leftIcon} - {loading ? loadingText ?? children : children} + ) : ( + leftIcon + )} + {loading ? (loadingText ?? children) : children} {!loading && rightIcon ? {rightIcon} : null} ); } - const { className, variant, size, as: _as, disabled, ...buttonProps } = rest as ButtonAsButtonProps; + const { + className, + variant, + size, + as: _as, + disabled, + ...buttonProps + } = rest as ButtonAsButtonProps; return ( diff --git a/apps/portal/src/components/atoms/checkbox.tsx b/apps/portal/src/components/atoms/checkbox.tsx index 506f08d5..189f9f2a 100644 --- a/apps/portal/src/components/atoms/checkbox.tsx +++ b/apps/portal/src/components/atoms/checkbox.tsx @@ -6,7 +6,7 @@ import React from "react"; import { cn } from "@/lib/utils"; -export interface CheckboxProps extends Omit, 'type'> { +export interface CheckboxProps extends Omit, "type"> { label?: string; error?: string; helperText?: string; @@ -42,12 +42,8 @@ export const Checkbox = React.forwardRef( )}
- {helperText && !error && ( -

{helperText}

- )} - {error && ( -

{error}

- )} + {helperText && !error &&

{helperText}

} + {error &&

{error}

} ); } diff --git a/apps/portal/src/components/atoms/input.tsx b/apps/portal/src/components/atoms/input.tsx index 1c3d32ce..4a238876 100644 --- a/apps/portal/src/components/atoms/input.tsx +++ b/apps/portal/src/components/atoms/input.tsx @@ -15,8 +15,7 @@ const Input = forwardRef( type={type} className={cn( "flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", - isInvalid && - "border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2", + isInvalid && "border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2", className )} aria-invalid={isInvalid || undefined} diff --git a/apps/portal/src/components/atoms/status-pill.tsx b/apps/portal/src/components/atoms/status-pill.tsx index 0d173ba2..d4e77369 100644 --- a/apps/portal/src/components/atoms/status-pill.tsx +++ b/apps/portal/src/components/atoms/status-pill.tsx @@ -10,23 +10,23 @@ export type StatusPillProps = HTMLAttributes & { export const StatusPill = forwardRef( ({ label, variant = "neutral", size = "md", icon, className, ...rest }, ref) => { - const tone = - variant === "success" - ? "bg-green-50 text-green-700 ring-green-600/20" - : variant === "warning" - ? "bg-amber-50 text-amber-700 ring-amber-600/20" - : variant === "info" - ? "bg-blue-50 text-blue-700 ring-blue-600/20" - : variant === "error" - ? "bg-red-50 text-red-700 ring-red-600/20" - : "bg-gray-50 text-gray-700 ring-gray-400/30"; + const tone = + variant === "success" + ? "bg-green-50 text-green-700 ring-green-600/20" + : variant === "warning" + ? "bg-amber-50 text-amber-700 ring-amber-600/20" + : variant === "info" + ? "bg-blue-50 text-blue-700 ring-blue-600/20" + : variant === "error" + ? "bg-red-50 text-red-700 ring-red-600/20" + : "bg-gray-50 text-gray-700 ring-gray-400/30"; - const sizing = - size === "sm" - ? "px-2 py-0.5 text-xs" - : size === "lg" - ? "px-4 py-1.5 text-sm" - : "px-3 py-1 text-xs"; + const sizing = + size === "sm" + ? "px-2 py-0.5 text-xs" + : size === "lg" + ? "px-4 py-1.5 text-sm" + : "px-3 py-1 text-xs"; return ( ( )} {children ? ( - isValidElement(children) - ? cloneElement(children, { - id, - "aria-invalid": error ? "true" : undefined, - "aria-describedby": cn(errorId, helperTextId) || undefined, - } as Record) - : children + isValidElement(children) ? ( + cloneElement(children, { + id, + "aria-invalid": error ? "true" : undefined, + "aria-describedby": cn(errorId, helperTextId) || undefined, + } as Record) + ) : ( + children + ) ) : ( ( aria-invalid={error ? "true" : undefined} aria-describedby={cn(errorId, helperTextId) || undefined} className={cn( - error && - "border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2", + error && "border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2", inputClassName, inputProps.className )} diff --git a/apps/portal/src/components/molecules/RouteLoading.tsx b/apps/portal/src/components/molecules/RouteLoading.tsx index 28e05aad..ae22a4cc 100644 --- a/apps/portal/src/components/molecules/RouteLoading.tsx +++ b/apps/portal/src/components/molecules/RouteLoading.tsx @@ -10,17 +10,17 @@ interface RouteLoadingProps { } // Shared route-level loading wrapper used by segment loading.tsx files -export function RouteLoading({ icon, title, description, mode = "skeleton", children }: RouteLoadingProps) { +export function RouteLoading({ + icon, + title, + description, + mode = "skeleton", + children, +}: RouteLoadingProps) { // Always use PageLayout with loading state for consistent skeleton loading return ( - + {children} ); } - diff --git a/apps/portal/src/components/molecules/SectionHeader/SectionHeader.tsx b/apps/portal/src/components/molecules/SectionHeader/SectionHeader.tsx index d4f46624..3eedc56d 100644 --- a/apps/portal/src/components/molecules/SectionHeader/SectionHeader.tsx +++ b/apps/portal/src/components/molecules/SectionHeader/SectionHeader.tsx @@ -10,7 +10,7 @@ interface SectionHeaderProps { export function SectionHeader({ title, children, className }: SectionHeaderProps) { return ( -
+

{title}

{children}
@@ -18,5 +18,3 @@ export function SectionHeader({ title, children, className }: SectionHeaderProps } export type { SectionHeaderProps }; - - diff --git a/apps/portal/src/components/molecules/error-boundary.tsx b/apps/portal/src/components/molecules/error-boundary.tsx index 496f23e9..6fa1c391 100644 --- a/apps/portal/src/components/molecules/error-boundary.tsx +++ b/apps/portal/src/components/molecules/error-boundary.tsx @@ -29,13 +29,13 @@ export class ErrorBoundary extends Component typeof x === "string")) { setExpandedItems(prev => { - const next = parsed as string[]; + const next = parsed; if (next.length === prev.length && next.every(v => prev.includes(v))) return prev; return next; }); diff --git a/apps/portal/src/components/organisms/AppShell/Header.tsx b/apps/portal/src/components/organisms/AppShell/Header.tsx index 26953a86..9373ffb9 100644 --- a/apps/portal/src/components/organisms/AppShell/Header.tsx +++ b/apps/portal/src/components/organisms/AppShell/Header.tsx @@ -12,7 +12,9 @@ interface HeaderProps { export const Header = memo(function Header({ onMenuClick, user, profileReady }: HeaderProps) { const displayName = profileReady - ? [user?.firstName, user?.lastName].filter(Boolean).join(" ") || user?.email?.split("@")[0] || "Account" + ? [user?.firstName, user?.lastName].filter(Boolean).join(" ") || + user?.email?.split("@")[0] || + "Account" : user?.email?.split("@")[0] || "Account"; return ( diff --git a/apps/portal/src/components/organisms/AppShell/Sidebar.tsx b/apps/portal/src/components/organisms/AppShell/Sidebar.tsx index 95003781..5adc3ec9 100644 --- a/apps/portal/src/components/organisms/AppShell/Sidebar.tsx +++ b/apps/portal/src/components/organisms/AppShell/Sidebar.tsx @@ -29,7 +29,9 @@ export const Sidebar = memo(function Sidebar({
- Assist Solutions + + Assist Solutions +

Customer Portal

@@ -221,4 +223,3 @@ const NavigationItem = memo(function NavigationItem({ ); }); - diff --git a/apps/portal/src/components/organisms/AppShell/navigation.ts b/apps/portal/src/components/organisms/AppShell/navigation.ts index a7f020f9..6b18cbb1 100644 --- a/apps/portal/src/components/organisms/AppShell/navigation.ts +++ b/apps/portal/src/components/organisms/AppShell/navigation.ts @@ -87,4 +87,3 @@ export function truncate(text: string, max: number): string { if (text.length <= max) return text; return text.slice(0, Math.max(0, max - 1)) + "…"; } - diff --git a/apps/portal/src/components/templates/AuthLayout/index.ts b/apps/portal/src/components/templates/AuthLayout/index.ts new file mode 100644 index 00000000..37cc8a5e --- /dev/null +++ b/apps/portal/src/components/templates/AuthLayout/index.ts @@ -0,0 +1,2 @@ +export { AuthLayout } from "./AuthLayout"; +export type { AuthLayoutProps } from "./AuthLayout"; diff --git a/apps/portal/src/components/templates/PageLayout/index.ts b/apps/portal/src/components/templates/PageLayout/index.ts new file mode 100644 index 00000000..17045872 --- /dev/null +++ b/apps/portal/src/components/templates/PageLayout/index.ts @@ -0,0 +1,2 @@ +export { PageLayout } from "./PageLayout"; +export type { BreadcrumbItem } from "./PageLayout"; diff --git a/apps/portal/src/components/templates/index.ts b/apps/portal/src/components/templates/index.ts index 1d4da43a..b5b5d48a 100644 --- a/apps/portal/src/components/templates/index.ts +++ b/apps/portal/src/components/templates/index.ts @@ -8,4 +8,3 @@ export type { AuthLayoutProps } from "./AuthLayout/AuthLayout"; export { PageLayout } from "./PageLayout/PageLayout"; export type { BreadcrumbItem } from "./PageLayout/PageLayout"; - diff --git a/apps/portal/src/features/account/hooks/useAddressEdit.ts b/apps/portal/src/features/account/hooks/useAddressEdit.ts index c9142fab..e696b13e 100644 --- a/apps/portal/src/features/account/hooks/useAddressEdit.ts +++ b/apps/portal/src/features/account/hooks/useAddressEdit.ts @@ -2,10 +2,10 @@ import { useCallback } from "react"; import { accountService } from "@/features/account/services/account.service"; -import { - addressFormSchema, +import { + addressFormSchema, addressFormToRequest, - type AddressFormData + type AddressFormData, } from "@customer-portal/domain"; import { useZodForm } from "@customer-portal/validation"; diff --git a/apps/portal/src/features/account/hooks/useProfileEdit.ts b/apps/portal/src/features/account/hooks/useProfileEdit.ts index 7e56c12d..aa8539e8 100644 --- a/apps/portal/src/features/account/hooks/useProfileEdit.ts +++ b/apps/portal/src/features/account/hooks/useProfileEdit.ts @@ -3,10 +3,10 @@ import { useCallback } from "react"; import { accountService } from "@/features/account/services/account.service"; import { useAuthStore } from "@/features/auth/services/auth.store"; -import { - profileEditFormSchema, +import { + profileEditFormSchema, profileFormToRequest, - type ProfileEditFormData + type ProfileEditFormData, } from "@customer-portal/domain"; import { useZodForm } from "@customer-portal/validation"; @@ -15,7 +15,7 @@ export function useProfileEdit(initial: ProfileEditFormData) { try { const requestData = profileFormToRequest(formData); const updated = await accountService.updateProfile(requestData); - + useAuthStore.setState(state => ({ ...state, user: state.user ? { ...state.user, ...updated } : state.user, diff --git a/apps/portal/src/features/account/services/account.service.ts b/apps/portal/src/features/account/services/account.service.ts index ea0794cf..2a635b6b 100644 --- a/apps/portal/src/features/account/services/account.service.ts +++ b/apps/portal/src/features/account/services/account.service.ts @@ -9,22 +9,22 @@ type ProfileUpdateInput = { export const accountService = { async getProfile() { - const response = await apiClient.GET('/api/me'); + const response = await apiClient.GET("/api/me"); return getNullableData(response); }, async updateProfile(update: ProfileUpdateInput) { - const response = await apiClient.PATCH('/api/me', { body: update }); + const response = await apiClient.PATCH("/api/me", { body: update }); return getDataOrThrow(response, "Failed to update profile"); }, async getAddress() { - const response = await apiClient.GET('/api/me/address'); + const response = await apiClient.GET
("/api/me/address"); return getNullableData
(response); }, async updateAddress(address: Address) { - const response = await apiClient.PATCH('/api/me/address', { body: address }); + const response = await apiClient.PATCH
("/api/me/address", { body: address }); return getDataOrThrow
(response, "Failed to update address"); }, }; diff --git a/apps/portal/src/features/account/views/ProfileContainer.tsx b/apps/portal/src/features/account/views/ProfileContainer.tsx index 6d8ca5da..66c59ad0 100644 --- a/apps/portal/src/features/account/views/ProfileContainer.tsx +++ b/apps/portal/src/features/account/views/ProfileContainer.tsx @@ -3,7 +3,13 @@ import { useEffect, useState } from "react"; import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; -import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon, UserIcon } from "@heroicons/react/24/outline"; +import { + MapPinIcon, + PencilIcon, + CheckIcon, + XMarkIcon, + UserIcon, +} from "@heroicons/react/24/outline"; import { useAuthStore } from "@/features/auth/services/auth.store"; import { accountService } from "@/features/account/services/account.service"; import { useProfileEdit } from "@/features/account/hooks/useProfileEdit"; @@ -245,11 +251,14 @@ export default function ProfileContainer() { + + } + > +
+ {paymentMethodsData.paymentMethods.map(paymentMethod => ( + ))}
- - ) : paymentMethodsData && paymentMethodsData.paymentMethods.length > 0 ? ( - - - - } - > -
- {paymentMethodsData.paymentMethods.map(paymentMethod => ( - - ))} -
-
- ) : ( - - {(!hasCheckedAuth && !paymentMethodsData) ? ( - - <> - - ) : ( - <> - } - title="No Payment Methods" - description="Open the billing portal to add a card." - action={{ - label: isLoading ? "Opening..." : "Manage Cards", - onClick: () => void openPaymentMethods(), - }} - /> -

Opens in a new tab for security

- - )} -
- )} - + ) : ( + + {!hasCheckedAuth && !paymentMethodsData ? ( + + <> + + ) : ( + <> + } + title="No Payment Methods" + description="Open the billing portal to add a card." + action={{ + label: isLoading ? "Opening..." : "Manage Cards", + onClick: () => void openPaymentMethods(), + }} + /> +

+ Opens in a new tab for security +

+ + )} +
+ )} + -
-
-
-
- -
-
-

Secure & Encrypted

-

- All payment information is securely encrypted and protected with industry-standard - security. -

+
+
+
+
+ +
+
+

Secure & Encrypted

+

+ All payment information is securely encrypted and protected with + industry-standard security. +

+
-
-
-

Supported Payment Methods

-
    -
  • • Credit Cards (Visa, MasterCard, American Express)
  • -
  • • Debit Cards
  • -
+
+

Supported Payment Methods

+
    +
  • • Credit Cards (Visa, MasterCard, American Express)
  • +
  • • Debit Cards
  • +
+
-
); diff --git a/apps/portal/src/features/catalog/components/base/AddonGroup.tsx b/apps/portal/src/features/catalog/components/base/AddonGroup.tsx index ea0f366f..29b2e9fd 100644 --- a/apps/portal/src/features/catalog/components/base/AddonGroup.tsx +++ b/apps/portal/src/features/catalog/components/base/AddonGroup.tsx @@ -5,7 +5,9 @@ import type { CatalogProductBase } from "@customer-portal/domain"; import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing"; interface AddonGroupProps { - addons: Array; + addons: Array< + CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean; raw?: unknown } + >; selectedAddonSkus: string[]; onAddonToggle: (skus: string[]) => void; showSkus?: boolean; @@ -23,7 +25,9 @@ type BundledAddonGroup = { }; function buildGroupedAddons( - addons: Array + addons: Array< + CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean; raw?: unknown } + > ): BundledAddonGroup[] { const groups: BundledAddonGroup[] = []; const processedSkus = new Set(); @@ -34,26 +38,31 @@ function buildGroupedAddons( if (processedSkus.has(addon.sku)) return; if (addon.isBundledAddon && addon.bundledAddonId) { - const partner = sorted.find(candidate => - candidate.raw && - typeof candidate.raw === 'object' && - 'Id' in candidate.raw && - candidate.raw.Id === addon.bundledAddonId + const partner = sorted.find( + candidate => + candidate.raw && + typeof candidate.raw === "object" && + "Id" in candidate.raw && + candidate.raw.Id === addon.bundledAddonId ); if (partner) { const monthlyAddon = addon.billingCycle === "Monthly" ? addon : partner; const activationAddon = addon.billingCycle === "Onetime" ? addon : partner; - const name = monthlyAddon.name.replace(/\s*(Monthly|Installation|Fee)\s*/gi, "").trim() || addon.name; + const name = + monthlyAddon.name.replace(/\s*(Monthly|Installation|Fee)\s*/gi, "").trim() || addon.name; groups.push({ id: `bundle-${addon.sku}-${partner.sku}`, name, description: `${name} bundle (installation included)`, - monthlyPrice: monthlyAddon.billingCycle === "Monthly" ? getMonthlyPrice(monthlyAddon) : undefined, + monthlyPrice: + monthlyAddon.billingCycle === "Monthly" ? getMonthlyPrice(monthlyAddon) : undefined, activationPrice: - activationAddon.billingCycle === "Onetime" ? getOneTimePrice(activationAddon) : undefined, + activationAddon.billingCycle === "Onetime" + ? getOneTimePrice(activationAddon) + : undefined, skus: [addon.sku, partner.sku], isBundled: true, displayOrder: addon.displayOrder ?? 0, diff --git a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx index d8397848..d70244a3 100644 --- a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx +++ b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx @@ -220,12 +220,7 @@ export function AddressConfirmation({
{error} -
diff --git a/apps/portal/src/features/catalog/components/base/AddressForm.tsx b/apps/portal/src/features/catalog/components/base/AddressForm.tsx index 315335dd..c3f42695 100644 --- a/apps/portal/src/features/catalog/components/base/AddressForm.tsx +++ b/apps/portal/src/features/catalog/components/base/AddressForm.tsx @@ -269,4 +269,4 @@ export function AddressForm({ )}
); -} \ No newline at end of file +} diff --git a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx index e416755f..78c01cc8 100644 --- a/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/EnhancedOrderSummary.tsx @@ -12,13 +12,10 @@ import { Button } from "@/components/atoms/button"; import { useRouter } from "next/navigation"; // Use consolidated domain types -import type { - OrderItemRequest, - OrderTotals as DomainOrderTotals -} from "@customer-portal/domain"; +import type { OrderItemRequest, OrderTotals as DomainOrderTotals } from "@customer-portal/domain"; // Enhanced OrderItem for UI - properly extends unified types instead of redefining everything -export interface OrderItem extends Omit { +export interface OrderItem extends Omit { id?: string; // Optional for UI purposes (OrderItemRequest.id is required) description?: string; } @@ -268,7 +265,14 @@ export function EnhancedOrderSummary({
{String(item.name)} - ¥{formatPrice(Number(item.billingCycle === "Monthly" ? (item.monthlyPrice || item.unitPrice || 0) : (item.oneTimePrice || item.unitPrice || 0)))} + ¥ + {formatPrice( + Number( + item.billingCycle === "Monthly" + ? item.monthlyPrice || item.unitPrice || 0 + : item.oneTimePrice || item.unitPrice || 0 + ) + )} {item.billingCycle === "Monthly" ? "/mo" : " one-time"}
diff --git a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx b/apps/portal/src/features/catalog/components/base/OrderSummary.tsx index f23c1cc6..8577b105 100644 --- a/apps/portal/src/features/catalog/components/base/OrderSummary.tsx +++ b/apps/portal/src/features/catalog/components/base/OrderSummary.tsx @@ -177,9 +177,7 @@ export function OrderSummary({
{plan.name} - - ¥{(plan.monthlyPrice ?? 0).toLocaleString()}/mo - + ¥{(plan.monthlyPrice ?? 0).toLocaleString()}/mo
{/* Show activation fees */} diff --git a/apps/portal/src/features/catalog/components/base/PaymentForm.tsx b/apps/portal/src/features/catalog/components/base/PaymentForm.tsx index 1e71db90..3f9851af 100644 --- a/apps/portal/src/features/catalog/components/base/PaymentForm.tsx +++ b/apps/portal/src/features/catalog/components/base/PaymentForm.tsx @@ -92,7 +92,7 @@ export function PaymentForm({ const isSelected = selectedMethod === methodId; const label = method.cardBrand ? `${method.cardBrand.toUpperCase()} ${method.lastFour ? `•••• ${method.lastFour}` : ""}`.trim() - : method.description ?? method.type; + : (method.description ?? method.type); return (
{method.expiryDate ? ( @@ -196,9 +198,7 @@ export function PaymentForm({ ) )} - {footerContent ? ( -
{footerContent}
- ) : null} + {footerContent ?
{footerContent}
: null}
); } diff --git a/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx b/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx index 9a751201..664bc8e5 100644 --- a/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx +++ b/apps/portal/src/features/catalog/components/internet/InstallationOptions.tsx @@ -2,7 +2,10 @@ import type { InternetInstallationCatalogItem } from "@customer-portal/domain"; import { getDisplayPrice } from "../../utils/pricing"; -import { inferInstallationTypeFromSku, type InstallationType } from "../../utils/inferInstallationType"; +import { + inferInstallationTypeFromSku, + type InstallationType, +} from "../../utils/inferInstallationType"; interface InstallationOptionsProps { installations: InternetInstallationCatalogItem[]; @@ -11,7 +14,10 @@ interface InstallationOptionsProps { showSkus?: boolean; } -function getCleanName(installation: InternetInstallationCatalogItem, inferredType: InstallationType): string { +function getCleanName( + installation: InternetInstallationCatalogItem, + inferredType: InstallationType +): string { const baseName = installation.name.replace(/^(NTT\s*)?Installation\s*Fee\s*/i, ""); switch (inferredType) { case "One-time": @@ -25,7 +31,10 @@ function getCleanName(installation: InternetInstallationCatalogItem, inferredTyp } } -function getCleanDescription(inferredType: InstallationType, description: string | undefined): string { +function getCleanDescription( + inferredType: InstallationType, + description: string | undefined +): string { const baseDescription = (description || "").replace(/^(NTT\s*)?Installation\s*Fee\s*/i, ""); switch (inferredType) { case "One-time": diff --git a/apps/portal/src/features/catalog/components/internet/InternetConfigureView.tsx b/apps/portal/src/features/catalog/components/internet/InternetConfigureView.tsx index 0c9f7847..7b9efde0 100644 --- a/apps/portal/src/features/catalog/components/internet/InternetConfigureView.tsx +++ b/apps/portal/src/features/catalog/components/internet/InternetConfigureView.tsx @@ -15,13 +15,7 @@ interface Props { onConfirm: () => void; } -export function InternetConfigureView({ - plan, - loading, - addons, - installations, - onConfirm, -}: Props) { +export function InternetConfigureView({ plan, loading, addons, installations, onConfirm }: Props) { return ( ); -} \ No newline at end of file +} diff --git a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx index 1d6fc2ba..b3534cd8 100644 --- a/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/internet/InternetPlanCard.tsx @@ -17,7 +17,12 @@ interface InternetPlanCardProps { disabledReason?: string; } -export function InternetPlanCard({ plan, installations, disabled, disabledReason }: InternetPlanCardProps) { +export function InternetPlanCard({ + plan, + installations, + disabled, + disabledReason, +}: InternetPlanCardProps) { const router = useRouter(); const tier = plan.internetPlanTier; const isGold = tier === "Gold"; @@ -32,9 +37,7 @@ export function InternetPlanCard({ plan, installations, disabled, disabledReason ) .filter(price => price > 0); - const minInstallationPrice = installationPrices.length - ? Math.min(...installationPrices) - : 0; + const minInstallationPrice = installationPrices.length ? Math.min(...installationPrices) : 0; const getBorderClass = () => { if (isGold) return "border-2 border-yellow-400 shadow-lg hover:shadow-xl"; @@ -83,13 +86,13 @@ export function InternetPlanCard({ plan, installations, disabled, disabledReason

{plan.name}

- {plan.catalogMetadata.tierDescription || plan.description} + {plan.catalogMetadata?.tierDescription || plan.description}

Your Plan Includes:

    - {plan.catalogMetadata.features && plan.catalogMetadata.features.length > 0 ? ( + {plan.catalogMetadata?.features && plan.catalogMetadata.features.length > 0 ? ( plan.catalogMetadata.features.map((feature, index) => (
  • @@ -100,7 +103,8 @@ export function InternetPlanCard({ plan, installations, disabled, disabledReason <>
  • 1 NTT Optical Fiber (Flet's - Hikari Next - {plan.internetOfferingType?.includes("Apartment") ? "Mansion" : "Home"}{" "} + Hikari Next -{" "} + {plan.internetOfferingType?.includes("Apartment") ? "Mansion" : "Home"}{" "} {plan.internetOfferingType?.includes("10G") ? "10Gbps" : plan.internetOfferingType?.includes("100M") diff --git a/apps/portal/src/features/catalog/components/internet/configure/hooks/useConfigureState.ts b/apps/portal/src/features/catalog/components/internet/configure/hooks/useConfigureState.ts index 2d3874d4..b5a70987 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/hooks/useConfigureState.ts +++ b/apps/portal/src/features/catalog/components/internet/configure/hooks/useConfigureState.ts @@ -6,8 +6,8 @@ import type { InternetInstallationCatalogItem, InternetAddonCatalogItem, } from "@customer-portal/domain"; -import type { AccessMode } from "../../../hooks/useConfigureParams"; -import { getMonthlyPrice, getOneTimePrice } from "../../../utils/pricing"; +import type { AccessMode } from "../../../../hooks/useConfigureParams"; +import { getMonthlyPrice, getOneTimePrice } from "../../../../utils/pricing"; interface ConfigureState { currentStep: number; @@ -51,16 +51,19 @@ export function useConfigureState( }, []); // Installation selection - const setSelectedInstallationSku = useCallback((sku: string | null) => { - const installation = sku ? installations.find(inst => inst.sku === sku) || null : null; - const installationType = installation ? inferInstallationTypeFromSku(installation.sku) : null; - - setState(prev => ({ - ...prev, - selectedInstallation: installation, - selectedInstallationType: installationType, - })); - }, [installations]); + const setSelectedInstallationSku = useCallback( + (sku: string | null) => { + const installation = sku ? installations.find(inst => inst.sku === sku) || null : null; + const installationType = installation ? inferInstallationTypeFromSku(installation.sku) : null; + + setState(prev => ({ + ...prev, + selectedInstallation: installation, + selectedInstallationType: installationType, + })); + }, + [installations] + ); // Addon selection const setSelectedAddonSkus = useCallback((skus: string[]) => { @@ -69,25 +72,38 @@ export function useConfigureState( // Calculate totals const totals: ConfigureTotals = { - monthlyTotal: calculateMonthlyTotal(plan, state.selectedInstallation, state.selectedAddonSkus, addons), - oneTimeTotal: calculateOneTimeTotal(plan, state.selectedInstallation, state.selectedAddonSkus, addons), + monthlyTotal: calculateMonthlyTotal( + plan, + state.selectedInstallation, + state.selectedAddonSkus, + addons + ), + oneTimeTotal: calculateOneTimeTotal( + plan, + state.selectedInstallation, + state.selectedAddonSkus, + addons + ), }; // Validation - const canProceedFromStep = useCallback((step: number): boolean => { - switch (step) { - case 1: - return plan?.internetPlanTier !== "Silver" || state.mode !== null; - case 2: - return state.selectedInstallation !== null; - case 3: - return true; // Add-ons are optional - case 4: - return true; // Review step - default: - return false; - } - }, [plan, state.mode, state.selectedInstallation]); + const canProceedFromStep = useCallback( + (step: number): boolean => { + switch (step) { + case 1: + return plan?.internetPlanTier !== "Silver" || state.mode !== null; + case 2: + return state.selectedInstallation !== null; + case 3: + return true; // Add-ons are optional + case 4: + return true; // Review step + default: + return false; + } + }, + [plan, state.mode, state.selectedInstallation] + ); return { ...state, @@ -155,11 +171,11 @@ function calculateOneTimeTotal( // Helper function to infer installation type from SKU function inferInstallationTypeFromSku(sku: string): string { // This should match the logic from the original inferInstallationType utility - if (sku.toLowerCase().includes('self')) { - return 'Self Installation'; + if (sku.toLowerCase().includes("self")) { + return "Self Installation"; } - if (sku.toLowerCase().includes('tech') || sku.toLowerCase().includes('professional')) { - return 'Technician Installation'; + if (sku.toLowerCase().includes("tech") || sku.toLowerCase().includes("professional")) { + return "Technician Installation"; } - return 'Standard Installation'; + return "Standard Installation"; } diff --git a/apps/portal/src/features/catalog/components/internet/configure/steps/AddonsStep.tsx b/apps/portal/src/features/catalog/components/internet/configure/steps/AddonsStep.tsx index 45608dcf..d22ca15d 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/steps/AddonsStep.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/steps/AddonsStep.tsx @@ -38,14 +38,14 @@ export function AddonsStep({ description="Optional services to enhance your internet experience" />
- + - +
- diff --git a/apps/portal/src/features/catalog/components/internet/configure/steps/ReviewOrderStep.tsx b/apps/portal/src/features/catalog/components/internet/configure/steps/ReviewOrderStep.tsx index f0303de3..bdc6533a 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/steps/ReviewOrderStep.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/steps/ReviewOrderStep.tsx @@ -9,8 +9,8 @@ import type { InternetInstallationCatalogItem, InternetAddonCatalogItem, } from "@customer-portal/domain"; -import type { AccessMode } from "../../../hooks/useConfigureParams"; -import { getMonthlyPrice, getOneTimePrice } from "../../../utils/pricing"; +import type { AccessMode } from "../../../../hooks/useConfigureParams"; +import { getMonthlyPrice, getOneTimePrice } from "../../../../utils/pricing"; interface Props { plan: InternetPlanCatalogItem; @@ -66,12 +66,7 @@ export function ReviewOrderStep({
- @@ -102,7 +97,7 @@ function OrderSummary({ return ( <>

Order Summary

- + {/* Plan Details */}
- + - + {selectedAddons.map(addon => ( void; } -export function ServiceConfigurationStep({ - plan, - mode, - setMode, - isTransitioning, - onNext, -}: Props) { +export function ServiceConfigurationStep({ plan, mode, setMode, isTransitioning, onNext }: Props) { return ( -

- Select Your Router & ISP Configuration: -

+

Select Your Router & ISP Configuration:

onSelect(mode)} diff --git a/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx b/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx index 0e0c2c27..aecf3eb7 100644 --- a/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx +++ b/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx @@ -16,20 +16,11 @@ import { ExclamationTriangleIcon, UsersIcon, } from "@heroicons/react/24/outline"; -import type { - SimCatalogProduct, - SimActivationFeeCatalogItem, -} from "@customer-portal/domain"; +import type { UseSimConfigureResult } from "@/features/catalog/hooks/useSimConfigure"; -interface Props { - plan: SimCatalogProduct | null; - loading: boolean; - activationFees: SimActivationFeeCatalogItem[]; - addons: SimCatalogProduct[]; - selectedAddonSkus: string[]; - onAddonChange: (addons: string[]) => void; +type Props = UseSimConfigureResult & { onConfirm: () => void; -} +}; export function SimConfigureView({ plan, @@ -181,10 +172,10 @@ export function SimConfigureView({
-
-
- ¥{(plan.monthlyPrice ?? plan.unitPrice ?? 0).toLocaleString()}/mo -
+
+
+ ¥{(plan.monthlyPrice ?? plan.unitPrice ?? 0).toLocaleString()}/mo +
{plan.simHasFamilyDiscount && (
Discounted Price
)} @@ -435,8 +426,8 @@ export function SimConfigureView({ const addon = addons.find(a => a.sku === addonSku); const addonAmount = addon ? addon.billingCycle === "Monthly" - ? addon.monthlyPrice ?? addon.unitPrice ?? 0 - : addon.oneTimePrice ?? addon.unitPrice ?? 0 + ? (addon.monthlyPrice ?? addon.unitPrice ?? 0) + : (addon.oneTimePrice ?? addon.unitPrice ?? 0) : 0; return ( @@ -461,7 +452,8 @@ export function SimConfigureView({

One-time Fees

{activationFees.map((fee, index) => { - const feeAmount = fee.oneTimePrice ?? fee.unitPrice ?? fee.monthlyPrice ?? 0; + const feeAmount = + fee.oneTimePrice ?? fee.unitPrice ?? fee.monthlyPrice ?? 0; return (
{fee.name} diff --git a/apps/portal/src/features/catalog/hooks/useCatalog.ts b/apps/portal/src/features/catalog/hooks/useCatalog.ts index 03be56c0..2065e26d 100644 --- a/apps/portal/src/features/catalog/hooks/useCatalog.ts +++ b/apps/portal/src/features/catalog/hooks/useCatalog.ts @@ -14,14 +14,7 @@ import { catalogService } from "../services"; export function useInternetCatalog() { return useQuery({ queryKey: queryKeys.catalog.internet.combined(), - queryFn: async () => { - const [plans, installations, addons] = await Promise.all([ - catalogService.getInternetPlans(), - catalogService.getInternetInstallations(), - catalogService.getInternetAddons(), - ]); - return { plans, installations, addons } as const; - }, + queryFn: () => catalogService.getInternetCatalog(), staleTime: 5 * 60 * 1000, }); } @@ -33,14 +26,7 @@ export function useInternetCatalog() { export function useSimCatalog() { return useQuery({ queryKey: queryKeys.catalog.sim.combined(), - queryFn: async () => { - const [plans, activationFees, addons] = await Promise.all([ - catalogService.getSimPlans(), - catalogService.getSimActivationFees(), - catalogService.getSimAddons(), - ]); - return { plans, activationFees, addons } as const; - }, + queryFn: () => catalogService.getSimCatalog(), staleTime: 5 * 60 * 1000, }); } @@ -52,13 +38,7 @@ export function useSimCatalog() { export function useVpnCatalog() { return useQuery({ queryKey: queryKeys.catalog.vpn.combined(), - queryFn: async () => { - const [plans, activationFees] = await Promise.all([ - catalogService.getVpnPlans(), - catalogService.getVpnActivationFees(), - ]); - return { plans, activationFees } as const; - }, + queryFn: () => catalogService.getVpnCatalog(), staleTime: 5 * 60 * 1000, }); } diff --git a/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts b/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts index 42b3d18b..d987f2e3 100644 --- a/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts +++ b/apps/portal/src/features/catalog/hooks/useInternetConfigure.ts @@ -74,7 +74,7 @@ export function useInternetConfigure(): UseInternetConfigureResult { if (selectedPlan) { setPlan(selectedPlan); setAddons(addonsData); - setInstallations(installationsData); + setInstallations(installationsData); if (accessMode) setMode(accessMode as InternetAccessMode); if (installationSku) setSelectedInstallationSku(installationSku); diff --git a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts b/apps/portal/src/features/catalog/hooks/useSimConfigure.ts index bfdbc903..88b86647 100644 --- a/apps/portal/src/features/catalog/hooks/useSimConfigure.ts +++ b/apps/portal/src/features/catalog/hooks/useSimConfigure.ts @@ -12,10 +12,7 @@ import { type ActivationType, type MnpData, } from "@customer-portal/domain"; -import type { - SimCatalogProduct, - SimActivationFeeCatalogItem, -} from "@customer-portal/domain"; +import type { SimCatalogProduct, SimActivationFeeCatalogItem } from "@customer-portal/domain"; export type UseSimConfigureResult = { // data @@ -27,9 +24,12 @@ export type UseSimConfigureResult = { // Zod form integration values: SimConfigureFormData; errors: Record; - setValue: (field: K, value: SimConfigureFormData[K]) => void; + setValue: ( + field: K, + value: SimConfigureFormData[K] + ) => void; validate: () => boolean; - + // Convenience getters for specific fields simType: SimType; setSimType: (value: SimType) => void; @@ -126,8 +126,8 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { if (mounted) { // Set initial values from URL params or defaults const initialSimType = (searchParams.get("simType") as SimType) || "eSIM"; - const initialActivationType = (searchParams.get("activationType") as ActivationType) || "Immediate"; - + const initialActivationType = + (searchParams.get("activationType") as ActivationType) || "Immediate"; setSimType(initialSimType); setEid(searchParams.get("eid") || ""); @@ -201,21 +201,21 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { // Build checkout search params const buildCheckoutSearchParams = () => { const params = new URLSearchParams(); - + if (selectedPlan) { params.set("planId", selectedPlan.id); params.set("simType", values.simType); - + if (values.eid) params.set("eid", values.eid); if (values.selectedAddons.length > 0) { params.set("addons", values.selectedAddons.join(",")); } - + params.set("activationType", values.activationType); if (values.scheduledActivationDate) { params.set("scheduledDate", values.scheduledActivationDate); } - + if (values.wantsMnp) { params.set("wantsMnp", "true"); if (values.mnpData) { @@ -225,7 +225,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { } } } - + return params; }; diff --git a/apps/portal/src/features/catalog/index.ts b/apps/portal/src/features/catalog/index.ts index 89d22e26..46f5d522 100644 --- a/apps/portal/src/features/catalog/index.ts +++ b/apps/portal/src/features/catalog/index.ts @@ -12,7 +12,6 @@ export * from "./hooks"; // Services export * from "./services"; - // Import domain types directly: import type { Address } from "@customer-portal/domain"; // Utilities diff --git a/apps/portal/src/features/catalog/services/catalog.service.ts b/apps/portal/src/features/catalog/services/catalog.service.ts index f979e80a..26f1c370 100644 --- a/apps/portal/src/features/catalog/services/catalog.service.ts +++ b/apps/portal/src/features/catalog/services/catalog.service.ts @@ -23,7 +23,11 @@ export const catalogService = { addons: InternetAddonCatalogItem[]; }> { const response = await apiClient.GET("/api/catalog/internet/plans"); - return getDataOrDefault(response, emptyInternetPlans); + return getDataOrDefault(response, { + plans: emptyInternetPlans, + installations: emptyInternetInstallations, + addons: emptyInternetAddons, + }); }, async getInternetInstallations(): Promise { @@ -42,7 +46,11 @@ export const catalogService = { addons: SimCatalogProduct[]; }> { const response = await apiClient.GET("/api/catalog/sim/plans"); - return getDataOrDefault(response, emptySimPlans); + return getDataOrDefault(response, { + plans: emptySimPlans, + activationFees: emptySimActivationFees, + addons: emptySimAddons, + }); }, async getSimActivationFees(): Promise { @@ -60,7 +68,10 @@ export const catalogService = { activationFees: VpnCatalogProduct[]; }> { const response = await apiClient.GET("/api/catalog/vpn/plans"); - return getDataOrDefault(response, emptyVpnPlans); + return getDataOrDefault(response, { + plans: emptyVpnPlans, + activationFees: emptyVpnPlans, + }); }, async getVpnActivationFees(): Promise { diff --git a/apps/portal/src/features/catalog/utils/catalog.utils.ts b/apps/portal/src/features/catalog/utils/catalog.utils.ts index 4f7bc10f..5a70ab8f 100644 --- a/apps/portal/src/features/catalog/utils/catalog.utils.ts +++ b/apps/portal/src/features/catalog/utils/catalog.utils.ts @@ -3,21 +3,30 @@ * Helper functions for catalog operations */ -import type { CatalogFilter } from "@customer-portal/domain"; -import type { InternetPlan, SimPlan, VpnPlan } from "@customer-portal/domain"; - -// Type alias for convenience -type CatalogProduct = InternetPlan | SimPlan | VpnPlan; - import { formatCurrency } from "@customer-portal/domain"; +import type { + CatalogFilter, + InternetPlanCatalogItem, + InternetAddonCatalogItem, + InternetInstallationCatalogItem, + SimCatalogProduct, + VpnCatalogProduct, +} from "@customer-portal/domain"; + +type CatalogProduct = + | InternetPlanCatalogItem + | InternetAddonCatalogItem + | InternetInstallationCatalogItem + | SimCatalogProduct + | VpnCatalogProduct; /** * Format price with currency (wrapper for centralized utility) */ export function formatPrice(price: number, currency: string = "JPY"): string { - return formatCurrency(price, { - currency, - locale: "ja-JP" + return formatCurrency(price, { + currency, + locale: "ja-JP", }); } @@ -65,7 +74,11 @@ export function getCategoryDisplayName(category: string): string { * Check if product is recommended (works with InternetPlan type) */ export function isProductRecommended(product: CatalogProduct): boolean { - return 'isRecommended' in product ? Boolean(product.isRecommended) : false; + if ("catalogMetadata" in product && product.catalogMetadata) { + const metadata = product.catalogMetadata as { isRecommended?: boolean }; + return Boolean(metadata.isRecommended); + } + return false; } /** diff --git a/apps/portal/src/features/catalog/utils/index.ts b/apps/portal/src/features/catalog/utils/index.ts new file mode 100644 index 00000000..4708aed2 --- /dev/null +++ b/apps/portal/src/features/catalog/utils/index.ts @@ -0,0 +1,3 @@ +export * from "./catalog.utils"; +export * from "./pricing"; +export * from "./inferInstallationType"; diff --git a/apps/portal/src/features/catalog/views/CatalogHome.tsx b/apps/portal/src/features/catalog/views/CatalogHome.tsx index c0c9298b..ffeffdce 100644 --- a/apps/portal/src/features/catalog/views/CatalogHome.tsx +++ b/apps/portal/src/features/catalog/views/CatalogHome.tsx @@ -102,5 +102,3 @@ export function CatalogHomeView() { } export default CatalogHomeView; - - diff --git a/apps/portal/src/features/catalog/views/InternetConfigure.tsx b/apps/portal/src/features/catalog/views/InternetConfigure.tsx index 043ca3ed..e05fa1af 100644 --- a/apps/portal/src/features/catalog/views/InternetConfigure.tsx +++ b/apps/portal/src/features/catalog/views/InternetConfigure.tsx @@ -18,5 +18,3 @@ export function InternetConfigureContainer() { } export default InternetConfigureContainer; - - diff --git a/apps/portal/src/features/catalog/views/InternetPlans.tsx b/apps/portal/src/features/catalog/views/InternetPlans.tsx index 6bb6b809..21b7588b 100644 --- a/apps/portal/src/features/catalog/views/InternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/InternetPlans.tsx @@ -52,23 +52,24 @@ export function InternetPlansContainer() { const getEligibilityIcon = (offeringType?: string) => { const lower = (offeringType || "").toLowerCase(); if (lower.includes("home")) return ; - if (lower.includes("apartment")) - return ; + if (lower.includes("apartment")) return ; return ; }; const getEligibilityColor = (offeringType?: string) => { const lower = (offeringType || "").toLowerCase(); - if (lower.includes("home")) - return "text-blue-600 bg-blue-50 border-blue-200"; - if (lower.includes("apartment")) - return "text-green-600 bg-green-50 border-green-200"; + if (lower.includes("home")) return "text-blue-600 bg-blue-50 border-blue-200"; + if (lower.includes("apartment")) return "text-green-600 bg-green-50 border-green-200"; return "text-gray-600 bg-gray-50 border-gray-200"; }; if (isLoading || error) { return ( - }> + } + >
{/* Back */} @@ -141,10 +142,18 @@ export function InternetPlansContainer() {
{hasActiveInternet && ( - +

- You already have an Internet subscription with us. If you want another subscription for a different residence, - please contact us. + You already have an Internet subscription with us. If you want another subscription + for a different residence, please{" "} + + contact us + + .

)} @@ -158,7 +167,11 @@ export function InternetPlansContainer() { plan={plan} installations={installations} disabled={hasActiveInternet} - disabledReason={hasActiveInternet ? "Already subscribed — contact us to add another residence" : undefined} + disabledReason={ + hasActiveInternet + ? "Already subscribed — contact us to add another residence" + : undefined + } /> ))}
@@ -166,8 +179,13 @@ export function InternetPlansContainer() {
  • Theoretical internet speed is the same for all three packages
  • -
  • One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments
  • -
  • Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month + ¥1,000-3,000 one-time)
  • +
  • + One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments +
  • +
  • + Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month + + ¥1,000-3,000 one-time) +
  • In-home technical assistance available (¥15,000 onsite visiting fee)
diff --git a/apps/portal/src/features/catalog/views/SimConfigure.tsx b/apps/portal/src/features/catalog/views/SimConfigure.tsx index 3400a9cc..3a085336 100644 --- a/apps/portal/src/features/catalog/views/SimConfigure.tsx +++ b/apps/portal/src/features/catalog/views/SimConfigure.tsx @@ -21,5 +21,3 @@ export function SimConfigureContainer() { } export default SimConfigureContainer; - - diff --git a/apps/portal/src/features/catalog/views/SimPlans.tsx b/apps/portal/src/features/catalog/views/SimPlans.tsx index ec8090d8..a5ecf821 100644 --- a/apps/portal/src/features/catalog/views/SimPlans.tsx +++ b/apps/portal/src/features/catalog/views/SimPlans.tsx @@ -154,7 +154,8 @@ export function SimPlansContainer() {

- You already have a SIM subscription with us. Family discount pricing is automatically applied to eligible additional lines below. + You already have a SIM subscription with us. Family discount pricing is + automatically applied to eligible additional lines below.

  • Reduced monthly pricing automatically reflected
  • @@ -226,7 +227,11 @@ export function SimPlansContainer() { > } plans={plansByType.DataSmsVoice} showFamilyDiscount={hasExistingSim} @@ -237,7 +242,11 @@ export function SimPlansContainer() { > } plans={plansByType.DataOnly} showFamilyDiscount={hasExistingSim} @@ -248,7 +257,11 @@ export function SimPlansContainer() { > } plans={plansByType.VoiceOnly} showFamilyDiscount={hasExistingSim} @@ -306,25 +319,32 @@ export function SimPlansContainer() {
- +
Contract Period

- Minimum 3 full billing months required. First month (sign-up to end of month) is free and doesn't count toward contract. + Minimum 3 full billing months required. First month (sign-up to end of month) is + free and doesn't count toward contract.

Billing Cycle

- Monthly billing from 1st to end of month. Regular billing starts on 1st of following month after sign-up. + Monthly billing from 1st to end of month. Regular billing starts on 1st of + following month after sign-up.

Cancellation

- Can be requested online after 3rd month. Service terminates at end of billing cycle. + Can be requested online after 3rd month. Service terminates at end of billing + cycle.

@@ -332,20 +352,20 @@ export function SimPlansContainer() {
Plan Changes

- Data plan switching is free and takes effect next month. Voice plan changes require new SIM and cancellation policies apply. + Data plan switching is free and takes effect next month. Voice plan changes + require new SIM and cancellation policies apply.

Calling/SMS Charges

- Pay-per-use charges apply separately. Billed 5-6 weeks after usage within billing cycle. + Pay-per-use charges apply separately. Billed 5-6 weeks after usage within billing + cycle.

SIM Replacement
-

- Reissue fee of 1,500 JPY applies for damaged, lost, or replacement SIM cards. -

+

Reissue fee of 1,500 JPY applies for damaged, lost, or replacement SIM cards.

diff --git a/apps/portal/src/features/catalog/views/VpnPlans.tsx b/apps/portal/src/features/catalog/views/VpnPlans.tsx index 4e6402c4..0ca2fbcb 100644 --- a/apps/portal/src/features/catalog/views/VpnPlans.tsx +++ b/apps/portal/src/features/catalog/views/VpnPlans.tsx @@ -16,8 +16,17 @@ export function VpnPlansView() { if (isLoading || error) { return ( - }> - + } + > + <> @@ -61,7 +70,8 @@ export function VpnPlansView() { {activationFees.length > 0 && ( - A one-time activation fee of 3000 JPY is incurred separately for each rental unit. Tax (10%) not included. + A one-time activation fee of 3000 JPY is incurred separately for each rental unit. + Tax (10%) not included. )}
@@ -103,7 +113,12 @@ export function VpnPlansView() {
- *1: Content subscriptions are NOT included in the SonixNet VPN package. Our VPN service will establish a network connection that virtually locates you in the designated server location, then you will sign up for the streaming services of your choice. Not all services/websites can be unblocked. Assist Solutions does not guarantee or bear any responsibility over the unblocking of any websites or the quality of the streaming/browsing. + *1: Content subscriptions are NOT included in the SonixNet VPN package. Our VPN service + will establish a network connection that virtually locates you in the designated server + location, then you will sign up for the streaming services of your choice. Not all + services/websites can be unblocked. Assist Solutions does not guarantee or bear any + responsibility over the unblocking of any websites or the quality of the + streaming/browsing. @@ -111,5 +126,3 @@ export function VpnPlansView() { } export default VpnPlansView; - - diff --git a/apps/portal/src/features/checkout/hooks/useCheckout.ts b/apps/portal/src/features/checkout/hooks/useCheckout.ts index 60235b3a..4e160d51 100644 --- a/apps/portal/src/features/checkout/hooks/useCheckout.ts +++ b/apps/portal/src/features/checkout/hooks/useCheckout.ts @@ -41,7 +41,7 @@ export function useCheckout() { const [confirmedAddress, setConfirmedAddress] = useState
(null); const [checkoutState, setCheckoutState] = useState>({ - status: 'loading' + status: "loading", }); // Load active subscriptions to enforce business rules client-side before submission @@ -146,17 +146,21 @@ export function useCheckout() { if (mounted) { const totals = calculateOrderTotals(items); - setCheckoutState(createSuccessState({ - items, - totals, - configuration: {} - })); + setCheckoutState( + createSuccessState({ + items, + totals, + configuration: {}, + }) + ); } } catch (error) { if (mounted) { - setCheckoutState(createErrorState( - error instanceof Error ? error.message : "Failed to load checkout data" - )); + setCheckoutState( + createErrorState( + error instanceof Error ? error.message : "Failed to load checkout data" + ) + ); } } })(); @@ -168,7 +172,7 @@ export function useCheckout() { const handleSubmitOrder = useCallback(async () => { try { setSubmitting(true); - if (checkoutState.status !== 'success') { + if (checkoutState.status !== "success") { throw new Error("Checkout data not loaded"); } const skus = extractOrderSKUs(checkoutState.data.items); @@ -223,7 +227,10 @@ export function useCheckout() { // Client-side guard: prevent Internet orders if an Internet subscription already exists if (orderType === "Internet" && Array.isArray(activeSubs)) { const hasActiveInternet = activeSubs.some( - s => String(s.groupName || s.productName || "").toLowerCase().includes("internet") && String(s.status || "").toLowerCase() === "active" + s => + String(s.groupName || s.productName || "") + .toLowerCase() + .includes("internet") && String(s.status || "").toLowerCase() === "active" ); if (hasActiveInternet) { throw new Error( diff --git a/apps/portal/src/features/checkout/views/CheckoutContainer.tsx b/apps/portal/src/features/checkout/views/CheckoutContainer.tsx index d1adb1c3..dc73789e 100644 --- a/apps/portal/src/features/checkout/views/CheckoutContainer.tsx +++ b/apps/portal/src/features/checkout/views/CheckoutContainer.tsx @@ -56,7 +56,9 @@ export function CheckoutContainer() {
{checkoutState.error} - +
@@ -75,7 +77,9 @@ export function CheckoutContainer() {
Checkout data is not available - +
@@ -125,7 +129,12 @@ export function CheckoutContainer() { {paymentMethodsLoading ? (
Checking payment methods...
) : paymentMethodsError ? ( - +