348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
|
import { Logger } from "nestjs-pino";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
|
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
|
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
|
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
|
|
import { SimValidationService } from "./sim-validation.service.js";
|
|
import { SubscriptionValidationCoordinator } from "../../shared/index.js";
|
|
import type {
|
|
SimCancelRequest,
|
|
SimCancelFullRequest,
|
|
SimCancellationMonth,
|
|
SimCancellationPreview,
|
|
} from "@customer-portal/domain/sim";
|
|
import {
|
|
generateCancellationMonths,
|
|
getCancellationEffectiveDate,
|
|
getRunDateFromMonth,
|
|
} from "@customer-portal/domain/subscriptions";
|
|
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: FreebitFacade,
|
|
private readonly whmcsClientService: WhmcsClientService,
|
|
private readonly validationCoordinator: SubscriptionValidationCoordinator,
|
|
private readonly opportunityService: SalesforceOpportunityService,
|
|
private readonly workflowCases: WorkflowCaseManager,
|
|
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 via coordinator
|
|
const whmcsClientId = await this.validationCoordinator.getWhmcsClientId(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> {
|
|
// Validate account mapping via coordinator
|
|
const { whmcsClientId, sfAccountId } =
|
|
await this.validationCoordinator.validateAccountMapping(userId);
|
|
|
|
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(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,
|
|
});
|
|
|
|
// Resolve OpportunityId via coordinator
|
|
const opportunityResolution = await this.validationCoordinator.resolveOpportunityId(
|
|
subscriptionId,
|
|
{ fallbackToSalesforce: true }
|
|
);
|
|
const opportunityId = opportunityResolution.opportunityId;
|
|
|
|
// Create Salesforce Case for cancellation via workflow manager
|
|
await this.workflowCases.notifySimCancellation({
|
|
accountId: sfAccountId,
|
|
...(opportunityId ? { opportunityId } : {}),
|
|
simAccount: account,
|
|
iccid: simDetails.iccid || "N/A",
|
|
subscriptionId,
|
|
cancellationMonth: request.cancellationMonth,
|
|
serviceEndDate: cancellationDate,
|
|
...(request.comments ? { comments: request.comments } : {}),
|
|
});
|
|
|
|
this.logger.log("SIM cancellation case created via WorkflowCaseManager", {
|
|
sfAccountIdTail: sfAccountId.slice(-4),
|
|
opportunityId: opportunityId ? opportunityId.slice(-4) : null,
|
|
});
|
|
|
|
// Use a placeholder caseId for notification since workflow manager doesn't return it
|
|
const caseId = `cancellation:sim:${subscriptionId}:${request.cancellationMonth}`;
|
|
|
|
// 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
|
|
);
|
|
}
|
|
}
|