From ec69e3dcbb7bb70b526d8ace4095b56644600bd0 Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 25 Sep 2025 17:42:36 +0900 Subject: [PATCH] Refactor Freebit and WHMCS integrations to enhance maintainability and error handling. Update type definitions and streamline service methods across various modules. Improve import paths and clean up unused code to ensure better organization and clarity in the project structure. --- apps/bff/src/app/bootstrap.ts | 6 +- .../integrations/freebit/freebit.module.ts | 1 - .../freebit/interfaces/freebit.types.ts | 13 + .../freebit/services/freebit-auth.service.ts | 8 +- .../services/freebit-client.service.ts | 23 +- .../freebit/services/freebit-error.service.ts | 8 +- .../services/freebit-mapper.service.ts | 22 +- .../services/freebit-operations.service.ts | 44 +- .../integrations/freebit/services/index.ts | 6 +- .../salesforce/events/pubsub.subscriber.ts | 4 +- .../connection/config/whmcs-config.service.ts | 35 +- .../services/whmcs-api-methods.service.ts | 12 +- .../whmcs-connection-orchestrator.service.ts | 23 +- .../services/whmcs-error-handler.service.ts | 81 +- .../services/whmcs-http-client.service.ts | 56 +- .../whmcs/services/whmcs-invoice.service.ts | 35 +- .../whmcs/services/whmcs-payment.service.ts | 1 - .../services/invoice-transformer.service.ts | 13 +- .../services/payment-transformer.service.ts | 29 +- .../subscription-transformer.service.ts | 8 +- .../whmcs-transformer-orchestrator.service.ts | 24 +- .../whmcs/transformers/utils/data-utils.ts | 1 - .../transformers/utils/status-normalizer.ts | 2 +- .../validators/transformation-validator.ts | 41 +- .../whmcs/types/whmcs-api.types.ts | 3 +- .../src/integrations/whmcs/whmcs.service.ts | 3 - .../catalog/services/base-catalog.service.ts | 14 +- .../catalog/services/vpn-catalog.service.ts | 5 +- .../utils/salesforce-product.mapper.ts | 4 +- .../modules/id-mappings/mappings.service.ts | 20 +- .../modules/invoices/invoices.controller.ts | 8 +- .../services/invoice-health.service.ts | 28 +- .../services/invoice-retrieval.service.ts | 27 +- .../services/invoices-orchestrator.service.ts | 37 +- .../validators/invoice-validator.service.ts | 14 +- .../orders/queue/provisioning.processor.ts | 8 +- .../orders/services/order-builder.service.ts | 29 +- .../order-fulfillment-validator.service.ts | 12 +- .../services/order-orchestrator.service.ts | 43 +- .../services/order-validator.service.ts | 4 +- .../subscriptions/sim-management.service.ts | 1 - .../services/sim-plan.service.ts | 5 +- .../services/sim-topup.service.ts | 4 +- .../services/sim-usage.service.ts | 5 +- .../sim-management/sim-management.module.ts | 9 +- .../sim-order-activation.service.ts | 6 +- .../subscriptions/subscriptions.module.ts | 8 +- .../subscriptions/subscriptions.service.ts | 4 +- apps/bff/src/modules/users/users.service.ts | 32 +- apps/portal/scripts/stubs/core-api.ts | 8 +- .../scripts/test-request-password-reset.cjs | 4 +- .../app/(authenticated)/account/loading.tsx | 1 - .../app/(authenticated)/catalog/loading.tsx | 1 - .../app/(authenticated)/checkout/loading.tsx | 2 - .../app/(authenticated)/dashboard/loading.tsx | 1 - .../portal/src/app/(authenticated)/layout.tsx | 1 - .../(authenticated)/support/cases/loading.tsx | 2 - .../(authenticated)/support/new/loading.tsx | 2 - apps/portal/src/app/(public)/auth/loading.tsx | 2 - apps/portal/src/components/atoms/button.tsx | 21 +- apps/portal/src/components/atoms/checkbox.tsx | 10 +- apps/portal/src/components/atoms/input.tsx | 3 +- .../src/components/atoms/status-pill.tsx | 32 +- apps/portal/src/components/index.ts | 2 +- .../molecules/FormField/FormField.tsx | 19 +- .../src/components/molecules/RouteLoading.tsx | 16 +- .../molecules/SectionHeader/SectionHeader.tsx | 4 +- .../components/molecules/error-boundary.tsx | 10 +- apps/portal/src/components/molecules/index.ts | 2 - .../organisms/AppShell/AppShell.tsx | 2 +- .../components/organisms/AppShell/Header.tsx | 4 +- .../components/organisms/AppShell/Sidebar.tsx | 5 +- .../organisms/AppShell/navigation.ts | 1 - .../components/templates/AuthLayout/index.ts | 2 + .../components/templates/PageLayout/index.ts | 2 + apps/portal/src/components/templates/index.ts | 1 - .../features/account/hooks/useAddressEdit.ts | 6 +- .../features/account/hooks/useProfileEdit.ts | 8 +- .../account/services/account.service.ts | 8 +- .../account/views/ProfileContainer.tsx | 52 +- .../LinkWhmcsForm/LinkWhmcsForm.tsx | 71 +- .../auth/components/LoginForm/LoginForm.tsx | 76 +- .../PasswordResetForm/PasswordResetForm.tsx | 83 +- .../auth/components/SessionTimeoutWarning.tsx | 5 +- .../SetPasswordForm/SetPasswordForm.tsx | 73 +- .../components/SignupForm/AddressStep.tsx | 89 +- .../components/SignupForm/PasswordStep.tsx | 22 +- .../components/SignupForm/PersonalStep.tsx | 55 +- .../auth/components/SignupForm/SignupForm.tsx | 113 +- .../src/features/auth/hooks/use-auth.ts | 32 +- .../src/features/auth/services/auth.store.ts | 104 +- .../src/features/auth/services/index.ts | 7 +- .../features/auth/utils/route-protection.ts | 2 - .../auth/views/ForgotPasswordView.tsx | 2 +- .../src/features/auth/views/LinkWhmcsView.tsx | 4 +- .../features/auth/views/ResetPasswordView.tsx | 5 +- .../components/BillingStatusBadge/index.ts | 1 + .../src/features/billing/hooks/useBilling.ts | 21 +- .../src/features/billing/utils/index.ts | 2 - .../features/billing/views/InvoiceDetail.tsx | 7 +- .../features/billing/views/InvoicesList.tsx | 2 - .../features/billing/views/PaymentMethods.tsx | 188 +- .../catalog/components/base/AddonGroup.tsx | 29 +- .../components/base/AddressConfirmation.tsx | 7 +- .../catalog/components/base/AddressForm.tsx | 2 +- .../components/base/EnhancedOrderSummary.tsx | 16 +- .../catalog/components/base/OrderSummary.tsx | 4 +- .../catalog/components/base/PaymentForm.tsx | 10 +- .../internet/InstallationOptions.tsx | 15 +- .../internet/InternetConfigureView.tsx | 10 +- .../components/internet/InternetPlanCard.tsx | 18 +- .../configure/hooks/useConfigureState.ts | 82 +- .../internet/configure/steps/AddonsStep.tsx | 4 +- .../configure/steps/InstallationStep.tsx | 6 +- .../configure/steps/ReviewOrderStep.tsx | 17 +- .../steps/ServiceConfigurationStep.tsx | 16 +- .../components/sim/SimConfigureView.tsx | 30 +- .../src/features/catalog/hooks/useCatalog.ts | 26 +- .../catalog/hooks/useInternetConfigure.ts | 2 +- .../features/catalog/hooks/useSimConfigure.ts | 26 +- apps/portal/src/features/catalog/index.ts | 1 - .../catalog/services/catalog.service.ts | 17 +- .../features/catalog/utils/catalog.utils.ts | 33 +- .../src/features/catalog/utils/index.ts | 3 + .../features/catalog/views/CatalogHome.tsx | 2 - .../catalog/views/InternetConfigure.tsx | 2 - .../features/catalog/views/InternetPlans.tsx | 44 +- .../features/catalog/views/SimConfigure.tsx | 2 - .../src/features/catalog/views/SimPlans.tsx | 46 +- .../src/features/catalog/views/VpnPlans.tsx | 25 +- .../features/checkout/hooks/useCheckout.ts | 29 +- .../checkout/views/CheckoutContainer.tsx | 17 +- .../components/PaymentErrorBanner.tsx | 9 +- .../dashboard/components/TasksChip.tsx | 2 - .../dashboard/hooks/useDashboardSummary.ts | 14 +- .../dashboard/views/DashboardView.tsx | 6 +- .../features/orders/components/OrderCard.tsx | 20 +- .../orders/components/OrderCardSkeleton.tsx | 2 - .../features/orders/utils/order-presenters.ts | 7 +- .../src/features/orders/views/OrderDetail.tsx | 20 +- .../src/features/orders/views/OrdersList.tsx | 6 +- .../src/features/sim-management/utils/plan.ts | 2 - .../subscriptions/hooks/useSubscriptions.ts | 7 +- .../subscriptions/views/SimCancel.tsx | 4 +- .../subscriptions/views/SimChangePlan.tsx | 2 - .../features/subscriptions/views/SimTopUp.tsx | 2 - .../views/SubscriptionDetail.tsx | 2 - .../subscriptions/views/SubscriptionsList.tsx | 160 +- .../portal/src/lib/api/__generated__/types.ts | 5112 ++++++++--------- apps/portal/src/lib/api/index.ts | 42 +- apps/portal/src/lib/api/response-helpers.ts | 24 +- apps/portal/src/lib/api/runtime/client.ts | 23 +- apps/portal/src/lib/api/types.ts | 2 +- apps/portal/src/lib/hooks/index.ts | 2 +- apps/portal/src/lib/hooks/useLocalStorage.ts | 19 +- apps/portal/src/lib/hooks/useMediaQuery.ts | 12 +- apps/portal/src/lib/utils/error-display.ts | 10 +- apps/portal/src/lib/utils/error-handling.ts | 119 +- apps/portal/src/lib/utils/index.ts | 2 +- apps/portal/src/lib/utils/plan.ts | 8 +- eslint.config.mjs | 21 +- .../domain/src/validation/business/orders.ts | 21 +- packages/domain/src/validation/index.ts | 1 - .../src/validation/shared/primitives.ts | 12 +- packages/validation/src/zod-form.ts | 10 +- scripts/typecheck/run-chunks.mjs | 28 +- 166 files changed, 4195 insertions(+), 4106 deletions(-) create mode 100644 apps/portal/src/components/templates/AuthLayout/index.ts create mode 100644 apps/portal/src/components/templates/PageLayout/index.ts create mode 100644 apps/portal/src/features/billing/components/BillingStatusBadge/index.ts create mode 100644 apps/portal/src/features/catalog/utils/index.ts 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 ? ( - +