import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.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 { SalesforceCaseService } from "@bff/integrations/salesforce/services/salesforce-case.service.js"; import { SimValidationService } from "./sim-validation.service.js"; import type { SimCancelRequest, SimCancelFullRequest, SimCancellationMonth, SimCancellationPreview, } from "@customer-portal/domain/sim"; import { SALESFORCE_CASE_ORIGIN } from "@customer-portal/domain/support/providers"; import { SIM_CANCELLATION_NOTICE } from "@customer-portal/domain/opportunity"; import { SimScheduleService } from "./sim-schedule.service.js"; import { SimActionRunnerService } from "./sim-action-runner.service.js"; import { SimApiNotificationService } from "./sim-api-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: FreebitOrchestratorService, private readonly whmcsClientService: WhmcsClientService, private readonly mappingsService: MappingsService, private readonly opportunityService: SalesforceOpportunityService, private readonly caseService: SalesforceCaseService, private readonly simValidation: SimValidationService, private readonly simSchedule: SimScheduleService, private readonly simActionRunner: SimActionRunnerService, private readonly apiNotification: SimApiNotificationService, 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"; } /** * Generate available cancellation months (next 12 months) */ private generateCancellationMonths(): SimCancellationMonth[] { const months: SimCancellationMonth[] = []; const today = new Date(); const dayOfMonth = today.getDate(); // Start from current month if before 25th, otherwise next month const startOffset = dayOfMonth <= 25 ? 0 : 1; for (let i = startOffset; i < startOffset + 12; i++) { const date = new Date(today.getFullYear(), today.getMonth() + i, 1); const year = date.getFullYear(); const month = date.getMonth() + 1; const monthStr = String(month).padStart(2, "0"); // runDate is the 1st of the NEXT month (cancellation takes effect at month end) const nextMonth = new Date(year, month, 1); const runYear = nextMonth.getFullYear(); const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0"); months.push({ value: `${year}-${monthStr}`, label: date.toLocaleDateString("en-US", { month: "long", year: "numeric" }), runDate: `${runYear}${runMonth}01`, }); } return months; } /** * 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 = parseInt(startDateStr.substring(0, 4), 10); const month = parseInt(startDateStr.substring(4, 6), 10) - 1; const day = parseInt(startDateStr.substring(6, 8), 10); if (isNaN(year) || isNaN(month) || 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: this.generateCancellationMonths(), customerEmail, customerName, }; } /** * Cancel SIM service (legacy) */ async cancelSim( userId: string, subscriptionId: number, request: SimCancelRequest = {} ): Promise { let account = ""; await this.simActionRunner.run( "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"); } // Parse cancellation month and calculate runDate const [year, month] = request.cancellationMonth.split("-").map(Number); if (!year || !month) { throw new BadRequestException("Invalid cancellation month format"); } // runDate is 1st of the NEXT month (cancellation at end of selected month) const nextMonth = new Date(year, month, 1); const runYear = nextMonth.getFullYear(); const runMonth = String(nextMonth.getMonth() + 1).padStart(2, "0"); const runDate = `${runYear}${runMonth}01`; // Calculate the cancellation date (last day of selected month) const lastDayOfMonth = new Date(year, month, 0); const cancellationDate = [ lastDayOfMonth.getFullYear(), String(lastDayOfMonth.getMonth() + 1).padStart(2, "0"), String(lastDayOfMonth.getDate()).padStart(2, "0"), ].join("-"); 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 }); } // Build description with all form data (same pattern as Internet) const descriptionLines = [ `Cancellation Request from Portal`, ``, `Product Type: SIM`, `SIM Number: ${account}`, `Serial Number: ${simDetails.iccid || "N/A"}`, `WHMCS Service ID: ${subscriptionId}`, `Cancellation Month: ${request.cancellationMonth}`, `Service End Date: ${cancellationDate}`, ``, ]; if (request.comments) { descriptionLines.push(`Customer Comments:`, request.comments, ``); } descriptionLines.push(`Submitted: ${new Date().toISOString()}`); // Create Salesforce Case for cancellation (same as Internet) let caseId: string | undefined; try { const caseResult = await this.caseService.createCase({ accountId: mapping.sfAccountId, opportunityId: opportunityId || undefined, subject: `Cancellation Request - SIM (${request.cancellationMonth})`, description: descriptionLines.join("\n"), origin: SALESFORCE_CASE_ORIGIN.PORTAL_NOTIFICATION, priority: "High", }); caseId = caseResult.id; this.logger.log("SIM cancellation case created", { caseId, opportunityId, }); } catch (error) { // Log but don't fail - Freebit API was already called successfully this.logger.error("Failed to create SIM cancellation Case", { error: error instanceof Error ? error.message : String(error), subscriptionId, }); } // 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.apiNotification.buildCancellationAdminEmail({ customerName, simNumber: account, serialNumber: simDetails.iccid, cancellationMonth: request.cancellationMonth, registeredEmail: customerEmail, comments: request.comments, }); await this.apiNotification.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.apiNotification.sendCustomerEmail( customerEmail, confirmationSubject, confirmationBody ); } }