diff --git a/apps/bff/src/modules/orders/validators/order-composite.validator.ts b/apps/bff/src/modules/orders/validators/order-composite.validator.ts new file mode 100644 index 00000000..a848224b --- /dev/null +++ b/apps/bff/src/modules/orders/validators/order-composite.validator.ts @@ -0,0 +1,144 @@ +import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { + validationSuccess, + validationFailure, + type ValidationResult, + type ValidationError, +} from "./base-validator.interface.js"; +import { UserMappingValidator, type UserMappingData } from "./user-mapping.validator.js"; +import { PaymentValidator } from "./payment.validator.js"; +import { SkuValidator } from "./sku.validator.js"; +import { SimOrderValidator } from "./sim-order.validator.js"; +import { InternetOrderValidator } from "./internet-order.validator.js"; +import type { CheckoutItem } from "@customer-portal/domain/orders"; + +/** + * Order validation options + */ +export interface OrderValidationOptions { + /** Whether order contains SIM products */ + hasSim: boolean; + /** Whether order contains Internet products */ + hasInternet: boolean; + /** Pricebook ID for SKU validation */ + pricebookId: string; +} + +/** + * Validated order context returned on successful validation + */ +export interface ValidatedOrderContext { + /** User mapping data (userId, sfAccountId, whmcsClientId) */ + mapping: UserMappingData; + /** Validated SKUs */ + skus: string[]; +} + +/** + * Order Composite Validator + * + * Coordinates all order validation steps in sequence. + * Collects all errors rather than failing fast for better UX. + */ +@Injectable() +export class OrderCompositeValidator { + constructor( + @Inject(Logger) private readonly logger: Logger, + private readonly userMappingValidator: UserMappingValidator, + private readonly paymentValidator: PaymentValidator, + private readonly skuValidator: SkuValidator, + private readonly simOrderValidator: SimOrderValidator, + private readonly internetOrderValidator: InternetOrderValidator + ) {} + + /** + * Validate an order creation request + * + * Steps: + * 1. User mapping validation (required IDs exist) + * 2. Payment method validation (has payment on file) + * 3. SKU validation (all SKUs are valid) + * 4. Product-specific validation (SIM requirements, internet eligibility) + * + * @param userId - User ID to validate + * @param cartItems - Cart items to validate + * @param options - Validation options (hasSim, hasInternet, pricebookId) + */ + async validate( + userId: string, + cartItems: CheckoutItem[], + options: OrderValidationOptions + ): Promise> { + const errors: ValidationError[] = []; + const skus = cartItems.map(item => item.sku); + + // Step 1: User mapping validation + const mappingResult = await this.userMappingValidator.validate(userId); + if (!mappingResult.success) { + errors.push(...(mappingResult.errors ?? [])); + // Can't continue without mapping - return early + return validationFailure(errors); + } + const mapping = mappingResult.data!; + + // Step 2: Payment validation + const paymentResult = await this.paymentValidator.validate(userId, mapping.whmcsClientId); + if (!paymentResult.success) { + errors.push(...(paymentResult.errors ?? [])); + } + + // Step 3: SKU validation + const skuResult = await this.skuValidator.validate(skus, options.pricebookId); + if (!skuResult.success) { + errors.push(...(skuResult.errors ?? [])); + } + + // Step 4: Product-specific validation + if (options.hasSim) { + const simResult = await this.simOrderValidator.validate(userId, skus); + if (!simResult.success) { + errors.push(...(simResult.errors ?? [])); + } + } + + if (options.hasInternet) { + const internetResult = await this.internetOrderValidator.validate( + userId, + mapping.whmcsClientId + ); + if (!internetResult.success) { + errors.push(...(internetResult.errors ?? [])); + } + } + + // Return collected errors or success + if (errors.length > 0) { + this.logger.warn({ userId, errorCount: errors.length }, "Order validation failed"); + return validationFailure(errors); + } + + return validationSuccess({ + mapping, + skus, + }); + } + + /** + * Validate and throw BadRequestException with all errors + */ + async validateOrThrow( + userId: string, + cartItems: CheckoutItem[], + options: OrderValidationOptions + ): Promise { + const result = await this.validate(userId, cartItems, options); + + if (!result.success) { + const messages = result.errors?.map(e => e.message).join("; "); + throw new BadRequestException(messages ?? "Order validation failed"); + } + + return result.data!; + } +} diff --git a/apps/bff/src/modules/orders/validators/payment.validator.ts b/apps/bff/src/modules/orders/validators/payment.validator.ts new file mode 100644 index 00000000..a8855813 --- /dev/null +++ b/apps/bff/src/modules/orders/validators/payment.validator.ts @@ -0,0 +1,74 @@ +import { Injectable, BadRequestException, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { + ValidationErrorCode, + createValidationError, + validationFailure, + validationSuccess, + type ValidationResult, +} from "./base-validator.interface.js"; + +/** + * Payment Validator + * + * Validates that a user has a payment method on file in WHMCS before ordering. + * Follows the same validation pattern as other validators (returns ValidationResult). + */ +@Injectable() +export class PaymentValidator { + constructor( + @Inject(Logger) private readonly logger: Logger, + private readonly whmcs: WhmcsConnectionFacade + ) {} + + /** + * Validate that a payment method exists for the WHMCS client + * Returns validation result indicating if payment method is on file + */ + async validate(userId: string, whmcsClientId: number): Promise> { + try { + const pay = await this.whmcs.getPaymentMethods({ clientid: whmcsClientId }); + const paymentMethods = Array.isArray(pay.paymethods) ? pay.paymethods : []; + + if (paymentMethods.length === 0) { + this.logger.warn({ userId, whmcsClientId }, "No payment method on file"); + return validationFailure([ + createValidationError( + ValidationErrorCode.NO_PAYMENT_METHOD, + "A payment method is required before ordering" + ), + ]); + } + + this.logger.log( + { userId, whmcsClientId, count: paymentMethods.length }, + "Payment method verified" + ); + return validationSuccess(); + } catch (e: unknown) { + const err = extractErrorMessage(e); + this.logger.error({ err, userId, whmcsClientId }, "Payment method verification failed"); + return validationFailure([ + createValidationError( + ValidationErrorCode.VALIDATION_ERROR, + "Unable to verify payment method. Please try again later." + ), + ]); + } + } + + /** + * Validate and throw BadRequestException on failure + * Use this when you need to fail fast with HTTP error + */ + async validateOrThrow(userId: string, whmcsClientId: number): Promise { + const result = await this.validate(userId, whmcsClientId); + + if (!result.success) { + const error = result.errors?.[0]; + throw new BadRequestException(error?.message ?? "Payment validation failed"); + } + } +} diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/mutations/esim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/mutations/esim-management.service.ts new file mode 100644 index 00000000..c3966b9a --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/mutations/esim-management.service.ts @@ -0,0 +1,202 @@ +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 "../support/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/mutations/sim-cancellation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/mutations/sim-cancellation.service.ts new file mode 100644 index 00000000..76e42fe7 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/mutations/sim-cancellation.service.ts @@ -0,0 +1,347 @@ +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 "../support/sim-schedule.service.js"; +import { SimNotificationService } from "../support/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/mutations/sim-plan.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/mutations/sim-plan.service.ts new file mode 100644 index 00000000..f1d43136 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/mutations/sim-plan.service.ts @@ -0,0 +1,353 @@ +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 "../support/sim-schedule.service.js"; +import { SimManagementQueueService } from "../../queue/sim-management.queue.js"; +import { SimNotificationService } from "../support/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/mutations/sim-topup.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/mutations/sim-topup.service.ts new file mode 100644 index 00000000..daa64100 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/mutations/sim-topup.service.ts @@ -0,0 +1,255 @@ +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 "../queries/sim-billing.service.js"; +import { SimNotificationService } from "../support/sim-notification.service.js"; +import { SimTopUpPricingService } from "../queries/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/support/sim-call-history-formatter.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/support/sim-call-history-formatter.service.ts new file mode 100644 index 00000000..72b58c66 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/support/sim-call-history-formatter.service.ts @@ -0,0 +1,84 @@ +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/support/sim-call-history-parser.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/support/sim-call-history-parser.service.ts new file mode 100644 index 00000000..9b7db675 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/support/sim-call-history-parser.service.ts @@ -0,0 +1,296 @@ +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/support/sim-call-history.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/support/sim-call-history.service.ts new file mode 100644 index 00000000..0b24cb60 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/support/sim-call-history.service.ts @@ -0,0 +1,419 @@ +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/support/sim-notification.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/support/sim-notification.service.ts new file mode 100644 index 00000000..85a1540e --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/support/sim-notification.service.ts @@ -0,0 +1,345 @@ +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/support/sim-schedule.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/support/sim-schedule.service.ts new file mode 100644 index 00000000..641c77cb --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/support/sim-schedule.service.ts @@ -0,0 +1,71 @@ +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/subscriptions-orchestrator.service.ts b/apps/bff/src/modules/subscriptions/subscriptions-orchestrator.service.ts new file mode 100644 index 00000000..062cb050 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/subscriptions-orchestrator.service.ts @@ -0,0 +1,472 @@ +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; +} + +/** + * Subscriptions Orchestrator + * + * Coordinates subscription management operations across multiple + * integration services (WHMCS, Salesforce). + */ +@Injectable() +export class SubscriptionsOrchestrator { + 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(), + }, + }; + } + } +}