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"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { SimValidationService } from "./sim-validation.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import type { SimCancelRequest, SimCancelFullRequest } from "@customer-portal/domain/sim"; import { SimScheduleService } from "./sim-schedule.service"; import { SimActionRunnerService } from "./sim-action-runner.service"; import { SimApiNotificationService } from "./sim-api-notification.service"; export interface CancellationMonth { value: string; // YYYY-MM format label: string; // Display label like "November 2025" runDate: string; // YYYYMMDD format for API (1st of next month) } export interface CancellationPreview { simNumber: string; serialNumber?: string; planCode: string; startDate?: string; minimumContractEndDate?: string; isWithinMinimumTerm: boolean; availableMonths: CancellationMonth[]; customerEmail: string; customerName: string; } @Injectable() export class SimCancellationService { constructor( private readonly freebitService: FreebitOrchestratorService, private readonly whmcsService: WhmcsService, private readonly mappingsService: MappingsService, private readonly simValidation: SimValidationService, private readonly simSchedule: SimScheduleService, private readonly simActionRunner: SimActionRunnerService, private readonly apiNotification: SimApiNotificationService, 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(): CancellationMonth[] { const months: CancellationMonth[] = []; 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 mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { throw new BadRequestException("WHMCS client mapping not found"); } const clientDetails = await this.whmcsService.getClientDetails(mapping.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 and email notifications) */ async cancelSimFull( userId: string, subscriptionId: number, request: SimCancelFullRequest ): Promise { 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 mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { throw new BadRequestException("WHMCS client mapping not found"); } const clientDetails = await this.whmcsService.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`; 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, }); // Send admin notification email const adminEmailBody = this.apiNotification.buildCancellationAdminEmail({ customerName, simNumber: account, serialNumber: simDetails.iccid, cancellationMonth: request.cancellationMonth, registeredEmail: customerEmail, otherEmail: request.alternativeEmail || undefined, 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 (and alternative if provided) 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 ); // Send to alternative email if provided if (request.alternativeEmail && request.alternativeEmail !== customerEmail) { await this.apiNotification.sendCustomerEmail( request.alternativeEmail, confirmationSubject, confirmationBody ); } } }