From 6a7ea6e057971efc17c078134b08bd0e5bd7f88f Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 19 Jan 2026 14:15:43 +0900 Subject: [PATCH] feat: add services for SIM billing, details, top-up pricing, and usage management --- apps/bff/src/modules/auth/auth.module.ts | 1 + .../http/utils/auth-cookie.util.ts | 11 - apps/bff/src/modules/orders/orders.module.ts | 20 +- .../src/modules/orders/validators/index.ts | 8 + .../call-history/call-history.controller.ts | 2 +- .../cancellation/cancellation.module.ts | 4 +- .../cancellation/cancellation.service.ts | 10 +- .../subscriptions/sim-management.service.ts | 124 ----- .../subscriptions/sim-management/index.ts | 19 +- .../services/esim-management.service.ts | 202 -------- .../{ => queries}/sim-billing.service.ts | 0 .../{ => queries}/sim-details.service.ts | 26 +- .../sim-topup-pricing.service.ts | 0 .../{ => queries}/sim-usage.service.ts | 6 +- .../sim-call-history-formatter.service.ts | 84 ---- .../sim-call-history-parser.service.ts | 296 ----------- .../services/sim-call-history.service.ts | 419 ---------------- .../services/sim-cancellation.service.ts | 347 ------------- .../services/sim-notification.service.ts | 345 ------------- .../services/sim-orchestrator.service.ts | 12 +- .../services/sim-plan.service.ts | 353 ------------- .../services/sim-schedule.service.ts | 71 --- .../services/sim-topup.service.ts | 255 ---------- .../services/sim-validation.service.ts | 8 +- .../sim-management/sim-management.module.ts | 38 +- .../sim-management/sim.controller.ts | 38 +- .../sim-order-activation.service.ts | 4 +- .../subscriptions/subscriptions.controller.ts | 14 +- .../subscriptions/subscriptions.module.ts | 10 +- .../subscriptions/subscriptions.service.ts | 466 ------------------ .../checkout/stores/checkout.store.ts | 11 + .../support/views/PublicContactView.tsx | 8 +- .../support/views/SupportCaseDetailView.tsx | 4 +- apps/portal/src/middleware.ts | 67 --- apps/portal/src/proxy.ts | 113 +++-- apps/portal/src/shared/utils/date.ts | 10 +- eslint.config.mjs | 24 + packages/domain/common/errors.ts | 228 +++++++++ .../providers/whmcs-utils/custom-fields.ts | 82 +-- .../domain/customer/providers/whmcs/mapper.ts | 30 +- packages/domain/notifications/contract.ts | 12 +- .../orders/providers/salesforce/mapper.ts | 82 +-- .../services/providers/salesforce/mapper.ts | 76 +-- .../domain/sim/providers/freebit/requests.ts | 53 +- packages/domain/sim/schema.ts | 54 +- .../subscriptions/providers/whmcs/mapper.ts | 74 ++- .../support/providers/salesforce/raw.types.ts | 10 +- 47 files changed, 747 insertions(+), 3384 deletions(-) delete mode 100644 apps/bff/src/modules/subscriptions/sim-management.service.ts delete mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts rename apps/bff/src/modules/subscriptions/sim-management/services/{ => queries}/sim-billing.service.ts (100%) rename apps/bff/src/modules/subscriptions/sim-management/services/{ => queries}/sim-details.service.ts (76%) rename apps/bff/src/modules/subscriptions/sim-management/services/{ => queries}/sim-topup-pricing.service.ts (100%) rename apps/bff/src/modules/subscriptions/sim-management/services/{ => queries}/sim-usage.service.ts (94%) delete mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-formatter.service.ts delete mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-parser.service.ts delete mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts delete mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts delete mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-notification.service.ts delete mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts delete mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-schedule.service.ts delete mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts delete mode 100644 apps/bff/src/modules/subscriptions/subscriptions.service.ts delete mode 100644 apps/portal/src/middleware.ts diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index 7d1d803c..a68aca0a 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -65,6 +65,7 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js"; FailedLoginThrottleGuard, AuthRateLimitService, LoginResultInterceptor, + PermissionsGuard, { provide: APP_GUARD, useClass: GlobalAuthGuard, diff --git a/apps/bff/src/modules/auth/presentation/http/utils/auth-cookie.util.ts b/apps/bff/src/modules/auth/presentation/http/utils/auth-cookie.util.ts index 9becec4d..fcaf2ca2 100644 --- a/apps/bff/src/modules/auth/presentation/http/utils/auth-cookie.util.ts +++ b/apps/bff/src/modules/auth/presentation/http/utils/auth-cookie.util.ts @@ -98,19 +98,12 @@ export function setAuthCookies(res: Response, tokens: AuthTokens): void { /** * Clear authentication cookies from the response. - * Also clears legacy cookies that may have been set on "/" path. */ export function clearAuthCookies(res: Response): void { const setSecureCookie = getSecureCookie(res); if (setSecureCookie) { - // Clear current cookie paths setSecureCookie("access_token", "", { maxAge: 0, path: ACCESS_COOKIE_PATH }); setSecureCookie("refresh_token", "", { maxAge: 0, path: REFRESH_COOKIE_PATH }); - - // DEPRECATED: Clear legacy cookies that were set on `/` - // TODO: Remove after 90 days (2025-04-XX) when legacy cookies have expired - setSecureCookie("access_token", "", { maxAge: 0, path: "/" }); - setSecureCookie("refresh_token", "", { maxAge: 0, path: "/" }); } else { // Fallback to standard cookie clearing const isProduction = process.env["NODE_ENV"] === "production"; @@ -123,10 +116,6 @@ export function clearAuthCookies(res: Response): void { res.cookie("access_token", "", { ...clearOptions, path: ACCESS_COOKIE_PATH }); res.cookie("refresh_token", "", { ...clearOptions, path: REFRESH_COOKIE_PATH }); - - // DEPRECATED: Clear legacy cookies - res.cookie("access_token", "", { ...clearOptions, path: "/" }); - res.cookie("refresh_token", "", { ...clearOptions, path: "/" }); } } diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index fa3d45f7..8afe1a5c 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -11,6 +11,16 @@ import { VerificationModule } from "@bff/modules/verification/verification.modul import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; import { WorkflowModule } from "@bff/modules/shared/workflow/index.js"; +// Validators (modular, composable) +import { + UserMappingValidator, + PaymentValidator, + SkuValidator, + SimOrderValidator, + InternetOrderValidator, + OrderCompositeValidator, +} from "./validators/index.js"; + // Clean modular order services import { OrderValidator } from "./services/order-validator.service.js"; import { OrderBuilder } from "./services/order-builder.service.js"; @@ -48,7 +58,15 @@ import { SalesforceOrderFieldConfigModule } from "@bff/integrations/salesforce/c ], controllers: [OrdersController, CheckoutController], providers: [ - // Shared services + // Validators (modular, composable) + UserMappingValidator, + PaymentValidator, + SkuValidator, + SimOrderValidator, + InternetOrderValidator, + OrderCompositeValidator, + + // Shared services (legacy - PaymentValidatorService used by OrderFulfillmentValidator) PaymentValidatorService, OrderEventsService, diff --git a/apps/bff/src/modules/orders/validators/index.ts b/apps/bff/src/modules/orders/validators/index.ts index 94bda173..a850bd4a 100644 --- a/apps/bff/src/modules/orders/validators/index.ts +++ b/apps/bff/src/modules/orders/validators/index.ts @@ -10,6 +10,14 @@ export { // Individual validators export { UserMappingValidator, type UserMappingData } from "./user-mapping.validator.js"; +export { PaymentValidator } from "./payment.validator.js"; export { SkuValidator } from "./sku.validator.js"; export { SimOrderValidator } from "./sim-order.validator.js"; export { InternetOrderValidator } from "./internet-order.validator.js"; + +// Composite validator +export { + OrderCompositeValidator, + type ValidatedOrderContext, + type OrderValidationOptions, +} from "./order-composite.validator.js"; diff --git a/apps/bff/src/modules/subscriptions/call-history/call-history.controller.ts b/apps/bff/src/modules/subscriptions/call-history/call-history.controller.ts index 0eee38a4..222d89fb 100644 --- a/apps/bff/src/modules/subscriptions/call-history/call-history.controller.ts +++ b/apps/bff/src/modules/subscriptions/call-history/call-history.controller.ts @@ -1,6 +1,6 @@ import { Controller, Get, Post, Param, Query, Request, Header, UseGuards } from "@nestjs/common"; import { Public } from "@bff/modules/auth/decorators/public.decorator.js"; -import { SimCallHistoryService } from "../sim-management/services/sim-call-history.service.js"; +import { SimCallHistoryService } from "../sim-management/services/support/sim-call-history.service.js"; import { AdminGuard } from "@bff/core/security/guards/admin.guard.js"; import { createZodDto, ZodResponse } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; diff --git a/apps/bff/src/modules/subscriptions/cancellation/cancellation.module.ts b/apps/bff/src/modules/subscriptions/cancellation/cancellation.module.ts index 744917ce..6e8e7fe8 100644 --- a/apps/bff/src/modules/subscriptions/cancellation/cancellation.module.ts +++ b/apps/bff/src/modules/subscriptions/cancellation/cancellation.module.ts @@ -1,7 +1,7 @@ import { Logger, Module } from "@nestjs/common"; import { CancellationController } from "./cancellation.controller.js"; import { CancellationService } from "./cancellation.service.js"; -import { SubscriptionsService } from "../subscriptions.service.js"; +import { SubscriptionsOrchestrator } from "../subscriptions-orchestrator.service.js"; import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js"; import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; @@ -26,7 +26,7 @@ import { SharedSubscriptionsModule } from "../shared/index.js"; SharedSubscriptionsModule, ], controllers: [CancellationController], - providers: [CancellationService, SubscriptionsService, Logger], + providers: [CancellationService, SubscriptionsOrchestrator, Logger], exports: [CancellationService], }) export class CancellationModule {} diff --git a/apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts b/apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts index a208f795..57d05c58 100644 --- a/apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/cancellation/cancellation.service.ts @@ -6,9 +6,9 @@ import type { } from "@customer-portal/domain/subscriptions"; import type { SimCancelFullRequest } from "@customer-portal/domain/sim"; import { OPPORTUNITY_STAGE } from "@customer-portal/domain/opportunity"; -import { SubscriptionsService } from "../subscriptions.service.js"; +import { SubscriptionsOrchestrator } from "../subscriptions-orchestrator.service.js"; import { InternetCancellationService } from "../internet-management/services/internet-cancellation.service.js"; -import { SimCancellationService } from "../sim-management/services/sim-cancellation.service.js"; +import { SimCancellationService } from "../sim-management/services/mutations/sim-cancellation.service.js"; import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; import { CANCELLATION_STEP3_NOTICES, CANCELLATION_TERMS } from "./cancellation-terms.config.js"; import { SubscriptionValidationCoordinator } from "../shared/index.js"; @@ -31,7 +31,7 @@ export class CancellationService { private readonly logger = new Logger(CancellationService.name); constructor( - private readonly subscriptionsService: SubscriptionsService, + private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator, private readonly opportunityService: SalesforceOpportunityService, private readonly internetCancellation: InternetCancellationService, private readonly simCancellation: SimCancellationService, @@ -49,7 +49,7 @@ export class CancellationService { */ async getPreview(userId: string, subscriptionId: number): Promise { // 1) Read subscription from WHMCS (includes custom fields) - const subscription = await this.subscriptionsService.getSubscriptionById( + const subscription = await this.subscriptionsOrchestrator.getSubscriptionById( userId, subscriptionId ); @@ -81,7 +81,7 @@ export class CancellationService { subscriptionId: number, request: InternetCancelRequest ): Promise { - const subscription = await this.subscriptionsService.getSubscriptionById( + const subscription = await this.subscriptionsOrchestrator.getSubscriptionById( userId, subscriptionId ); diff --git a/apps/bff/src/modules/subscriptions/sim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management.service.ts deleted file mode 100644 index 8240610d..00000000 --- a/apps/bff/src/modules/subscriptions/sim-management.service.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { SimOrchestrator } from "./sim-management/services/sim-orchestrator.service.js"; -import type { - SimDetails, - SimUsage, - SimTopUpHistory, - SimTopUpRequest, - SimPlanChangeRequest, - SimCancelRequest, - SimTopUpHistoryRequest, - SimFeaturesUpdateRequest, - SimReissueRequest, -} from "@customer-portal/domain/sim"; - -@Injectable() -export class SimManagementService { - constructor(private readonly simOrchestrator: SimOrchestrator) {} - - /** - * Debug method to check subscription data for SIM services - */ - async debugSimSubscription( - userId: string, - subscriptionId: number - ): Promise> { - return this.simOrchestrator.debugSimSubscription(userId, subscriptionId); - } - - /** - * Debug method to query Freebit directly for any account's details - */ - async getSimDetailsDebug(account: string): Promise { - return this.simOrchestrator.getSimDetailsDirectly(account); - } - - // This method is now handled by SimValidationService internally - - /** - * Get SIM details for a subscription - */ - async getSimDetails(userId: string, subscriptionId: number): Promise { - return this.simOrchestrator.getSimDetails(userId, subscriptionId); - } - - /** - * Get SIM data usage for a subscription - */ - async getSimUsage(userId: string, subscriptionId: number): Promise { - return this.simOrchestrator.getSimUsage(userId, subscriptionId); - } - - /** - * Top up SIM data quota with payment processing - * Pricing: 1GB = 500 JPY - */ - async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise { - return this.simOrchestrator.topUpSim(userId, subscriptionId, request); - } - - /** - * Get SIM top-up history - */ - async getSimTopUpHistory( - userId: string, - subscriptionId: number, - request: SimTopUpHistoryRequest - ): Promise { - return this.simOrchestrator.getSimTopUpHistory(userId, subscriptionId, request); - } - - /** - * Change SIM plan - */ - async changeSimPlan( - userId: string, - subscriptionId: number, - request: SimPlanChangeRequest - ): Promise<{ ipv4?: string; ipv6?: string }> { - return this.simOrchestrator.changeSimPlan(userId, subscriptionId, request); - } - - /** - * Update SIM features (voicemail, call waiting, roaming, network type) - */ - async updateSimFeatures( - userId: string, - subscriptionId: number, - request: SimFeaturesUpdateRequest - ): Promise { - return this.simOrchestrator.updateSimFeatures(userId, subscriptionId, request); - } - - /** - * Cancel SIM service - */ - async cancelSim( - userId: string, - subscriptionId: number, - request: SimCancelRequest = {} - ): Promise { - return this.simOrchestrator.cancelSim(userId, subscriptionId, request); - } - - /** - * Reissue eSIM profile - */ - async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise { - const request: SimReissueRequest = newEid ? { newEid } : {}; - return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, request); - } - - /** - * Get comprehensive SIM information (details + usage combined) - */ - async getSimInfo( - userId: string, - subscriptionId: number - ): Promise<{ - details: SimDetails; - usage: SimUsage; - }> { - return this.simOrchestrator.getSimInfo(userId, subscriptionId); - } -} diff --git a/apps/bff/src/modules/subscriptions/sim-management/index.ts b/apps/bff/src/modules/subscriptions/sim-management/index.ts index c17b874e..3b8663a5 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/index.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/index.ts @@ -1,13 +1,16 @@ -// Services +// Core services export { SimOrchestrator } from "./services/sim-orchestrator.service.js"; -export { SimDetailsService } from "./services/sim-details.service.js"; -export { SimUsageService } from "./services/sim-usage.service.js"; -export { SimTopUpService } from "./services/sim-topup.service.js"; -export { SimPlanService } from "./services/sim-plan.service.js"; -export { SimCancellationService } from "./services/sim-cancellation.service.js"; -export { EsimManagementService } from "./services/esim-management.service.js"; export { SimValidationService } from "./services/sim-validation.service.js"; -export { SimNotificationService } from "./services/sim-notification.service.js"; +// Query services +export { SimDetailsService } from "./services/queries/sim-details.service.js"; +export { SimUsageService } from "./services/queries/sim-usage.service.js"; +// Mutation services +export { SimTopUpService } from "./services/mutations/sim-topup.service.js"; +export { SimPlanService } from "./services/mutations/sim-plan.service.js"; +export { SimCancellationService } from "./services/mutations/sim-cancellation.service.js"; +export { EsimManagementService } from "./services/mutations/esim-management.service.js"; +// Support services +export { SimNotificationService } from "./services/support/sim-notification.service.js"; // Types (re-export from domain for module convenience) export type { diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts deleted file mode 100644 index e6bd42e5..00000000 --- a/apps/bff/src/modules/subscriptions/sim-management/services/esim-management.service.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { Injectable, Inject, BadRequestException } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { ConfigService } from "@nestjs/config"; -import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js"; -import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js"; -import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; -import { SimValidationService } from "./sim-validation.service.js"; -import { SimNotificationService } from "./sim-notification.service.js"; -import { extractErrorMessage } from "@bff/core/utils/error.util.js"; -import type { SimReissueRequest, SimReissueFullRequest } from "@customer-portal/domain/sim"; - -@Injectable() -export class EsimManagementService { - constructor( - private readonly freebitService: FreebitFacade, - private readonly whmcsClientService: WhmcsClientService, - private readonly mappingsService: MappingsService, - private readonly simValidation: SimValidationService, - private readonly simNotification: SimNotificationService, - private readonly configService: ConfigService, - @Inject(Logger) private readonly logger: Logger - ) {} - - private get freebitBaseUrl(): string { - return this.configService.get("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api"; - } - - /** - * Reissue eSIM profile (legacy method) - */ - async reissueEsimProfile( - userId: string, - subscriptionId: number, - request: SimReissueRequest - ): Promise { - try { - const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId); - - // First check if this is actually an eSIM - const simDetails = await this.freebitService.getSimDetails(account); - if (simDetails.simType !== "esim") { - throw new BadRequestException("This operation is only available for eSIM subscriptions"); - } - - const newEid = request.newEid; - - if (newEid) { - await this.freebitService.reissueEsimProfileEnhanced(account, newEid, { - oldEid: simDetails.eid, - planCode: simDetails.planCode, - }); - } else { - await this.freebitService.reissueEsimProfile(account); - } - - this.logger.log(`Successfully reissued eSIM profile for subscription ${subscriptionId}`, { - userId, - subscriptionId, - account, - oldEid: simDetails.eid, - newEid: newEid || undefined, - }); - - await this.simNotification.notifySimAction("Reissue eSIM", "SUCCESS", { - userId, - subscriptionId, - account, - oldEid: simDetails.eid, - newEid: newEid || undefined, - }); - } catch (error) { - const sanitizedError = extractErrorMessage(error); - this.logger.error(`Failed to reissue eSIM profile for subscription ${subscriptionId}`, { - error: sanitizedError, - userId, - subscriptionId, - newEid: request.newEid || undefined, - }); - await this.simNotification.notifySimAction("Reissue eSIM", "ERROR", { - userId, - subscriptionId, - newEid: request.newEid || undefined, - error: sanitizedError, - }); - throw error; - } - } - - /** - * Reissue SIM with full flow (eSIM via PA05-41, Physical SIM via email) - */ - async reissueSim( - userId: string, - subscriptionId: number, - request: SimReissueFullRequest - ): Promise { - const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId); - const simDetails = await this.freebitService.getSimDetails(account); - - // Get customer info from WHMCS - const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); - const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId); - const customerName = - `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer"; - const customerEmail = clientDetails.email || ""; - - if (request.simType === "esim") { - // eSIM reissue via PA05-41 - if (!request.newEid) { - throw new BadRequestException("New EID is required for eSIM reissue"); - } - - const oldEid = simDetails.eid; - - this.logger.log(`Reissuing eSIM via PA05-41`, { - userId, - subscriptionId, - account, - oldEid, - newEid: request.newEid, - }); - - // Call PA05-41 with addKind: "R" for reissue - await this.freebitService.activateEsimAccountNew({ - account, - eid: request.newEid, - addKind: "R", - planCode: simDetails.planCode, - }); - - // Send API results email to admin - await this.simNotification.sendApiResultsEmail("SIM Re-issue Request", [ - { - url: `${this.freebitBaseUrl}/mvno/esim/addAcnt/`, - json: { - reissue: { oldEid }, - account, - addKind: "R", - eid: request.newEid, - authKey: "[REDACTED]", - }, - result: { - resultCode: "100", - status: { message: "OK", statusCode: "200" }, - }, - }, - ]); - - // Send customer email - const customerEmailBody = this.simNotification.buildEsimReissueEmail( - customerName, - account, - request.newEid - ); - await this.simNotification.sendCustomerEmail( - customerEmail, - "SIM Re-issue Request", - customerEmailBody - ); - - this.logger.log(`Successfully reissued eSIM for subscription ${subscriptionId}`, { - userId, - subscriptionId, - account, - oldEid, - newEid: request.newEid, - }); - } else { - // Physical SIM reissue - email only, no API call - this.logger.log(`Processing physical SIM reissue request`, { - userId, - subscriptionId, - account, - }); - - // Send admin notification email - await this.simNotification.sendApiResultsEmail( - "Physical SIM Re-issue Request", - [], - `Physical SIM reissue requested for:\nCustomer: ${customerName}\nSIM #: ${account}\nSerial #: ${simDetails.iccid || "N/A"}\nEmail: ${customerEmail}` - ); - - // Send customer email - const customerEmailBody = this.simNotification.buildPhysicalSimReissueEmail( - customerName, - account - ); - await this.simNotification.sendCustomerEmail( - customerEmail, - "Physical SIM Re-issue Request", - customerEmailBody - ); - - this.logger.log(`Sent physical SIM reissue request emails`, { - userId, - subscriptionId, - account, - customerEmail, - }); - } - } -} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-billing.service.ts similarity index 100% rename from apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts rename to apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-billing.service.ts diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-details.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-details.service.ts similarity index 76% rename from apps/bff/src/modules/subscriptions/sim-management/services/sim-details.service.ts rename to apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-details.service.ts index d750f1e3..921c1965 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-details.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-details.service.ts @@ -1,7 +1,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js"; -import { SimValidationService } from "./sim-validation.service.js"; +import { SimValidationService } from "../sim-validation.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import type { SimDetails } from "@customer-portal/domain/sim"; @@ -47,25 +47,25 @@ export class SimDetailsService { */ async getSimDetailsDirectly(account: string): Promise { try { - this.logger.log(`[DEBUG] Querying Freebit for account: ${account}`); + this.logger.debug({ account }, "Querying Freebit for SIM details"); const simDetails = await this.freebitService.getSimDetails(account); - this.logger.log(`[DEBUG] Retrieved SIM details from Freebit`, { - account, - planCode: simDetails.planCode, - planName: simDetails.planName, - status: simDetails.status, - simType: simDetails.simType, - }); + this.logger.debug( + { + account, + planCode: simDetails.planCode, + planName: simDetails.planName, + status: simDetails.status, + simType: simDetails.simType, + }, + "Retrieved SIM details from Freebit" + ); return simDetails; } catch (error) { const sanitizedError = extractErrorMessage(error); - this.logger.error(`[DEBUG] Failed to get SIM details for account ${account}`, { - error: sanitizedError, - account, - }); + this.logger.error({ error: sanitizedError, account }, "Failed to get SIM details"); throw error; } } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup-pricing.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-topup-pricing.service.ts similarity index 100% rename from apps/bff/src/modules/subscriptions/sim-management/services/sim-topup-pricing.service.ts rename to apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-topup-pricing.service.ts diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-usage.service.ts similarity index 94% rename from apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts rename to apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-usage.service.ts index 7bc23b1f..6844c44c 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/queries/sim-usage.service.ts @@ -1,15 +1,15 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js"; -import { SimValidationService } from "./sim-validation.service.js"; -import { SimUsageStoreService } from "../../sim-usage-store.service.js"; +import { SimValidationService } from "../sim-validation.service.js"; +import { SimUsageStoreService } from "../../../sim-usage-store.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import type { SimTopUpHistory, SimUsage, SimTopUpHistoryRequest, } from "@customer-portal/domain/sim"; -import { SimScheduleService } from "./sim-schedule.service.js"; +import { SimScheduleService } from "../support/sim-schedule.service.js"; @Injectable() export class SimUsageService { diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-formatter.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-formatter.service.ts deleted file mode 100644 index 72b58c66..00000000 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-formatter.service.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Injectable } from "@nestjs/common"; - -/** - * Service for formatting call history data for display. - * Handles time, duration, and phone number formatting. - */ -@Injectable() -export class SimCallHistoryFormatterService { - /** - * Convert HHMMSS to HH:MM:SS display format - */ - formatTime(timeStr: string): string { - if (!timeStr || timeStr.length < 6) return timeStr; - const clean = timeStr.replace(/[^0-9]/g, "").padStart(6, "0"); - return `${clean.slice(0, 2)}:${clean.slice(2, 4)}:${clean.slice(4, 6)}`; - } - - /** - * Convert seconds to readable duration format (Xh Ym Zs) - */ - formatDuration(seconds: number): string { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}h ${minutes}m ${secs}s`; - } else if (minutes > 0) { - return `${minutes}m ${secs}s`; - } else { - return `${secs}s`; - } - } - - /** - * Format Japanese phone numbers with appropriate separators - */ - formatPhoneNumber(phone: string): string { - if (!phone) return phone; - const clean = phone.replace(/[^0-9+]/g, ""); - - // 080-XXXX-XXXX or 070-XXXX-XXXX or 090-XXXX-XXXX format - if ( - clean.length === 11 && - (clean.startsWith("080") || clean.startsWith("070") || clean.startsWith("090")) - ) { - return `${clean.slice(0, 3)}-${clean.slice(3, 7)}-${clean.slice(7)}`; - } - - // 03-XXXX-XXXX format (landline) - if (clean.length === 10 && clean.startsWith("0")) { - return `${clean.slice(0, 2)}-${clean.slice(2, 6)}-${clean.slice(6)}`; - } - - return clean; - } - - /** - * Get default month for call history queries (2 months ago) - */ - getDefaultMonth(): string { - const now = new Date(); - now.setMonth(now.getMonth() - 2); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - return `${year}-${month}`; - } - - /** - * Format a date to YYYY-MM format - */ - formatYearMonth(date: Date): string { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - return `${year}-${month}`; - } - - /** - * Convert YYYYMM to YYYY-MM format - */ - normalizeMonth(yearMonth: string): string { - return `${yearMonth.slice(0, 4)}-${yearMonth.slice(4, 6)}`; - } -} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-parser.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-parser.service.ts deleted file mode 100644 index 9b7db675..00000000 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history-parser.service.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { Injectable } from "@nestjs/common"; - -// SmsType enum to match Prisma schema -type SmsType = "DOMESTIC" | "INTERNATIONAL"; - -export interface DomesticCallRecord { - account: string; - callDate: Date; - callTime: string; - calledTo: string; - location: string | null; - durationSec: number; - chargeYen: number; - month: string; -} - -export interface InternationalCallRecord { - account: string; - callDate: Date; - startTime: string; - stopTime: string | null; - country: string | null; - calledTo: string; - durationSec: number; - chargeYen: number; - month: string; -} - -export interface SmsRecord { - account: string; - smsDate: Date; - smsTime: string; - sentTo: string; - smsType: SmsType; - month: string; -} - -export interface ParsedTalkDetail { - domestic: DomesticCallRecord[]; - international: InternationalCallRecord[]; - skipped: number; - errors: number; -} - -export interface ParsedSmsDetail { - records: SmsRecord[]; - skipped: number; - errors: number; -} - -/** - * Service for parsing call history CSV files from Freebit SFTP. - * Handles the specific CSV formats for talk detail and SMS detail files. - * - * Talk Detail CSV Columns: - * 1. Customer phone number - * 2. Date (YYYYMMDD) - * 3. Start time (HHMMSS) - * 4. Called to phone number - * 5. dome/tointl (call type) - * 6. Location - * 7. Duration (MMSST format - minutes, seconds, tenths) - * 8. Tokens (each token = 10 yen) - * 9. Alternative charge (if location is "他社") - * - * SMS Detail CSV Columns: - * 1. Customer phone number - * 2. Date (YYYYMMDD) - * 3. Start time (HHMMSS) - * 4. SMS sent to phone number - * 5. dome/tointl - * 6. SMS type (SMS or 国際SMS) - */ -@Injectable() -export class SimCallHistoryParserService { - /** - * Parse talk detail CSV content into domestic and international call records - */ - parseTalkDetailCsv(content: string, month: string): ParsedTalkDetail { - const domestic: DomesticCallRecord[] = []; - const international: InternationalCallRecord[] = []; - let skipped = 0; - let errors = 0; - - const lines = content.split(/\r?\n/).filter(line => line.trim()); - - for (const line of lines) { - try { - const columns = this.parseCsvLine(line); - - if (columns.length < 8) { - skipped++; - continue; - } - - const [ - phoneNumber = "", - dateStr = "", - timeStr = "", - calledTo = "", - callType = "", - location = "", - durationStr = "", - tokensStr = "", - altChargeStr = "", - ] = columns; - - // Parse date - const callDate = this.parseDate(dateStr); - if (!callDate) { - skipped++; - continue; - } - - // Parse duration - format is MMSST (minutes, seconds, tenths) - // e.g., 36270 = 36 min 27.0 sec, 320 = 0 min 32.0 sec - const durationSec = this.parseDuration(durationStr); - - // Parse charge: use tokens * 10 yen, or alt charge if location is "他社" - const chargeYen = this.parseCharge(location, tokensStr, altChargeStr); - - // Clean account number (remove dashes, spaces) - const account = this.cleanPhoneNumber(phoneNumber); - - // Clean called-to number - const cleanCalledTo = this.cleanPhoneNumber(calledTo); - - if (callType === "dome" || callType === "domestic") { - domestic.push({ - account, - callDate, - callTime: timeStr, - calledTo: cleanCalledTo, - location: location || null, - durationSec, - chargeYen, - month, - }); - } else if (callType === "tointl" || callType === "international") { - international.push({ - account, - callDate, - startTime: timeStr, - stopTime: null, - country: location || null, - calledTo: cleanCalledTo, - durationSec, - chargeYen, - month, - }); - } else { - skipped++; - } - } catch { - errors++; - } - } - - return { domestic, international, skipped, errors }; - } - - /** - * Parse SMS detail CSV content - */ - parseSmsDetailCsv(content: string, month: string): ParsedSmsDetail { - const records: SmsRecord[] = []; - let skipped = 0; - let errors = 0; - - const lines = content.split(/\r?\n/).filter(line => line.trim()); - - for (const line of lines) { - try { - const columns = this.parseCsvLine(line); - - if (columns.length < 6) { - skipped++; - continue; - } - - const [phoneNumber = "", dateStr = "", timeStr = "", sentTo = "", , smsTypeStr = ""] = - columns; - - // Parse date - const smsDate = this.parseDate(dateStr); - if (!smsDate) { - skipped++; - continue; - } - - // Clean account number - const account = this.cleanPhoneNumber(phoneNumber); - - // Clean sent-to number - const cleanSentTo = this.cleanPhoneNumber(sentTo); - - // Determine SMS type - const smsType: SmsType = smsTypeStr.includes("国際") ? "INTERNATIONAL" : "DOMESTIC"; - - records.push({ - account, - smsDate, - smsTime: timeStr, - sentTo: cleanSentTo, - smsType, - month, - }); - } catch { - errors++; - } - } - - return { records, skipped, errors }; - } - - /** - * Parse a CSV line handling quoted fields and escaped quotes - */ - private parseCsvLine(line: string): string[] { - const normalizedLine = line.replace(/\r$/, "").replace(/^\uFEFF/, ""); - const result: string[] = []; - let current = ""; - let inQuotes = false; - - for (let i = 0; i < normalizedLine.length; i++) { - const char = normalizedLine[i]; - if (char === '"') { - if (inQuotes && normalizedLine[i + 1] === '"') { - current += '"'; - i++; - continue; - } - inQuotes = !inQuotes; - continue; - } - if (char === "," && !inQuotes) { - result.push(current.trim()); - current = ""; - continue; - } - current += char; - } - result.push(current.trim()); - - return result; - } - - /** - * Parse YYYYMMDD date string to Date object - */ - private parseDate(dateStr: string): Date | null { - if (!dateStr || dateStr.length < 8) return null; - - const clean = dateStr.replace(/[^0-9]/g, ""); - if (clean.length < 8) return null; - - const year = Number.parseInt(clean.slice(0, 4), 10); - const month = Number.parseInt(clean.slice(4, 6), 10) - 1; - const day = Number.parseInt(clean.slice(6, 8), 10); - - if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) return null; - - return new Date(year, month, day); - } - - /** - * Parse duration string (MMSST format) to seconds - */ - private parseDuration(durationStr: string): number { - const durationVal = durationStr.padStart(5, "0"); - const minutes = Number.parseInt(durationVal.slice(0, -3), 10) || 0; - const seconds = Number.parseInt(durationVal.slice(-3, -1), 10) || 0; - return minutes * 60 + seconds; - } - - /** - * Parse charge from tokens or alternative charge - */ - private parseCharge( - location: string | undefined, - tokensStr: string, - altChargeStr: string | undefined - ): number { - if (location && location.includes("他社") && altChargeStr) { - return Number.parseInt(altChargeStr, 10) || 0; - } - return (Number.parseInt(tokensStr, 10) || 0) * 10; - } - - /** - * Clean phone number by removing dashes and spaces - */ - private cleanPhoneNumber(phone: string): string { - return phone.replace(/[-\s]/g, ""); - } -} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts deleted file mode 100644 index 1eb03c04..00000000 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-call-history.service.ts +++ /dev/null @@ -1,419 +0,0 @@ -import { Injectable, Inject } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { PrismaService } from "@bff/infra/database/prisma.service.js"; -import { SftpClientService } from "@bff/integrations/sftp/sftp-client.service.js"; -import { SimValidationService } from "./sim-validation.service.js"; -import { SimCallHistoryParserService } from "./sim-call-history-parser.service.js"; -import { SimCallHistoryFormatterService } from "./sim-call-history-formatter.service.js"; -import type { - SimDomesticCallHistoryResponse, - SimInternationalCallHistoryResponse, - SimSmsHistoryResponse, -} from "@customer-portal/domain/sim"; - -// Re-export types for consumers -export type { - DomesticCallRecord, - InternationalCallRecord, - SmsRecord, -} from "./sim-call-history-parser.service.js"; - -// SmsType enum to match Prisma schema -type SmsType = "DOMESTIC" | "INTERNATIONAL"; - -export interface CallHistoryPagination { - page: number; - limit: number; - total: number; - totalPages: number; -} - -/** - * Service for managing SIM call history data. - * Coordinates importing data from SFTP and querying stored history. - */ -@Injectable() -export class SimCallHistoryService { - constructor( - private readonly prisma: PrismaService, - private readonly sftp: SftpClientService, - private readonly simValidation: SimValidationService, - private readonly parser: SimCallHistoryParserService, - private readonly formatter: SimCallHistoryFormatterService, - @Inject(Logger) private readonly logger: Logger - ) {} - - /** - * Import call history from SFTP for a specific month - */ - async importCallHistory(yearMonth: string): Promise<{ - domestic: number; - international: number; - sms: number; - }> { - const month = this.formatter.normalizeMonth(yearMonth); - - this.logger.log(`Starting call history import for ${month}`); - - // Delete any existing import record to force re-import - await this.prisma.simHistoryImport.deleteMany({ - where: { month }, - }); - this.logger.log(`Cleared existing import record for ${month}`); - - let domesticCount = 0; - let internationalCount = 0; - let smsCount = 0; - - // Import talk detail (calls) - try { - const talkContent = await this.sftp.downloadTalkDetail(yearMonth); - const parsed = this.parser.parseTalkDetailCsv(talkContent, month); - - this.logger.log(`Parsed talk detail`, { - domestic: parsed.domestic.length, - international: parsed.international.length, - skipped: parsed.skipped, - errors: parsed.errors, - }); - - domesticCount = await this.processInBatches( - parsed.domestic, - 50, - async record => - this.prisma.simCallHistoryDomestic.upsert({ - where: { - account_callDate_callTime_calledTo: { - account: record.account, - callDate: record.callDate, - callTime: record.callTime, - calledTo: record.calledTo, - }, - }, - update: { - location: record.location, - durationSec: record.durationSec, - chargeYen: record.chargeYen, - }, - create: record, - }), - (record, error) => - this.logger.warn("Failed to store domestic call record", { record, error }) - ); - - internationalCount = await this.processInBatches( - parsed.international, - 50, - async record => - this.prisma.simCallHistoryInternational.upsert({ - where: { - account_callDate_startTime_calledTo: { - account: record.account, - callDate: record.callDate, - startTime: record.startTime, - calledTo: record.calledTo, - }, - }, - update: { - stopTime: record.stopTime, - country: record.country, - durationSec: record.durationSec, - chargeYen: record.chargeYen, - }, - create: record, - }), - (record, error) => - this.logger.warn("Failed to store international call record", { record, error }) - ); - - this.logger.log( - `Imported ${domesticCount} domestic and ${internationalCount} international calls` - ); - } catch (error) { - this.logger.error(`Failed to import talk detail`, { error, yearMonth }); - } - - // Import SMS detail - try { - const smsContent = await this.sftp.downloadSmsDetail(yearMonth); - const parsed = this.parser.parseSmsDetailCsv(smsContent, month); - - this.logger.log(`Parsed SMS detail`, { - records: parsed.records.length, - skipped: parsed.skipped, - errors: parsed.errors, - }); - - smsCount = await this.processInBatches( - parsed.records, - 50, - async record => - this.prisma.simSmsHistory.upsert({ - where: { - account_smsDate_smsTime_sentTo: { - account: record.account, - smsDate: record.smsDate, - smsTime: record.smsTime, - sentTo: record.sentTo, - }, - }, - update: { - smsType: record.smsType, - }, - create: record, - }), - (record, error) => this.logger.warn("Failed to store SMS record", { record, error }) - ); - - this.logger.log(`Imported ${smsCount} SMS records`); - } catch (error) { - this.logger.error(`Failed to import SMS detail`, { error, yearMonth }); - } - - // Record the import - await this.prisma.simHistoryImport.upsert({ - where: { month }, - update: { - talkFile: this.sftp.getTalkDetailFileName(yearMonth), - smsFile: this.sftp.getSmsDetailFileName(yearMonth), - talkRecords: domesticCount + internationalCount, - smsRecords: smsCount, - status: "completed", - importedAt: new Date(), - }, - create: { - month, - talkFile: this.sftp.getTalkDetailFileName(yearMonth), - smsFile: this.sftp.getSmsDetailFileName(yearMonth), - talkRecords: domesticCount + internationalCount, - smsRecords: smsCount, - status: "completed", - }, - }); - - return { domestic: domesticCount, international: internationalCount, sms: smsCount }; - } - - /** - * Get domestic call history for a user's SIM - */ - async getDomesticCallHistory( - userId: string, - subscriptionId: number, - month?: string, - page: number = 1, - limit: number = 50 - ): Promise { - await this.simValidation.validateSimSubscription(userId, subscriptionId); - // Dev/testing mode: call history data is currently sourced from a fixed account. - // TODO: Replace with the validated subscription account once call history data is available per user. - const account = "08077052946"; - - const targetMonth = month || this.formatter.getDefaultMonth(); - - const [calls, total] = await Promise.all([ - this.prisma.simCallHistoryDomestic.findMany({ - where: { account, month: targetMonth }, - orderBy: [{ callDate: "desc" }, { callTime: "desc" }], - skip: (page - 1) * limit, - take: limit, - }), - this.prisma.simCallHistoryDomestic.count({ - where: { account, month: targetMonth }, - }), - ]); - - return { - calls: calls.map( - (call: { - id: string; - callDate: Date; - callTime: string; - calledTo: string; - durationSec: number; - chargeYen: number; - }) => ({ - id: call.id, - date: call.callDate.toISOString().split("T")[0] ?? "", - time: this.formatter.formatTime(call.callTime), - calledTo: this.formatter.formatPhoneNumber(call.calledTo), - callLength: this.formatter.formatDuration(call.durationSec), - callCharge: call.chargeYen, - }) - ), - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit), - }, - month: targetMonth, - }; - } - - /** - * Get international call history for a user's SIM - */ - async getInternationalCallHistory( - userId: string, - subscriptionId: number, - month?: string, - page: number = 1, - limit: number = 50 - ): Promise { - await this.simValidation.validateSimSubscription(userId, subscriptionId); - const account = "08077052946"; - - const targetMonth = month || this.formatter.getDefaultMonth(); - - const [calls, total] = await Promise.all([ - this.prisma.simCallHistoryInternational.findMany({ - where: { account, month: targetMonth }, - orderBy: [{ callDate: "desc" }, { startTime: "desc" }], - skip: (page - 1) * limit, - take: limit, - }), - this.prisma.simCallHistoryInternational.count({ - where: { account, month: targetMonth }, - }), - ]); - - return { - calls: calls.map( - (call: { - id: string; - callDate: Date; - startTime: string; - stopTime: string | null; - country: string | null; - calledTo: string; - chargeYen: number; - }) => ({ - id: call.id, - date: call.callDate.toISOString().split("T")[0] ?? "", - startTime: this.formatter.formatTime(call.startTime), - stopTime: call.stopTime ? this.formatter.formatTime(call.stopTime) : null, - country: call.country, - calledTo: this.formatter.formatPhoneNumber(call.calledTo), - callCharge: call.chargeYen, - }) - ), - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit), - }, - month: targetMonth, - }; - } - - /** - * Get SMS history for a user's SIM - */ - async getSmsHistory( - userId: string, - subscriptionId: number, - month?: string, - page: number = 1, - limit: number = 50 - ): Promise { - await this.simValidation.validateSimSubscription(userId, subscriptionId); - const account = "08077052946"; - - const targetMonth = month || this.formatter.getDefaultMonth(); - - const [messages, total] = await Promise.all([ - this.prisma.simSmsHistory.findMany({ - where: { account, month: targetMonth }, - orderBy: [{ smsDate: "desc" }, { smsTime: "desc" }], - skip: (page - 1) * limit, - take: limit, - }), - this.prisma.simSmsHistory.count({ - where: { account, month: targetMonth }, - }), - ]); - - return { - messages: messages.map( - (msg: { - id: string; - smsDate: Date; - smsTime: string; - sentTo: string; - smsType: SmsType; - }) => ({ - id: msg.id, - date: msg.smsDate.toISOString().split("T")[0] ?? "", - time: this.formatter.formatTime(msg.smsTime), - sentTo: this.formatter.formatPhoneNumber(msg.sentTo), - type: msg.smsType === "INTERNATIONAL" ? "International SMS" : "SMS", - }) - ), - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit), - }, - month: targetMonth, - }; - } - - /** - * Get available months for history - */ - async getAvailableMonths(): Promise { - this.logger.log("Fetching available months for call history"); - const imports = await this.prisma.simHistoryImport.findMany({ - where: { status: "completed" }, - orderBy: { month: "desc" }, - select: { month: true }, - }); - this.logger.log(`Found ${imports.length} completed imports`, { months: imports }); - return imports.map((i: { month: string }) => i.month); - } - - /** - * List available files on SFTP server - */ - async listSftpFiles(path: string = "/"): Promise { - try { - return await this.sftp.listFiles(path); - } catch (error) { - this.logger.error("Failed to list SFTP files", { error, path }); - throw error; - } - } - - /** - * Process records in batches with error handling - */ - private async processInBatches( - records: T[], - batchSize: number, - handler: (record: T) => Promise, - onError: (record: T, error: unknown) => void - ): Promise { - let successCount = 0; - - for (let i = 0; i < records.length; i += batchSize) { - const batch = records.slice(i, i + batchSize); - const results = await Promise.allSettled(batch.map(async record => handler(record))); - - for (const [index, result] of results.entries()) { - if (result.status === "fulfilled") { - successCount++; - } else { - const record = batch[index]; - if (record !== undefined) { - onError(record, result.reason); - } - } - } - } - - return successCount; - } -} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts deleted file mode 100644 index ede90dbb..00000000 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { Injectable, Inject, BadRequestException } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { ConfigService } from "@nestjs/config"; -import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js"; -import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js"; -import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js"; -import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js"; -import { SimValidationService } from "./sim-validation.service.js"; -import { SubscriptionValidationCoordinator } from "../../shared/index.js"; -import type { - SimCancelRequest, - SimCancelFullRequest, - SimCancellationMonth, - SimCancellationPreview, -} from "@customer-portal/domain/sim"; -import { - generateCancellationMonths, - getCancellationEffectiveDate, - getRunDateFromMonth, -} from "@customer-portal/domain/subscriptions"; -import { SIM_CANCELLATION_NOTICE } from "@customer-portal/domain/opportunity"; -import { SimScheduleService } from "./sim-schedule.service.js"; -import { SimNotificationService } from "./sim-notification.service.js"; -import { NotificationService } from "@bff/modules/notifications/notifications.service.js"; -import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications"; - -@Injectable() -export class SimCancellationService { - constructor( - private readonly freebitService: FreebitFacade, - private readonly whmcsClientService: WhmcsClientService, - private readonly validationCoordinator: SubscriptionValidationCoordinator, - private readonly opportunityService: SalesforceOpportunityService, - private readonly workflowCases: WorkflowCaseManager, - private readonly simValidation: SimValidationService, - private readonly simSchedule: SimScheduleService, - private readonly simNotification: SimNotificationService, - private readonly notifications: NotificationService, - private readonly configService: ConfigService, - @Inject(Logger) private readonly logger: Logger - ) {} - - private get freebitBaseUrl(): string { - return this.configService.get("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api"; - } - - /** - * Calculate minimum contract end date (3 months after start, signup month not included) - */ - private calculateMinimumContractEndDate(startDateStr: string): Date | null { - if (!startDateStr || startDateStr.length < 8) return null; - - // Parse YYYYMMDD format - const year = Number.parseInt(startDateStr.slice(0, 4), 10); - const month = Number.parseInt(startDateStr.slice(4, 6), 10) - 1; - const day = Number.parseInt(startDateStr.slice(6, 8), 10); - - if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) return null; - - const startDate = new Date(year, month, day); - // Minimum term is 3 months after signup month (signup month not included) - // e.g., signup in January = minimum term ends April 30 - const endDate = new Date(startDate.getFullYear(), startDate.getMonth() + 4, 0); - - return endDate; - } - - /** - * Get cancellation preview with available months - */ - async getCancellationPreview( - userId: string, - subscriptionId: number - ): Promise { - const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); - const simDetails = await this.freebitService.getSimDetails(validation.account); - - // Get customer info from WHMCS via coordinator - const whmcsClientId = await this.validationCoordinator.getWhmcsClientId(userId); - const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId); - const customerName = - `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer"; - const customerEmail = clientDetails.email || ""; - - // Calculate minimum contract end date - const startDate = simDetails.startDate; - const minEndDate = startDate ? this.calculateMinimumContractEndDate(startDate) : null; - const today = new Date(); - const isWithinMinimumTerm = minEndDate ? today < minEndDate : false; - - // Format minimum contract end date for display - let minimumContractEndDate: string | undefined; - if (minEndDate) { - const year = minEndDate.getFullYear(); - const month = String(minEndDate.getMonth() + 1).padStart(2, "0"); - minimumContractEndDate = `${year}-${month}`; - } - - return { - simNumber: validation.account, - serialNumber: simDetails.iccid, - planCode: simDetails.planCode, - startDate, - minimumContractEndDate, - isWithinMinimumTerm, - availableMonths: generateCancellationMonths({ - includeRunDate: true, - }) as SimCancellationMonth[], - customerEmail, - customerName, - }; - } - - /** - * Cancel SIM service (legacy) - */ - async cancelSim( - userId: string, - subscriptionId: number, - request: SimCancelRequest = {} - ): Promise { - let account = ""; - - await this.simNotification.runWithNotification( - "Cancel SIM", - { - baseContext: { - userId, - subscriptionId, - scheduledAt: request.scheduledAt, - }, - enrichSuccess: result => ({ - account: result.account, - runDate: result.runDate, - }), - enrichError: () => ({ - account, - }), - }, - async () => { - const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); - account = validation.account; - - const scheduleResolution = this.simSchedule.resolveScheduledDate(request.scheduledAt); - - await this.freebitService.cancelSim(account, scheduleResolution.date); - - this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, { - userId, - subscriptionId, - account, - runDate: scheduleResolution.date, - }); - - return { - account, - runDate: scheduleResolution.date, - }; - } - ); - } - - /** - * Cancel SIM service with full flow (PA02-04, Salesforce Case + Opportunity, and email notifications) - * - * Flow: - * 1. Validate SIM subscription - * 2. Call Freebit PA02-04 API to schedule cancellation - * 3. Create Salesforce Case with all form details - * 4. Update Salesforce Opportunity (if linked) - * 5. Send email notifications - */ - async cancelSimFull( - userId: string, - subscriptionId: number, - request: SimCancelFullRequest - ): Promise { - // Validate account mapping via coordinator - const { whmcsClientId, sfAccountId } = - await this.validationCoordinator.validateAccountMapping(userId); - - const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); - const account = validation.account; - const simDetails = await this.freebitService.getSimDetails(account); - - // Get customer info from WHMCS - const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId); - const customerName = - `${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer"; - const customerEmail = clientDetails.email || ""; - - // Validate confirmations - if (!request.confirmRead || !request.confirmCancel) { - throw new BadRequestException("You must confirm both checkboxes to proceed"); - } - - // Calculate runDate and cancellation date using shared utilities - let runDate: string; - let cancellationDate: string; - try { - runDate = getRunDateFromMonth(request.cancellationMonth); - cancellationDate = getCancellationEffectiveDate(request.cancellationMonth); - } catch { - throw new BadRequestException("Invalid cancellation month format"); - } - - this.logger.log(`Processing SIM cancellation via PA02-04`, { - userId, - subscriptionId, - account, - cancellationMonth: request.cancellationMonth, - runDate, - }); - - // Call PA02-04 cancellation API - await this.freebitService.cancelAccount(account, runDate); - - this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, { - userId, - subscriptionId, - account, - runDate, - }); - - // Resolve OpportunityId via coordinator - const opportunityResolution = await this.validationCoordinator.resolveOpportunityId( - subscriptionId, - { fallbackToSalesforce: true } - ); - const opportunityId = opportunityResolution.opportunityId; - - // Create Salesforce Case for cancellation via workflow manager - await this.workflowCases.notifySimCancellation({ - accountId: sfAccountId, - ...(opportunityId ? { opportunityId } : {}), - simAccount: account, - iccid: simDetails.iccid || "N/A", - subscriptionId, - cancellationMonth: request.cancellationMonth, - serviceEndDate: cancellationDate, - ...(request.comments ? { comments: request.comments } : {}), - }); - - this.logger.log("SIM cancellation case created via WorkflowCaseManager", { - sfAccountIdTail: sfAccountId.slice(-4), - opportunityId: opportunityId ? opportunityId.slice(-4) : null, - }); - - // Use a placeholder caseId for notification since workflow manager doesn't return it - const caseId = `cancellation:sim:${subscriptionId}:${request.cancellationMonth}`; - - // Update Salesforce Opportunity (if linked via WHMCS_Service_ID__c) - if (opportunityId) { - try { - const cancellationData = { - scheduledCancellationDate: `${cancellationDate}T23:59:59.000Z`, - cancellationNotice: SIM_CANCELLATION_NOTICE.RECEIVED, - }; - - await this.opportunityService.updateSimCancellationData(opportunityId, cancellationData); - - this.logger.log("Opportunity updated with SIM cancellation data", { - opportunityId, - scheduledDate: cancellationDate, - }); - } catch (error) { - // Log but don't fail - Freebit API was already called successfully - this.logger.warn("Failed to update Opportunity with SIM cancellation data", { - error: error instanceof Error ? error.message : String(error), - subscriptionId, - opportunityId, - }); - } - } else { - this.logger.debug("No Opportunity linked to SIM subscription", { subscriptionId }); - } - - try { - await this.notifications.createNotification({ - userId, - type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED, - source: NOTIFICATION_SOURCE.SYSTEM, - sourceId: caseId || opportunityId || `sim:${subscriptionId}:${runDate}`, - actionUrl: `/account/services/${subscriptionId}`, - }); - } catch (error) { - this.logger.warn("Failed to create SIM cancellation notification", { - userId, - subscriptionId, - account, - runDate, - error: error instanceof Error ? error.message : String(error), - }); - } - - // Send admin notification email - const adminEmailBody = this.simNotification.buildCancellationAdminEmail({ - customerName, - simNumber: account, - ...(simDetails.iccid === undefined ? {} : { serialNumber: simDetails.iccid }), - cancellationMonth: request.cancellationMonth, - registeredEmail: customerEmail, - ...(request.comments === undefined ? {} : { comments: request.comments }), - }); - - await this.simNotification.sendApiResultsEmail( - "SonixNet SIM Online Cancellation", - [ - { - url: `${this.freebitBaseUrl}/master/cnclAcnt/`, - json: { - kind: "MVNO", - account, - runDate, - authKey: "[REDACTED]", - }, - result: { - resultCode: "100", - status: { message: "OK", statusCode: "200" }, - }, - }, - ], - adminEmailBody - ); - - // Send confirmation email to customer - const confirmationSubject = "SonixNet SIM Cancellation Confirmation"; - const confirmationBody = `Dear ${customerName}, - -Your cancellation request for SIM #${account} has been confirmed. - -The cancellation will take effect at the end of ${request.cancellationMonth}. - -If you have any questions, please contact us at info@asolutions.co.jp - -With best regards, -Assist Solutions Customer Support -TEL: 0120-660-470 (Mon-Fri / 10AM-6PM) -Email: info@asolutions.co.jp`; - - await this.simNotification.sendCustomerEmail( - customerEmail, - confirmationSubject, - confirmationBody - ); - } -} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-notification.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-notification.service.ts deleted file mode 100644 index a5e41b46..00000000 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-notification.service.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { Injectable, Inject } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { ConfigService } from "@nestjs/config"; -import { EmailService } from "@bff/infra/email/email.service.js"; -import { extractErrorMessage } from "@bff/core/utils/error.util.js"; -import type { SimNotificationContext } from "../interfaces/sim-base.interface.js"; - -const ADMIN_EMAIL = "info@asolutions.co.jp"; - -/** - * API call log structure for notification emails - */ -export interface ApiCallLog { - url: string; - senddata?: Record | string; - json?: Record | string; - result: Record | string; -} - -/** - * Unified SIM notification service. - * Handles all SIM-related email notifications including: - * - Internal action notifications (success/error alerts) - * - API results notifications to admin - * - Customer-facing emails (eSIM reissue, cancellation confirmations) - */ -@Injectable() -export class SimNotificationService { - constructor( - @Inject(Logger) private readonly logger: Logger, - private readonly email: EmailService, - private readonly configService: ConfigService - ) {} - - // ============================================================================ - // Internal Action Notifications - // ============================================================================ - - /** - * Send notification for SIM actions to configured alert email - */ - async notifySimAction( - action: string, - status: "SUCCESS" | "ERROR", - context: SimNotificationContext - ): Promise { - const subject = `[SIM ACTION] ${action} - ${status}`; - const toAddress = this.configService.get("SIM_ALERT_EMAIL_TO"); - const fromAddress = this.configService.get("SIM_ALERT_EMAIL_FROM"); - - if (!toAddress || !fromAddress) { - this.logger.debug("SIM action notification skipped: email config missing", { - action, - status, - }); - return; - } - - const publicContext = this.redactSensitiveFields(context); - - try { - const lines: string[] = [ - `Action: ${action}`, - `Result: ${status}`, - `Timestamp: ${new Date().toISOString()}`, - "", - "Context:", - JSON.stringify(publicContext, null, 2), - ]; - await this.email.sendEmail({ - to: toAddress, - from: fromAddress, - subject, - text: lines.join("\n"), - }); - } catch (err) { - this.logger.warn("Failed to send SIM action notification email", { - action, - status, - error: extractErrorMessage(err), - }); - } - } - - // ============================================================================ - // API Results Notifications (Admin) - // ============================================================================ - - /** - * Send API results notification email to admin - */ - async sendApiResultsEmail( - subject: string, - apiCalls: ApiCallLog[], - additionalInfo?: string - ): Promise { - try { - const lines: string[] = []; - - for (const call of apiCalls) { - lines.push(`url: ${call.url}`); - lines.push(""); - - if (call.senddata) { - const senddataStr = - typeof call.senddata === "string" - ? call.senddata - : JSON.stringify(call.senddata, null, 2); - lines.push(`senddata: ${senddataStr}`); - lines.push(""); - } - - if (call.json) { - const jsonStr = - typeof call.json === "string" ? call.json : JSON.stringify(call.json, null, 2); - lines.push(`json: ${jsonStr}`); - lines.push(""); - } - - const resultStr = - typeof call.result === "string" ? call.result : JSON.stringify(call.result, null, 2); - lines.push(`result: ${resultStr}`); - lines.push(""); - lines.push("---"); - lines.push(""); - } - - if (additionalInfo) { - lines.push(additionalInfo); - } - - await this.email.sendEmail({ - to: ADMIN_EMAIL, - from: ADMIN_EMAIL, - subject, - text: lines.join("\n"), - }); - - this.logger.log("Sent API results notification email", { - subject, - to: ADMIN_EMAIL, - callCount: apiCalls.length, - }); - } catch (err) { - this.logger.warn("Failed to send API results notification email", { - subject, - error: extractErrorMessage(err), - }); - } - } - - // ============================================================================ - // Customer Notifications - // ============================================================================ - - /** - * Send customer notification email - */ - async sendCustomerEmail(to: string, subject: string, body: string): Promise { - try { - await this.email.sendEmail({ - to, - from: ADMIN_EMAIL, - subject, - text: body, - }); - - this.logger.log("Sent customer notification email", { - subject, - to, - }); - } catch (err) { - this.logger.warn("Failed to send customer notification email", { - subject, - to, - error: extractErrorMessage(err), - }); - } - } - - // ============================================================================ - // Email Body Builders - // ============================================================================ - - /** - * Build eSIM reissue customer email body - */ - buildEsimReissueEmail(customerName: string, simNumber: string, newEid: string): string { - return `Dear ${customerName}, - -This is to confirm that your request to re-issue the SIM card ${simNumber} -to the EID=${newEid} has been accepted. - -Please download the SIM plan, then follow the instructions to install the APN profile. - -eSIM plan download: https://www.asolutions.co.jp/uploads/pdf/esim.pdf -APN profile instructions: https://www.asolutions.co.jp/sim-card/ - -With best regards, -Assist Solutions Customer Support -TEL: 0120-660-470 (Mon-Fri / 10AM-6PM) -Email: ${ADMIN_EMAIL}`; - } - - /** - * Build physical SIM reissue customer email body - */ - buildPhysicalSimReissueEmail(customerName: string, simNumber: string): string { - return `Dear ${customerName}, - -This is to confirm that your request to re-issue the SIM card ${simNumber} -as a physical SIM has been accepted. - -You will be contacted by us again as soon as details about the shipping -schedule can be disclosed (typically in 3-5 business days). - -With best regards, -Assist Solutions Customer Support -TEL: 0120-660-470 (Mon-Fri / 10AM-6PM) -Email: ${ADMIN_EMAIL}`; - } - - /** - * Build cancellation notification email body for admin - */ - buildCancellationAdminEmail(params: { - customerName: string; - simNumber: string; - serialNumber?: string; - cancellationMonth: string; - registeredEmail: string; - comments?: string; - }): string { - return `The following SONIXNET SIM cancellation has been requested. - -Customer name: ${params.customerName} -SIM #: ${params.simNumber} -Serial #: ${params.serialNumber || "N/A"} -Cancellation month: ${params.cancellationMonth} -Registered email address: ${params.registeredEmail} -Comments: ${params.comments || "N/A"}`; - } - - // ============================================================================ - // Error Message Utilities - // ============================================================================ - - /** - * Convert technical errors to user-friendly messages for SIM operations - */ - getUserFriendlySimError(technicalError: string): string { - if (!technicalError) { - return "SIM operation failed. Please try again or contact support."; - } - - const errorLower = technicalError.toLowerCase(); - - // Freebit API errors - if (errorLower.includes("api error: ng") || errorLower.includes("account not found")) { - return "SIM account not found. Please contact support to verify your SIM configuration."; - } - - if (errorLower.includes("authentication failed") || errorLower.includes("auth")) { - return "SIM service is temporarily unavailable. Please try again later."; - } - - if (errorLower.includes("timeout") || errorLower.includes("network")) { - return "SIM service request timed out. Please try again."; - } - - // WHMCS errors - if (errorLower.includes("invalid permissions") || errorLower.includes("not allowed")) { - return "SIM service is temporarily unavailable. Please contact support for assistance."; - } - - // Generic errors - if (errorLower.includes("failed") || errorLower.includes("error")) { - return "SIM operation failed. Please try again or contact support."; - } - - // Default fallback - return "SIM operation failed. Please try again or contact support."; - } - - // ============================================================================ - // Action Runner (for wrapping operations with notifications) - // ============================================================================ - - /** - * Run an operation with automatic success/error notifications. - * Replaces the separate SimActionRunnerService. - */ - async runWithNotification( - action: string, - options: { - baseContext: SimNotificationContext; - enrichSuccess?: (result: T) => Partial; - enrichError?: (error: unknown) => Partial; - }, - handler: () => Promise - ): Promise { - try { - const result = await handler(); - const successContext = { - ...options.baseContext, - ...(options.enrichSuccess ? options.enrichSuccess(result) : {}), - }; - await this.notifySimAction(action, "SUCCESS", successContext); - return result; - } catch (error) { - const errorContext = { - ...options.baseContext, - error: extractErrorMessage(error), - ...(options.enrichError ? options.enrichError(error) : {}), - }; - await this.notifySimAction(action, "ERROR", errorContext); - throw error; - } - } - - // ============================================================================ - // Private Helpers - // ============================================================================ - - /** - * Redact sensitive information from notification context - */ - private redactSensitiveFields(context: Record): Record { - const sanitized: Record = {}; - for (const [key, value] of Object.entries(context)) { - if (typeof key === "string" && key.toLowerCase().includes("password")) { - sanitized[key] = "[REDACTED]"; - continue; - } - - if (typeof value === "string" && value.length > 200) { - sanitized[key] = `${value.slice(0, 200)}…`; - continue; - } - - sanitized[key] = value; - } - return sanitized; - } -} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts index e90cfb65..788af4b5 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts @@ -1,11 +1,11 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { SimDetailsService } from "./sim-details.service.js"; -import { SimUsageService } from "./sim-usage.service.js"; -import { SimTopUpService } from "./sim-topup.service.js"; -import { SimPlanService } from "./sim-plan.service.js"; -import { SimCancellationService } from "./sim-cancellation.service.js"; -import { EsimManagementService } from "./esim-management.service.js"; +import { SimDetailsService } from "./queries/sim-details.service.js"; +import { SimUsageService } from "./queries/sim-usage.service.js"; +import { SimTopUpService } from "./mutations/sim-topup.service.js"; +import { SimPlanService } from "./mutations/sim-plan.service.js"; +import { SimCancellationService } from "./mutations/sim-cancellation.service.js"; +import { EsimManagementService } from "./mutations/esim-management.service.js"; import { SimValidationService } from "./sim-validation.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { simInfoSchema } from "@customer-portal/domain/sim"; 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 deleted file mode 100644 index 10e50f19..00000000 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { Injectable, Inject, BadRequestException } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { ConfigService } from "@nestjs/config"; -import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js"; -import { SimValidationService } from "./sim-validation.service.js"; -import type { - SimPlanChangeRequest, - SimFeaturesUpdateRequest, - SimChangePlanFullRequest, - SimAvailablePlan, -} from "@customer-portal/domain/sim"; -import { SimScheduleService } from "./sim-schedule.service.js"; -import { SimManagementQueueService } from "../queue/sim-management.queue.js"; -import { SimNotificationService } from "./sim-notification.service.js"; -import { SimServicesService } from "@bff/modules/services/application/sim-services.service.js"; - -// Mapping from Salesforce SKU to Freebit plan code -const SKU_TO_FREEBIT_PLAN_CODE: Record = { - "SIM-DATA-VOICE-5GB": "PASI_5G", - "SIM-DATA-VOICE-10GB": "PASI_10G", - "SIM-DATA-VOICE-25GB": "PASI_25G", - "SIM-DATA-VOICE-50GB": "PASI_50G", - "SIM-DATA-ONLY-5GB": "PASI_5G_DATA", - "SIM-DATA-ONLY-10GB": "PASI_10G_DATA", - "SIM-DATA-ONLY-25GB": "PASI_25G_DATA", - "SIM-DATA-ONLY-50GB": "PASI_50G_DATA", - "SIM-VOICE-ONLY": "PASI_VOICE", -}; - -// Reverse mapping: Freebit plan code to Salesforce SKU -const FREEBIT_PLAN_CODE_TO_SKU: Record = Object.fromEntries( - Object.entries(SKU_TO_FREEBIT_PLAN_CODE).map(([sku, code]) => [code, sku]) -); - -@Injectable() -export class SimPlanService { - constructor( - private readonly freebitService: FreebitFacade, - private readonly simValidation: SimValidationService, - private readonly simSchedule: SimScheduleService, - private readonly simQueue: SimManagementQueueService, - private readonly simNotification: SimNotificationService, - private readonly simCatalog: SimServicesService, - private readonly configService: ConfigService, - @Inject(Logger) private readonly logger: Logger - ) {} - - private get freebitBaseUrl(): string { - return this.configService.get("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api"; - } - - /** - * Get available plans for plan change - * Filters by current plan type (e.g., only show DataSmsVoice plans if current is DataSmsVoice) - */ - async getAvailablePlans(userId: string, subscriptionId: number): Promise { - const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); - const simDetails = await this.freebitService.getSimDetails(validation.account); - const currentPlanCode = simDetails.planCode; - const currentSku = FREEBIT_PLAN_CODE_TO_SKU[currentPlanCode]; - - // Get all plans from Salesforce - const allPlans = await this.simCatalog.getPlans(); - - // Determine current plan type - let currentPlanType: string | undefined; - if (currentSku) { - const currentPlan = allPlans.find(p => p.sku === currentSku); - currentPlanType = currentPlan?.simPlanType; - } - - // Filter plans by type (e.g., only show DataSmsVoice if current is DataSmsVoice) - const filteredPlans = currentPlanType - ? allPlans.filter(p => p.simPlanType === currentPlanType) - : allPlans.filter(p => !p.simHasFamilyDiscount); // Default: non-family plans - - // Map to SimAvailablePlan with Freebit codes - return filteredPlans.map(plan => { - const freebitPlanCode = SKU_TO_FREEBIT_PLAN_CODE[plan.sku] || plan.sku; - return { - ...plan, - freebitPlanCode, - isCurrentPlan: freebitPlanCode === currentPlanCode, - }; - }); - } - - /** - * Get Freebit plan code from Salesforce SKU - */ - getFreebitPlanCode(sku: string): string | undefined { - return SKU_TO_FREEBIT_PLAN_CODE[sku]; - } - - /** - * Get Salesforce SKU from Freebit plan code - */ - getSalesforceSku(planCode: string): string | undefined { - return FREEBIT_PLAN_CODE_TO_SKU[planCode]; - } - - /** - * Change SIM plan (basic) - */ - async changeSimPlan( - userId: string, - subscriptionId: number, - request: SimPlanChangeRequest - ): Promise<{ ipv4?: string; ipv6?: string }> { - let account = ""; - const assignGlobalIp = request.assignGlobalIp ?? false; - - const response = await this.simNotification.runWithNotification( - "Change Plan", - { - baseContext: { - userId, - subscriptionId, - newPlanCode: request.newPlanCode, - assignGlobalIp, - }, - enrichSuccess: result => ({ - account: result.account, - scheduledAt: result.scheduledAt, - }), - enrichError: () => ({ - account, - }), - }, - async () => { - const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); - account = validation.account; - - if (!request.newPlanCode || request.newPlanCode.length < 3) { - throw new BadRequestException("Invalid plan code"); - } - - const scheduleResolution = this.simSchedule.resolveScheduledDate(request.scheduledAt); - - this.logger.log("Submitting SIM plan change request", { - userId, - subscriptionId, - account, - newPlanCode: request.newPlanCode, - scheduledAt: scheduleResolution.date, - assignGlobalIp, - scheduleOrigin: scheduleResolution.source, - }); - - const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, { - assignGlobalIp, - scheduledAt: scheduleResolution.date, - }); - - this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, { - userId, - subscriptionId, - account, - newPlanCode: request.newPlanCode, - scheduledAt: scheduleResolution.date, - assignGlobalIp, - }); - - return { - ...result, - account, - scheduledAt: scheduleResolution.date, - }; - } - ); - - return { - ...(response.ipv4 === undefined ? {} : { ipv4: response.ipv4 }), - ...(response.ipv6 === undefined ? {} : { ipv6: response.ipv6 }), - }; - } - - /** - * Change SIM plan with enhanced notifications and Salesforce SKU mapping - */ - async changeSimPlanFull( - userId: string, - subscriptionId: number, - request: SimChangePlanFullRequest - ): Promise<{ ipv4?: string; ipv6?: string; scheduledAt?: string }> { - const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); - const account = validation.account; - - // Get or derive Freebit plan code from SKU - const freebitPlanCode = SKU_TO_FREEBIT_PLAN_CODE[request.newPlanSku] || request.newPlanCode; - - if (!freebitPlanCode || freebitPlanCode.length < 3) { - throw new BadRequestException("Invalid plan code"); - } - - // Always schedule for 1st of following month - const nextMonth = new Date(); - nextMonth.setMonth(nextMonth.getMonth() + 1); - nextMonth.setDate(1); - const year = nextMonth.getFullYear(); - const month = String(nextMonth.getMonth() + 1).padStart(2, "0"); - const scheduledAt = `${year}${month}01`; - - this.logger.log("Submitting SIM plan change request (full)", { - userId, - subscriptionId, - account, - newPlanSku: request.newPlanSku, - freebitPlanCode, - scheduledAt, - }); - - const result = await this.freebitService.changeSimPlan(account, freebitPlanCode, { - assignGlobalIp: request.assignGlobalIp ?? false, - scheduledAt, - }); - - this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, { - userId, - subscriptionId, - account, - newPlanCode: freebitPlanCode, - scheduledAt, - }); - - // Send API results email - await this.simNotification.sendApiResultsEmail( - "API results - Plan Change", - [ - { - url: `${this.freebitBaseUrl}/mvno/changePlan/`, - json: { - account, - planCode: freebitPlanCode, - runTime: scheduledAt, - authKey: "[REDACTED]", - }, - result: { - resultCode: "100", - status: { message: "OK", statusCode: "200" }, - ipv4: result.ipv4 || "", - ipv6: result.ipv6 || "", - }, - }, - ], - `Plan changed to: ${request.newPlanName || freebitPlanCode}\nScheduled for: ${scheduledAt}` - ); - - return { - ...(result.ipv4 === undefined ? {} : { ipv4: result.ipv4 }), - ...(result.ipv6 === undefined ? {} : { ipv6: result.ipv6 }), - scheduledAt, - }; - } - - /** - * Update SIM features (voicemail, call waiting, roaming, network type) - */ - async updateSimFeatures( - userId: string, - subscriptionId: number, - request: SimFeaturesUpdateRequest - ): Promise { - let account = ""; - - await this.simNotification.runWithNotification( - "Update Features", - { - baseContext: { - userId, - subscriptionId, - ...request, - }, - enrichSuccess: () => ({ - account, - }), - enrichError: () => ({ - account, - }), - }, - async () => { - const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); - account = validation.account; - - if (request.networkType && !["4G", "5G"].includes(request.networkType)) { - throw new BadRequestException('networkType must be either "4G" or "5G"'); - } - - const doVoice = - typeof request.voiceMailEnabled === "boolean" || - typeof request.callWaitingEnabled === "boolean" || - typeof request.internationalRoamingEnabled === "boolean"; - const doContract = typeof request.networkType === "string"; - - if (doVoice && doContract) { - await this.freebitService.updateSimFeatures(account, { - ...(request.voiceMailEnabled === undefined - ? {} - : { voiceMailEnabled: request.voiceMailEnabled }), - ...(request.callWaitingEnabled === undefined - ? {} - : { callWaitingEnabled: request.callWaitingEnabled }), - ...(request.internationalRoamingEnabled === undefined - ? {} - : { internationalRoamingEnabled: request.internationalRoamingEnabled }), - }); - - await this.simQueue.scheduleNetworkTypeChange({ - account, - networkType: request.networkType as "4G" | "5G", - userId, - subscriptionId, - }); - - this.logger.log("Scheduled contract line change via queue after voice option change", { - userId, - subscriptionId, - account, - networkType: request.networkType, - }); - } else { - // Filter out undefined values to satisfy exactOptionalPropertyTypes - const filteredRequest: { - voiceMailEnabled?: boolean; - callWaitingEnabled?: boolean; - internationalRoamingEnabled?: boolean; - networkType?: "4G" | "5G"; - } = {}; - if (request.voiceMailEnabled !== undefined) { - filteredRequest.voiceMailEnabled = request.voiceMailEnabled; - } - if (request.callWaitingEnabled !== undefined) { - filteredRequest.callWaitingEnabled = request.callWaitingEnabled; - } - if (request.internationalRoamingEnabled !== undefined) { - filteredRequest.internationalRoamingEnabled = request.internationalRoamingEnabled; - } - if (request.networkType !== undefined) { - filteredRequest.networkType = request.networkType; - } - await this.freebitService.updateSimFeatures(account, filteredRequest); - } - - this.logger.log(`Updated SIM features for subscription ${subscriptionId}`, { - userId, - subscriptionId, - account, - ...request, - }); - } - ); - } -} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-schedule.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-schedule.service.ts deleted file mode 100644 index 641c77cb..00000000 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-schedule.service.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { BadRequestException, Injectable } from "@nestjs/common"; - -type ScheduleResolution = { - date: string; - source: "user" | "auto"; -}; - -@Injectable() -export class SimScheduleService { - private static readonly DATE_REGEX = /^\d{8}$/; - - ensureYyyyMmDd(value: string | undefined, fieldName = "date"): string { - if (!value) { - throw new BadRequestException(`${fieldName} is required in YYYYMMDD format`); - } - if (!SimScheduleService.DATE_REGEX.test(value)) { - throw new BadRequestException(`${fieldName} must be in YYYYMMDD format`); - } - return value; - } - - validateOptionalYyyyMmDd(value: string | undefined, fieldName = "date"): string | undefined { - if (value == null) { - return undefined; - } - if (!SimScheduleService.DATE_REGEX.test(value)) { - throw new BadRequestException(`${fieldName} must be in YYYYMMDD format`); - } - return value; - } - - resolveScheduledDate(value?: string, fieldName = "scheduledAt"): ScheduleResolution { - if (value) { - return { - date: this.ensureYyyyMmDd(value, fieldName), - source: "user", - }; - } - return { - date: this.firstDayOfNextMonthYyyyMmDd(), - source: "auto", - }; - } - - firstDayOfNextMonth(): Date { - const nextMonth = new Date(); - nextMonth.setHours(0, 0, 0, 0); - nextMonth.setMonth(nextMonth.getMonth() + 1); - nextMonth.setDate(1); - return nextMonth; - } - - firstDayOfNextMonthYyyyMmDd(): string { - return this.formatYyyyMmDd(this.firstDayOfNextMonth()); - } - - firstDayOfNextMonthIsoDate(): string { - return this.formatIsoDate(this.firstDayOfNextMonth()); - } - - formatYyyyMmDd(date: Date): string { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}${month}${day}`; - } - - formatIsoDate(date: Date): string { - return date.toISOString().split("T")[0] ?? ""; - } -} 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 deleted file mode 100644 index fce87ffc..00000000 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { Injectable, Inject, BadRequestException } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { ConfigService } from "@nestjs/config"; -import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js"; -import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; -import { SimValidationService } from "./sim-validation.service.js"; -import { extractErrorMessage } from "@bff/core/utils/error.util.js"; -import type { SimTopUpRequest } from "@customer-portal/domain/sim"; -import { SimBillingService } from "./sim-billing.service.js"; -import { SimNotificationService } from "./sim-notification.service.js"; -import { SimTopUpPricingService } from "./sim-topup-pricing.service.js"; - -@Injectable() -export class SimTopUpService { - constructor( - private readonly freebitService: FreebitFacade, - private readonly mappingsService: MappingsService, - private readonly simValidation: SimValidationService, - private readonly simBilling: SimBillingService, - private readonly simNotification: SimNotificationService, - private readonly configService: ConfigService, - private readonly simTopUpPricing: SimTopUpPricingService, - @Inject(Logger) private readonly logger: Logger - ) {} - - private get whmcsBaseUrl(): string { - return ( - this.configService.get("WHMCS_BASE_URL") || - "https://accounts.asolutions.co.jp/includes/api.php" - ); - } - - private get freebitBaseUrl(): string { - return this.configService.get("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api"; - } - - /** - * Top up SIM data quota with payment processing - * Pricing: 1GB = 500 JPY - */ - async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise { - let latestAccount = ""; - - await this.simNotification.runWithNotification( - "Top Up Data", - { - baseContext: { - userId, - subscriptionId, - quotaMb: request.quotaMb, - }, - enrichSuccess: meta => ({ - account: meta.account, - costJpy: meta.costJpy, - invoiceId: meta.invoiceId, - transactionId: meta.transactionId, - }), - enrichError: () => ({ - account: latestAccount, - }), - }, - async () => { - const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); - latestAccount = validation.account; - - if (request.quotaMb <= 0) { - throw new BadRequestException("Quota must be greater than 0MB"); - } - - const pricing = await this.simTopUpPricing.getTopUpPricing(); - const minQuotaMb = pricing.minQuotaMb; - const maxQuotaMb = pricing.maxQuotaMb; - const quotaGb = request.quotaMb / 1000; - const units = Math.ceil(quotaGb); - const costJpy = units * pricing.pricePerGbJpy; - - if (request.quotaMb < minQuotaMb || request.quotaMb > maxQuotaMb) { - throw new BadRequestException( - `Quota must be between ${minQuotaMb}MB and ${maxQuotaMb}MB for Freebit API compatibility` - ); - } - - const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); - - this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, { - userId, - subscriptionId, - account: latestAccount, - quotaMb: request.quotaMb, - quotaGb: quotaGb.toFixed(2), - costJpy, - }); - - const billing = await this.simBilling.createOneTimeCharge({ - clientId: whmcsClientId, - userId, - description: `SIM Data Top-up: ${units}GB for ${latestAccount}`, - amountJpy: costJpy, - currency: "JPY", - dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - notes: `Subscription ID: ${subscriptionId}, Phone: ${latestAccount}`, - failureNotesPrefix: "Payment capture failed", - publicErrorMessage: "SIM top-up failed: payment could not be processed", - metadata: { subscriptionId }, - }); - - // Call Freebit API to add quota - let freebitResult: { - resultCode: string; - status: { message: string; statusCode: string }; - } | null = null; - try { - await this.freebitService.topUpSim(latestAccount, request.quotaMb, {}); - freebitResult = { resultCode: "100", status: { message: "OK", statusCode: "200" } }; - } catch (freebitError) { - await this.handleFreebitFailureAfterPayment( - freebitError, - billing.invoice, - billing.transactionId || "unknown", - userId, - subscriptionId, - latestAccount, - request.quotaMb - ); - } - - this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, { - userId, - subscriptionId, - account: latestAccount, - quotaMb: request.quotaMb, - costJpy, - invoiceId: billing.invoice.id, - transactionId: billing.transactionId, - }); - - // Send API results email notification - const today = new Date(); - const dateStr = today.toISOString().split("T")[0]; - const dueDate = new Date(today.getTime() + 2 * 24 * 60 * 60 * 1000) - .toISOString() - .split("T")[0]; - - await this.simNotification.sendApiResultsEmail("API results", [ - { - url: this.whmcsBaseUrl, - senddata: { - itemdescription1: `Top-up data (${units}GB)\nSIM Number: ${latestAccount}`, - itemamount1: String(costJpy), - userid: String(whmcsClientId), - date: dateStr, - responsetype: "json", - itemtaxed1: "1", - action: "CreateInvoice", - duedate: dueDate, - paymentmethod: "stripe", - sendinvoice: "1", - }, - result: { - result: "success", - invoiceid: billing.invoice.id, - status: billing.invoice.status, - }, - }, - { - url: this.whmcsBaseUrl, - senddata: { - responsetype: "json", - action: "CapturePayment", - invoiceid: billing.invoice.id, - }, - result: { result: "success" }, - }, - { - url: `${this.freebitBaseUrl}/master/addSpec/`, - json: { - quota: request.quotaMb, - kind: "MVNO", - account: latestAccount, - authKey: "[REDACTED]", - }, - result: freebitResult || { - resultCode: "100", - status: { message: "OK", statusCode: "200" }, - }, - }, - ]); - - return { - account: latestAccount, - costJpy, - invoiceId: billing.invoice.id, - transactionId: billing.transactionId, - }; - } - ); - } - - /** - * Handle Freebit API failure after successful payment - */ - private async handleFreebitFailureAfterPayment( - freebitError: unknown, - invoice: { id: number; number: string }, - transactionId: string, - userId: string, - subscriptionId: number, - account: string, - quotaMb: number - ): Promise { - this.logger.error( - `Freebit API failed after successful payment for subscription ${subscriptionId}`, - { - error: extractErrorMessage(freebitError), - userId, - subscriptionId, - account, - quotaMb, - invoiceId: invoice.id, - transactionId, - paymentCaptured: true, - } - ); - - // Add a note to the invoice about the Freebit failure - try { - await this.simBilling.appendInvoiceNote( - invoice.id, - `Payment successful but SIM top-up failed: ${extractErrorMessage( - freebitError - )}. Manual intervention required.` - ); - - this.logger.log(`Added failure note to invoice ${invoice.id}`, { - invoiceId: invoice.id, - reason: "Freebit API failure after payment", - }); - } catch (updateError) { - this.logger.error(`Failed to update invoice ${invoice.id} with failure note`, { - invoiceId: invoice.id, - updateError: extractErrorMessage(updateError), - originalError: extractErrorMessage(freebitError), - }); - } - - // Refund logic is handled by the caller (via top-up failure handling) - // Automatic refunds should be implemented at the payment processing layer - // to ensure consistency across all failure scenarios. - // For manual refunds, use the WHMCS admin panel or dedicated refund endpoints. - - throw new Error( - `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.` - ); - } -} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts index 368d9bc2..c9faabce 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-validation.service.ts @@ -1,7 +1,7 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; -import { SubscriptionsService } from "../../subscriptions.service.js"; +import { SubscriptionsOrchestrator } from "../../subscriptions-orchestrator.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import type { SimValidationResult } from "../interfaces/sim-base.interface.js"; import { @@ -13,7 +13,7 @@ import { @Injectable() export class SimValidationService { constructor( - private readonly subscriptionsService: SubscriptionsService, + private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator, private readonly configService: ConfigService, @Inject(Logger) private readonly logger: Logger ) {} @@ -29,7 +29,7 @@ export class SimValidationService { ): Promise { try { // Get subscription details to verify it's a SIM service - const subscription = await this.subscriptionsService.getSubscriptionById( + const subscription = await this.subscriptionsOrchestrator.getSubscriptionById( userId, subscriptionId ); @@ -105,7 +105,7 @@ export class SimValidationService { subscriptionId: number ): Promise> { try { - const subscription = await this.subscriptionsService.getSubscriptionById( + const subscription = await this.subscriptionsOrchestrator.getSubscriptionById( userId, subscriptionId ); 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 49058f46..35d48c12 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 @@ -6,29 +6,32 @@ import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; import { SftpModule } from "@bff/integrations/sftp/sftp.module.js"; import { SecurityModule } from "@bff/core/security/security.module.js"; import { SimUsageStoreService } from "../sim-usage-store.service.js"; -import { SubscriptionsService } from "../subscriptions.service.js"; -import { SimManagementService } from "../sim-management.service.js"; +import { SubscriptionsOrchestrator } from "../subscriptions-orchestrator.service.js"; import { SharedSubscriptionsModule } from "../shared/index.js"; // SimController is registered in SubscriptionsModule to ensure route order -// Import all SIM management services +// Import SIM management services - organized by responsibility import { SimOrchestrator } from "./services/sim-orchestrator.service.js"; -import { SimDetailsService } from "./services/sim-details.service.js"; -import { SimUsageService } from "./services/sim-usage.service.js"; -import { SimTopUpService } from "./services/sim-topup.service.js"; -import { SimTopUpPricingService } from "./services/sim-topup-pricing.service.js"; -import { SimPlanService } from "./services/sim-plan.service.js"; -import { SimCancellationService } from "./services/sim-cancellation.service.js"; -import { EsimManagementService } from "./services/esim-management.service.js"; import { SimValidationService } from "./services/sim-validation.service.js"; -import { SimNotificationService } from "./services/sim-notification.service.js"; -import { SimBillingService } from "./services/sim-billing.service.js"; -import { SimScheduleService } from "./services/sim-schedule.service.js"; +// Query services (read operations) +import { SimDetailsService } from "./services/queries/sim-details.service.js"; +import { SimUsageService } from "./services/queries/sim-usage.service.js"; +import { SimBillingService } from "./services/queries/sim-billing.service.js"; +import { SimTopUpPricingService } from "./services/queries/sim-topup-pricing.service.js"; +// Mutation services (write operations) +import { SimTopUpService } from "./services/mutations/sim-topup.service.js"; +import { SimPlanService } from "./services/mutations/sim-plan.service.js"; +import { SimCancellationService } from "./services/mutations/sim-cancellation.service.js"; +import { EsimManagementService } from "./services/mutations/esim-management.service.js"; +// Support services (utilities) +import { SimCallHistoryService } from "./services/support/sim-call-history.service.js"; +import { SimCallHistoryParserService } from "./services/support/sim-call-history-parser.service.js"; +import { SimCallHistoryFormatterService } from "./services/support/sim-call-history-formatter.service.js"; +import { SimNotificationService } from "./services/support/sim-notification.service.js"; +import { SimScheduleService } from "./services/support/sim-schedule.service.js"; +// Queue services import { SimManagementQueueService } from "./queue/sim-management.queue.js"; import { SimManagementProcessor } from "./queue/sim-management.processor.js"; -import { SimCallHistoryService } from "./services/sim-call-history.service.js"; -import { SimCallHistoryParserService } from "./services/sim-call-history-parser.service.js"; -import { SimCallHistoryFormatterService } from "./services/sim-call-history-formatter.service.js"; import { ServicesModule } from "@bff/modules/services/services.module.js"; import { NotificationsModule } from "@bff/modules/notifications/notifications.module.js"; import { VoiceOptionsModule, VoiceOptionsService } from "@bff/modules/voice-options/index.js"; @@ -54,8 +57,7 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js"; providers: [ // Core services that the SIM services depend on SimUsageStoreService, - SubscriptionsService, - SimManagementService, + SubscriptionsOrchestrator, // SIM management services SimValidationService, diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts b/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts index 8954ff9b..61867619 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim.controller.ts @@ -9,11 +9,11 @@ import { Header, UseGuards, } from "@nestjs/common"; -import { SimManagementService } from "../sim-management.service.js"; -import { SimTopUpPricingService } from "./services/sim-topup-pricing.service.js"; -import { SimPlanService } from "./services/sim-plan.service.js"; -import { SimCancellationService } from "./services/sim-cancellation.service.js"; -import { EsimManagementService } from "./services/esim-management.service.js"; +import { SimOrchestrator } from "./services/sim-orchestrator.service.js"; +import { SimTopUpPricingService } from "./services/queries/sim-topup-pricing.service.js"; +import { SimPlanService } from "./services/mutations/sim-plan.service.js"; +import { SimCancellationService } from "./services/mutations/sim-cancellation.service.js"; +import { EsimManagementService } from "./services/mutations/esim-management.service.js"; import { AdminGuard } from "@bff/core/security/guards/admin.guard.js"; import { createZodDto, ZodResponse } from "nestjs-zod"; import type { RequestWithUser } from "@bff/modules/auth/auth.types.js"; @@ -77,7 +77,7 @@ class SimCancellationPreviewResponseDto extends createZodDto(simCancellationPrev @Controller("subscriptions") export class SimController { constructor( - private readonly simManagementService: SimManagementService, + private readonly simOrchestrator: SimOrchestrator, private readonly simTopUpPricingService: SimTopUpPricingService, private readonly simPlanService: SimPlanService, private readonly simCancellationService: SimCancellationService, @@ -108,7 +108,7 @@ export class SimController { @Get("debug/sim-details/:account") @UseGuards(AdminGuard) async debugSimDetails(@Param("account") account: string) { - return await this.simManagementService.getSimDetailsDebug(account); + return await this.simOrchestrator.getSimDetailsDirectly(account); } // ==================== Subscription-specific SIM Routes ==================== @@ -119,25 +119,25 @@ export class SimController { @Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto ): Promise> { - return this.simManagementService.debugSimSubscription(req.user.id, params.id); + return this.simOrchestrator.debugSimSubscription(req.user.id, params.id); } @Get(":id/sim") @ZodResponse({ description: "Get SIM info", type: SimInfoDto }) async getSimInfo(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) { - return this.simManagementService.getSimInfo(req.user.id, params.id); + return this.simOrchestrator.getSimInfo(req.user.id, params.id); } @Get(":id/sim/details") @ZodResponse({ description: "Get SIM details", type: SimDetailsDto }) async getSimDetails(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) { - return this.simManagementService.getSimDetails(req.user.id, params.id); + return this.simOrchestrator.getSimDetails(req.user.id, params.id); } @Get(":id/sim/usage") @ZodResponse({ description: "Get SIM usage", type: SimUsageDto }) async getSimUsage(@Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto) { - return this.simManagementService.getSimUsage(req.user.id, params.id); + return this.simOrchestrator.getSimUsage(req.user.id, params.id); } @Get(":id/sim/top-up-history") @@ -147,7 +147,7 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @Query() query: SimTopUpHistoryRequestDto ) { - return this.simManagementService.getSimTopUpHistory(req.user.id, params.id, query); + return this.simOrchestrator.getSimTopUpHistory(req.user.id, params.id, query); } @Post(":id/sim/top-up") @@ -157,7 +157,7 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @Body() body: SimTopUpRequestDto ): Promise { - await this.simManagementService.topUpSim(req.user.id, params.id, body); + await this.simOrchestrator.topUpSim(req.user.id, params.id, body); return { message: "SIM top-up completed successfully" }; } @@ -168,7 +168,7 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @Body() body: SimPlanChangeRequestDto ): Promise { - const result = await this.simManagementService.changeSimPlan(req.user.id, params.id, body); + const result = await this.simOrchestrator.changeSimPlan(req.user.id, params.id, body); return { message: "SIM plan change completed successfully", ...result, @@ -182,7 +182,7 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @Body() body: SimCancelRequestDto ): Promise { - await this.simManagementService.cancelSim(req.user.id, params.id, body); + await this.simOrchestrator.cancelSim(req.user.id, params.id, body); return { message: "SIM cancellation completed successfully" }; } @@ -193,7 +193,11 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @Body() body: SimReissueEsimRequestDto ): Promise { - await this.simManagementService.reissueEsimProfile(req.user.id, params.id, body.newEid); + await this.simOrchestrator.reissueEsimProfile( + req.user.id, + params.id, + body.newEid ? { newEid: body.newEid } : {} + ); return { message: "eSIM profile reissue completed successfully" }; } @@ -204,7 +208,7 @@ export class SimController { @Param() params: SubscriptionIdParamDto, @Body() body: SimFeaturesUpdateRequestDto ): Promise { - await this.simManagementService.updateSimFeatures(req.user.id, params.id, body); + await this.simOrchestrator.updateSimFeatures(req.user.id, params.id, body); return { message: "SIM features updated successfully" }; } 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 45994837..b99c8737 100644 --- a/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts @@ -7,8 +7,8 @@ import { CacheService } from "@bff/infra/cache/cache.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import type { SimOrderActivationRequest } from "@customer-portal/domain/sim"; import { randomUUID } from "crypto"; -import { SimBillingService } from "./sim-management/services/sim-billing.service.js"; -import { SimScheduleService } from "./sim-management/services/sim-schedule.service.js"; +import { SimBillingService } from "./sim-management/services/queries/sim-billing.service.js"; +import { SimScheduleService } from "./sim-management/services/support/sim-schedule.service.js"; @Injectable() export class SimOrderActivationService { diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index 6be0b8fb..1f1f7280 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Param, Query, Request, Header } from "@nestjs/common"; -import { SubscriptionsService } from "./subscriptions.service.js"; +import { SubscriptionsOrchestrator } from "./subscriptions-orchestrator.service.js"; import { subscriptionQuerySchema, subscriptionListSchema, @@ -47,7 +47,7 @@ class InvoiceListDto extends createZodDto(invoiceListSchema) {} */ @Controller("subscriptions") export class SubscriptionsController { - constructor(private readonly subscriptionsService: SubscriptionsService) {} + constructor(private readonly subscriptionsOrchestrator: SubscriptionsOrchestrator) {} @Get() @Header("Cache-Control", "private, max-age=300") @@ -57,7 +57,7 @@ export class SubscriptionsController { @Query() query: SubscriptionQueryDto ): Promise { const { status } = query; - return this.subscriptionsService.getSubscriptions( + return this.subscriptionsOrchestrator.getSubscriptions( req.user.id, status === undefined ? {} : { status } ); @@ -67,14 +67,14 @@ export class SubscriptionsController { @Header("Cache-Control", "private, max-age=300") @ZodResponse({ description: "List active subscriptions", type: ActiveSubscriptionsDto }) async getActiveSubscriptions(@Request() req: RequestWithUser): Promise { - return this.subscriptionsService.getActiveSubscriptions(req.user.id); + return this.subscriptionsOrchestrator.getActiveSubscriptions(req.user.id); } @Get("stats") @Header("Cache-Control", "private, max-age=300") @ZodResponse({ description: "Get subscription stats", type: SubscriptionStatsDto }) async getSubscriptionStats(@Request() req: RequestWithUser): Promise { - return this.subscriptionsService.getSubscriptionStats(req.user.id); + return this.subscriptionsOrchestrator.getSubscriptionStats(req.user.id); } @Get(":id") @@ -84,7 +84,7 @@ export class SubscriptionsController { @Request() req: RequestWithUser, @Param() params: SubscriptionIdParamDto ): Promise { - return this.subscriptionsService.getSubscriptionById(req.user.id, params.id); + return this.subscriptionsOrchestrator.getSubscriptionById(req.user.id, params.id); } @Get(":id/invoices") @@ -95,6 +95,6 @@ export class SubscriptionsController { @Param() params: SubscriptionIdParamDto, @Query() query: SubscriptionInvoiceQueryDto ): Promise { - return this.subscriptionsService.getSubscriptionInvoices(req.user.id, params.id, query); + return this.subscriptionsOrchestrator.getSubscriptionInvoices(req.user.id, params.id, query); } } diff --git a/apps/bff/src/modules/subscriptions/subscriptions.module.ts b/apps/bff/src/modules/subscriptions/subscriptions.module.ts index dcd7865e..507c8a4e 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.module.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.module.ts @@ -1,7 +1,6 @@ import { Module } from "@nestjs/common"; import { SubscriptionsController } from "./subscriptions.controller.js"; -import { SubscriptionsService } from "./subscriptions.service.js"; -import { SimManagementService } from "./sim-management.service.js"; +import { SubscriptionsOrchestrator } from "./subscriptions-orchestrator.service.js"; import { SimUsageStoreService } from "./sim-usage-store.service.js"; import { SimOrdersController } from "./sim-orders.controller.js"; import { SimOrderActivationService } from "./sim-order-activation.service.js"; @@ -37,11 +36,6 @@ import { CancellationController } from "./cancellation/cancellation.controller.j SubscriptionsController, SimOrdersController, ], - providers: [ - SubscriptionsService, - SimManagementService, - SimUsageStoreService, - SimOrderActivationService, - ], + providers: [SubscriptionsOrchestrator, SimUsageStoreService, SimOrderActivationService], }) export class SubscriptionsModule {} diff --git a/apps/bff/src/modules/subscriptions/subscriptions.service.ts b/apps/bff/src/modules/subscriptions/subscriptions.service.ts deleted file mode 100644 index c9e90138..00000000 --- a/apps/bff/src/modules/subscriptions/subscriptions.service.ts +++ /dev/null @@ -1,466 +0,0 @@ -import { Injectable, Inject, BadRequestException } from "@nestjs/common"; -import { - subscriptionListSchema, - subscriptionStatusSchema, - subscriptionStatsSchema, -} from "@customer-portal/domain/subscriptions"; -import type { - Subscription, - SubscriptionList, - SubscriptionStatus, -} from "@customer-portal/domain/subscriptions"; -import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing"; -import { WhmcsCacheService } from "@bff/integrations/whmcs/cache/whmcs-cache.service.js"; -import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js"; -import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js"; -import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js"; -import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js"; -import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; -import { Logger } from "nestjs-pino"; -import { withErrorHandling } from "@bff/core/utils/error-handler.util.js"; -import { extractErrorMessage } from "@bff/core/utils/error.util.js"; - -export interface GetSubscriptionsOptions { - status?: SubscriptionStatus; -} - -@Injectable() -export class SubscriptionsService { - constructor( - private readonly whmcsSubscriptionService: WhmcsSubscriptionService, - private readonly whmcsInvoiceService: WhmcsInvoiceService, - private readonly whmcsClientService: WhmcsClientService, - private readonly whmcsConnectionService: WhmcsConnectionFacade, - private readonly cacheService: WhmcsCacheService, - private readonly mappingsService: MappingsService, - @Inject(Logger) private readonly logger: Logger - ) {} - - /** - * Get all subscriptions for a user - */ - async getSubscriptions( - userId: string, - options: GetSubscriptionsOptions = {} - ): Promise { - const { status } = options; - const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); - - return withErrorHandling( - async () => { - const subscriptionList = await this.whmcsSubscriptionService.getSubscriptions( - whmcsClientId, - userId, - status === undefined ? {} : { status } - ); - - const parsed = subscriptionListSchema.parse(subscriptionList); - - let subscriptions = parsed.subscriptions; - if (status) { - const normalizedStatus = subscriptionStatusSchema.parse(status); - subscriptions = subscriptions.filter(sub => sub.status === normalizedStatus); - } - - return subscriptionListSchema.parse({ - subscriptions, - totalCount: subscriptions.length, - }); - }, - this.logger, - { - context: `Get subscriptions for user ${userId}`, - fallbackMessage: "Failed to retrieve subscriptions", - } - ); - } - - /** - * Get individual subscription by ID - */ - async getSubscriptionById(userId: string, subscriptionId: number): Promise { - // Validate subscription ID - if (!subscriptionId || subscriptionId < 1) { - throw new BadRequestException("Subscription ID must be a positive number"); - } - - // Get WHMCS client ID from user mapping - const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); - - return withErrorHandling( - async () => { - const subscription = await this.whmcsSubscriptionService.getSubscriptionById( - whmcsClientId, - userId, - subscriptionId - ); - - this.logger.log(`Retrieved subscription ${subscriptionId} for user ${userId}`, { - productName: subscription.productName, - status: subscription.status, - amount: subscription.amount, - currency: subscription.currency, - }); - - return subscription; - }, - this.logger, - { - context: `Get subscription ${subscriptionId} for user ${userId}`, - fallbackMessage: "Failed to retrieve subscription", - } - ); - } - - /** - * Get active subscriptions for a user - */ - async getActiveSubscriptions(userId: string): Promise { - try { - const subscriptionList = await this.getSubscriptions(userId, { - status: "Active", - }); - return subscriptionList.subscriptions; - } catch (error) { - this.logger.error(`Failed to get active subscriptions for user ${userId}`, { - error: extractErrorMessage(error), - }); - throw error; - } - } - - /** - * Get subscriptions by status - */ - async getSubscriptionsByStatus( - userId: string, - status: SubscriptionStatus - ): Promise { - try { - const normalizedStatus = subscriptionStatusSchema.parse(status); - const subscriptionList = await this.getSubscriptions(userId, { status: normalizedStatus }); - return subscriptionList.subscriptions; - } catch (error) { - this.logger.error(`Failed to get ${status} subscriptions for user ${userId}`, { - error: extractErrorMessage(error), - }); - throw error; - } - } - - /** - * Get subscription statistics for a user - */ - async getSubscriptionStats(userId: string): Promise<{ - total: number; - active: number; - completed: number; - cancelled: number; - }> { - return withErrorHandling( - async () => { - const subscriptionList = await this.getSubscriptions(userId); - const subscriptions: Subscription[] = subscriptionList.subscriptions; - - const stats = { - total: subscriptions.length, - active: subscriptions.filter(s => s.status === "Active").length, - completed: subscriptions.filter(s => s.status === "Completed").length, - cancelled: subscriptions.filter(s => s.status === "Cancelled").length, - }; - - this.logger.log(`Generated subscription stats for user ${userId}`, stats); - - return subscriptionStatsSchema.parse(stats); - }, - this.logger, - { - context: `Generate subscription stats for user ${userId}`, - } - ); - } - - /** - * Get subscriptions expiring soon (within next 30 days) - */ - async getExpiringSoon(userId: string, days: number = 30): Promise { - return withErrorHandling( - async () => { - const subscriptionList = await this.getSubscriptions(userId); - const subscriptions: Subscription[] = subscriptionList.subscriptions; - - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() + days); - - const expiringSoon = subscriptions.filter(subscription => { - if (!subscription.nextDue || subscription.status !== "Active") { - return false; - } - - const nextDueDate = new Date(subscription.nextDue); - return nextDueDate <= cutoffDate; - }); - - this.logger.log( - `Found ${expiringSoon.length} subscriptions expiring within ${days} days for user ${userId}` - ); - return expiringSoon; - }, - this.logger, - { - context: `Get expiring subscriptions for user ${userId}`, - } - ); - } - - /** - * Get recent subscription activity (newly created or status changed) - */ - async getRecentActivity(userId: string, days: number = 30): Promise { - return withErrorHandling( - async () => { - const subscriptionList = await this.getSubscriptions(userId); - const subscriptions = subscriptionList.subscriptions; - - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - days); - - const recentActivity = subscriptions.filter((subscription: Subscription) => { - const registrationDate = new Date(subscription.registrationDate); - return registrationDate >= cutoffDate; - }); - - this.logger.log( - `Found ${recentActivity.length} recent subscription activities within ${days} days for user ${userId}` - ); - return recentActivity; - }, - this.logger, - { - context: `Get recent subscription activity for user ${userId}`, - } - ); - } - - /** - * Search subscriptions by product name or domain - */ - async searchSubscriptions(userId: string, query: string): Promise { - if (!query || query.trim().length < 2) { - throw new BadRequestException("Search query must be at least 2 characters long"); - } - - return withErrorHandling( - async () => { - const subscriptionList = await this.getSubscriptions(userId); - const subscriptions = subscriptionList.subscriptions; - - const searchTerm = query.toLowerCase().trim(); - const matches = subscriptions.filter((subscription: Subscription) => { - const productName = subscription.productName.toLowerCase(); - const domain = subscription.domain?.toLowerCase() || ""; - - return productName.includes(searchTerm) || domain.includes(searchTerm); - }); - - this.logger.log( - `Found ${matches.length} subscriptions matching query "${query}" for user ${userId}` - ); - return matches; - }, - this.logger, - { - context: `Search subscriptions for user ${userId}`, - } - ); - } - - /** - * Get invoices related to a specific subscription - */ - async getSubscriptionInvoices( - userId: string, - subscriptionId: number, - options: { page?: number; limit?: number } = {} - ): Promise { - const { page = 1, limit = 10 } = options; - const batchSize = Math.min(100, Math.max(limit, 25)); - - return withErrorHandling( - async () => { - // Try page cache first - const cached = await this.cacheService.getSubscriptionInvoices( - userId, - subscriptionId, - page, - limit - ); - if (cached) { - this.logger.debug( - `Cache hit for subscription invoices: user ${userId}, subscription ${subscriptionId}` - ); - return cached; - } - - // Try full list cache to avoid rescanning all WHMCS invoices per page - const cachedAll = await this.cacheService.getSubscriptionInvoicesAll( - userId, - subscriptionId - ); - if (cachedAll) { - const startIndex = (page - 1) * limit; - const endIndex = startIndex + limit; - const paginatedInvoices = cachedAll.slice(startIndex, endIndex); - - const result: InvoiceList = { - invoices: paginatedInvoices, - pagination: { - page, - totalPages: cachedAll.length === 0 ? 0 : Math.ceil(cachedAll.length / limit), - totalItems: cachedAll.length, - }, - }; - - await this.cacheService.setSubscriptionInvoices( - userId, - subscriptionId, - page, - limit, - result - ); - - return result; - } - - // Validate subscription exists and belongs to user - await this.getSubscriptionById(userId, subscriptionId); - - // Get WHMCS client ID from user mapping - const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); - - const relatedInvoices: Invoice[] = []; - let currentPage = 1; - let totalPages = 1; - - do { - const invoiceBatch = await this.whmcsInvoiceService.getInvoicesWithItems( - whmcsClientId, - userId, - { page: currentPage, limit: batchSize } - ); - - totalPages = invoiceBatch.pagination.totalPages; - - for (const invoice of invoiceBatch.invoices) { - if (!invoice.items?.length) { - continue; - } - - const hasMatchingService = invoice.items.some( - (item: InvoiceItem) => item.serviceId === subscriptionId - ); - - if (hasMatchingService) { - relatedInvoices.push(invoice); - } - } - - currentPage += 1; - } while (currentPage <= totalPages); - - // Apply pagination to filtered results - const startIndex = (page - 1) * limit; - const endIndex = startIndex + limit; - const paginatedInvoices = relatedInvoices.slice(startIndex, endIndex); - - const result: InvoiceList = { - invoices: paginatedInvoices, - pagination: { - page, - totalPages: - relatedInvoices.length === 0 ? 0 : Math.ceil(relatedInvoices.length / limit), - totalItems: relatedInvoices.length, - }, - }; - - this.logger.log( - `Retrieved ${paginatedInvoices.length} invoices for subscription ${subscriptionId}`, - { - userId, - subscriptionId, - totalRelated: relatedInvoices.length, - page, - limit, - } - ); - - // Cache the result - await this.cacheService.setSubscriptionInvoices( - userId, - subscriptionId, - page, - limit, - result - ); - await this.cacheService.setSubscriptionInvoicesAll(userId, subscriptionId, relatedInvoices); - - return result; - }, - this.logger, - { - context: `Get invoices for subscription ${subscriptionId}`, - fallbackMessage: "Failed to retrieve subscription invoices", - } - ); - } - - /** - * Invalidate subscription cache for a user - */ - async invalidateCache(userId: string, subscriptionId?: number): Promise { - try { - if (subscriptionId) { - await this.whmcsSubscriptionService.invalidateSubscriptionCache(userId, subscriptionId); - } else { - await this.whmcsClientService.invalidateUserCache(userId); - } - - this.logger.log( - `Invalidated subscription cache for user ${userId}${subscriptionId ? `, subscription ${subscriptionId}` : ""}` - ); - } catch (error) { - this.logger.error(`Failed to invalidate subscription cache for user ${userId}`, { - error: extractErrorMessage(error), - subscriptionId, - }); - } - } - - /** - * Health check for subscription service - */ - async healthCheck(): Promise<{ status: string; details: unknown }> { - try { - const whmcsHealthy = await this.whmcsConnectionService.healthCheck(); - - return { - status: whmcsHealthy ? "healthy" : "unhealthy", - details: { - whmcsApi: whmcsHealthy ? "connected" : "disconnected", - timestamp: new Date().toISOString(), - }, - }; - } catch (error) { - this.logger.error("Subscription service health check failed", { - error: extractErrorMessage(error), - }); - return { - status: "unhealthy", - details: { - error: extractErrorMessage(error), - timestamp: new Date().toISOString(), - }, - }; - } - } -} diff --git a/apps/portal/src/features/checkout/stores/checkout.store.ts b/apps/portal/src/features/checkout/stores/checkout.store.ts index 56169e03..aa72fae0 100644 --- a/apps/portal/src/features/checkout/stores/checkout.store.ts +++ b/apps/portal/src/features/checkout/stores/checkout.store.ts @@ -106,6 +106,17 @@ export const useCheckoutStore = create()( name: "checkout-store", version: 1, storage: createJSONStorage(() => localStorage), + migrate: (persistedState, version) => { + // Version 0 (or undefined) -> Version 1: no structural changes, just initialize missing fields + if (version === 0 || version === undefined) { + const state = persistedState as Partial; + return { + ...initialState, + ...state, + }; + } + return persistedState as CheckoutState; + }, partialize: state => ({ // Persist only essential data cartItem: state.cartItem diff --git a/apps/portal/src/features/support/views/PublicContactView.tsx b/apps/portal/src/features/support/views/PublicContactView.tsx index cc40fe04..543ee609 100644 --- a/apps/portal/src/features/support/views/PublicContactView.tsx +++ b/apps/portal/src/features/support/views/PublicContactView.tsx @@ -13,6 +13,8 @@ import { } from "@customer-portal/domain/support"; import { apiClient, ApiError, isApiError } from "@/core/api"; +const SEND_ERROR_MESSAGE = "Failed to send message"; + /** * PublicContactView - Contact page with form, phone, chat, and location info */ @@ -29,14 +31,14 @@ export function PublicContactView() { setIsSubmitted(true); } catch (error) { if (isApiError(error)) { - setSubmitError(error.message || "Failed to send message"); + setSubmitError(error.message || SEND_ERROR_MESSAGE); return; } if (error instanceof ApiError) { - setSubmitError(error.message || "Failed to send message"); + setSubmitError(error.message || SEND_ERROR_MESSAGE); return; } - setSubmitError(error instanceof Error ? error.message : "Failed to send message"); + setSubmitError(error instanceof Error ? error.message : SEND_ERROR_MESSAGE); } }, []); diff --git a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx index 94fc4586..0fb0bd60 100644 --- a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx +++ b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx @@ -27,6 +27,8 @@ import { formatIsoDate, formatIsoRelative } from "@/shared/utils"; import type { CaseMessage } from "@customer-portal/domain/support"; import { CLOSED_STATUSES } from "@customer-portal/domain/support"; +const SUPPORT_HREF = "/account/support"; + // ============================================================================ // Helper Functions // ============================================================================ @@ -161,7 +163,7 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) { actions={