375 lines
13 KiB
TypeScript
Raw Normal View History

import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config";
import { FreebitOperationsService } from "@bff/integrations/freebit/services/freebit-operations.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 {
generateCancellationMonths,
getCancellationEffectiveDate,
getRunDateFromMonth,
} from "@customer-portal/domain/subscriptions";
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 { 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: FreebitOperationsService,
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 simNotification: SimNotificationService,
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";
}
/**
* 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<SimCancellationPreview> {
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<void> {
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<void> {
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 });
}
// 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 } : {}),
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.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
);
}
}