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 { 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 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 mappingsService: MappingsService, 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 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 || ""; // 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 { const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId || !mapping?.sfAccountId) { throw new BadRequestException("Account mapping not found"); } 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(mapping.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, }); // Find existing Opportunity for this subscription (by WHMCS Service ID) let opportunityId: string | null = null; try { opportunityId = await this.opportunityService.findOpportunityByWhmcsServiceId(subscriptionId); } catch { this.logger.warn("Could not find Opportunity for SIM subscription", { subscriptionId }); } // Create Salesforce Case for cancellation via workflow manager await this.workflowCases.notifySimCancellation({ accountId: mapping.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: mapping.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 ); } }