- Introduced MeStatus module to aggregate customer status, integrating dashboard summary, payment methods, internet eligibility, and residence card verification. - Updated dashboard hooks to utilize MeStatus for improved data fetching and error handling. - Enhanced notification handling across various modules, including cancellation notifications for internet and SIM services, ensuring timely user alerts. - Refactored related schemas and services to support new dashboard tasks and notification types, improving overall user engagement and experience.
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
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";
|
|
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
|
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
|
|
|
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 notifications: NotificationService,
|
|
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,
|
|
});
|
|
|
|
try {
|
|
await this.notifications.createNotification({
|
|
userId,
|
|
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
|
|
source: NOTIFICATION_SOURCE.SYSTEM,
|
|
sourceId: `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,
|
|
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
|
|
);
|
|
}
|
|
}
|
|
}
|