319 lines
11 KiB
TypeScript
Raw Normal View History

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 { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { SimValidationService } from "./sim-validation.service.js";
import type { SimCancelRequest, SimCancelFullRequest } from "@customer-portal/domain/sim";
import { SimScheduleService } from "./sim-schedule.service.js";
import { SimActionRunnerService } from "./sim-action-runner.service.js";
import { SimApiNotificationService } from "./sim-api-notification.service.js";
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<string>("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<CancellationPreview> {
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<void> {
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<void> {
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
);
}
}
}