- Deleted migration file that removed cached profile fields from the users table, centralizing profile data retrieval from WHMCS. - Updated CsrfMiddleware to include new public authentication endpoints for password reset, setting password, and WHMCS account linking. - Enhanced error handling in password and WHMCS linking workflows to provide clearer feedback on missing mappings and improve user experience. - Adjusted user creation and update methods in UsersFacade to handle cases where WHMCS mappings are not yet available, ensuring smoother account setup.
945 lines
30 KiB
TypeScript
945 lines
30 KiB
TypeScript
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
|
import { Logger } from "nestjs-pino";
|
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
|
import { FreebitClientService } from "./freebit-client.service";
|
|
import { FreebitMapperService } from "./freebit-mapper.service";
|
|
import { FreebitAuthService } from "./freebit-auth.service";
|
|
import type {
|
|
FreebitAccountDetailsRequest,
|
|
FreebitAccountDetailsResponse,
|
|
FreebitTrafficInfoRequest,
|
|
FreebitTrafficInfoResponse,
|
|
FreebitTopUpRequest,
|
|
FreebitTopUpResponse,
|
|
FreebitQuotaHistoryRequest,
|
|
FreebitQuotaHistoryResponse,
|
|
FreebitPlanChangeRequest,
|
|
FreebitPlanChangeResponse,
|
|
FreebitContractLineChangeRequest,
|
|
FreebitContractLineChangeResponse,
|
|
FreebitAddSpecRequest,
|
|
FreebitAddSpecResponse,
|
|
FreebitVoiceOptionSettings,
|
|
FreebitVoiceOptionRequest,
|
|
FreebitVoiceOptionResponse,
|
|
FreebitCancelPlanRequest,
|
|
FreebitCancelPlanResponse,
|
|
FreebitEsimReissueRequest,
|
|
FreebitEsimReissueResponse,
|
|
FreebitEsimAddAccountRequest,
|
|
FreebitEsimAddAccountResponse,
|
|
FreebitEsimAccountActivationRequest,
|
|
FreebitEsimAccountActivationResponse,
|
|
SimDetails,
|
|
SimUsage,
|
|
SimTopUpHistory,
|
|
} from "../interfaces/freebit.types";
|
|
|
|
@Injectable()
|
|
export class FreebitOperationsService {
|
|
constructor(
|
|
private readonly client: FreebitClientService,
|
|
private readonly mapper: FreebitMapperService,
|
|
private readonly auth: FreebitAuthService,
|
|
@Inject(Logger) private readonly logger: Logger,
|
|
@Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: any
|
|
) {}
|
|
|
|
private readonly operationTimestamps = new Map<
|
|
string,
|
|
{
|
|
voice?: number;
|
|
network?: number;
|
|
plan?: number;
|
|
cancellation?: number;
|
|
}
|
|
>();
|
|
|
|
private getOperationWindow(account: string) {
|
|
if (!this.operationTimestamps.has(account)) {
|
|
this.operationTimestamps.set(account, {});
|
|
}
|
|
return this.operationTimestamps.get(account)!;
|
|
}
|
|
|
|
private assertOperationSpacing(account: string, op: "voice" | "network" | "plan") {
|
|
const windowMs = 30 * 60 * 1000;
|
|
const now = Date.now();
|
|
const entry = this.getOperationWindow(account);
|
|
|
|
if (op === "voice") {
|
|
if (entry.plan && now - entry.plan < windowMs) {
|
|
throw new BadRequestException(
|
|
"Voice feature changes must be at least 30 minutes apart from plan changes. Please try again later."
|
|
);
|
|
}
|
|
if (entry.network && now - entry.network < windowMs) {
|
|
throw new BadRequestException(
|
|
"Voice feature changes must be at least 30 minutes apart from network type updates. Please try again later."
|
|
);
|
|
}
|
|
}
|
|
|
|
if (op === "network") {
|
|
if (entry.voice && now - entry.voice < windowMs) {
|
|
throw new BadRequestException(
|
|
"Network type updates must be requested 30 minutes after voice option changes. Please try again later."
|
|
);
|
|
}
|
|
if (entry.plan && now - entry.plan < windowMs) {
|
|
throw new BadRequestException(
|
|
"Network type updates must be requested at least 30 minutes apart from plan changes. Please try again later."
|
|
);
|
|
}
|
|
}
|
|
|
|
if (op === "plan") {
|
|
if (entry.voice && now - entry.voice < windowMs) {
|
|
throw new BadRequestException(
|
|
"Plan changes must be requested 30 minutes after voice option changes. Please try again later."
|
|
);
|
|
}
|
|
if (entry.network && now - entry.network < windowMs) {
|
|
throw new BadRequestException(
|
|
"Plan changes must be requested 30 minutes after network type updates. Please try again later."
|
|
);
|
|
}
|
|
if (entry.cancellation) {
|
|
throw new BadRequestException(
|
|
"This subscription has a pending cancellation. Plan changes are no longer permitted."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private stampOperation(account: string, op: "voice" | "network" | "plan" | "cancellation") {
|
|
const entry = this.getOperationWindow(account);
|
|
entry[op] = Date.now();
|
|
}
|
|
|
|
/**
|
|
* Get SIM account details with endpoint fallback
|
|
*/
|
|
async getSimDetails(account: string): Promise<SimDetails> {
|
|
try {
|
|
const request: Omit<FreebitAccountDetailsRequest, "authKey"> = {
|
|
version: "2",
|
|
requestDatas: [{ kind: "MVNO", account }],
|
|
};
|
|
|
|
const config = this.auth.getConfig();
|
|
const configured = config.detailsEndpoint || "/master/getAcnt/";
|
|
const candidates = Array.from(
|
|
new Set([
|
|
configured,
|
|
configured.replace(/\/$/, ""),
|
|
"/master/getAcnt/",
|
|
"/master/getAcnt",
|
|
"/mvno/getAccountDetail/",
|
|
"/mvno/getAccountDetail",
|
|
"/mvno/getAcntDetail/",
|
|
"/mvno/getAcntDetail",
|
|
"/mvno/getAccountInfo/",
|
|
"/mvno/getAccountInfo",
|
|
"/mvno/getSubscriberInfo/",
|
|
"/mvno/getSubscriberInfo",
|
|
"/mvno/getInfo/",
|
|
"/mvno/getInfo",
|
|
"/master/getDetail/",
|
|
"/master/getDetail",
|
|
])
|
|
);
|
|
|
|
let response: FreebitAccountDetailsResponse | undefined;
|
|
let lastError: unknown;
|
|
|
|
for (const ep of candidates) {
|
|
try {
|
|
if (ep !== candidates[0]) {
|
|
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
|
|
}
|
|
response = await this.client.makeAuthenticatedRequest<
|
|
FreebitAccountDetailsResponse,
|
|
typeof request
|
|
>(ep, request);
|
|
break;
|
|
} catch (err: unknown) {
|
|
lastError = err;
|
|
if (getErrorMessage(err).includes("HTTP 404")) {
|
|
continue; // try next endpoint
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!response) {
|
|
if (lastError instanceof Error) {
|
|
throw lastError;
|
|
}
|
|
throw new Error("Failed to get SIM details from any endpoint");
|
|
}
|
|
|
|
return await this.mapper.mapToSimDetails(response);
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
this.logger.error(`Failed to get SIM details for account ${account}`, {
|
|
account,
|
|
error: message,
|
|
});
|
|
throw new BadRequestException(`Failed to get SIM details: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get SIM usage/traffic information
|
|
*/
|
|
async getSimUsage(account: string): Promise<SimUsage> {
|
|
try {
|
|
const request: Omit<FreebitTrafficInfoRequest, "authKey"> = { account };
|
|
|
|
const response = await this.client.makeAuthenticatedRequest<
|
|
FreebitTrafficInfoResponse,
|
|
typeof request
|
|
>("/mvno/getTrafficInfo/", request);
|
|
|
|
return this.mapper.mapToSimUsage(response);
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
this.logger.error(`Failed to get SIM usage for account ${account}`, {
|
|
account,
|
|
error: message,
|
|
});
|
|
throw new BadRequestException(`Failed to get SIM usage: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Top up SIM data quota
|
|
*/
|
|
async topUpSim(
|
|
account: string,
|
|
quotaMb: number,
|
|
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
|
|
): Promise<void> {
|
|
try {
|
|
const quotaKb = Math.round(quotaMb * 1024);
|
|
const baseRequest: Omit<FreebitTopUpRequest, "authKey"> = {
|
|
account,
|
|
quota: quotaKb,
|
|
quotaCode: options.campaignCode,
|
|
expire: options.expiryDate,
|
|
};
|
|
|
|
const scheduled = !!options.scheduledAt;
|
|
const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
|
|
const request = scheduled ? { ...baseRequest, runTime: options.scheduledAt } : baseRequest;
|
|
|
|
await this.client.makeAuthenticatedRequest<FreebitTopUpResponse, typeof request>(
|
|
endpoint,
|
|
request
|
|
);
|
|
|
|
this.logger.log(`Successfully topped up ${quotaMb}MB for account ${account}`, {
|
|
account,
|
|
endpoint,
|
|
quotaMb,
|
|
quotaKb,
|
|
scheduled,
|
|
});
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
this.logger.error(`Failed to top up SIM for account ${account}`, {
|
|
account,
|
|
quotaMb,
|
|
error: message,
|
|
});
|
|
throw new BadRequestException(`Failed to top up SIM: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get SIM top-up history
|
|
*/
|
|
async getSimTopUpHistory(
|
|
account: string,
|
|
fromDate: string,
|
|
toDate: string
|
|
): Promise<SimTopUpHistory> {
|
|
try {
|
|
const request: Omit<FreebitQuotaHistoryRequest, "authKey"> = {
|
|
account,
|
|
fromDate,
|
|
toDate,
|
|
};
|
|
|
|
const response = await this.client.makeAuthenticatedRequest<
|
|
FreebitQuotaHistoryResponse,
|
|
typeof request
|
|
>("/mvno/getQuotaHistory/", request);
|
|
|
|
return this.mapper.mapToSimTopUpHistory(response, account);
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
this.logger.error(`Failed to get SIM top-up history for account ${account}`, {
|
|
account,
|
|
fromDate,
|
|
toDate,
|
|
error: message,
|
|
});
|
|
throw new BadRequestException(`Failed to get SIM top-up history: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Change SIM plan
|
|
* Uses PA05-21 changePlan endpoint
|
|
*
|
|
* IMPORTANT CONSTRAINTS:
|
|
* - Requires runTime parameter set to 1st of following month (YYYYMMDDHHmm format)
|
|
* - Does NOT take effect immediately (unlike PA05-06 and PA05-38)
|
|
* - Must be done AFTER PA05-06 and PA05-38 (with 30-minute gaps)
|
|
* - Cannot coexist with PA02-04 (cancellation) - plan changes will cancel the cancellation
|
|
* - Must run 30 minutes apart from PA05-06 and PA05-38
|
|
*/
|
|
async changeSimPlan(
|
|
account: string,
|
|
newPlanCode: string,
|
|
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
|
try {
|
|
this.assertOperationSpacing(account, "plan");
|
|
// First, get current SIM details to log for debugging
|
|
let currentPlanCode: string | undefined;
|
|
try {
|
|
const simDetails = await this.getSimDetails(account);
|
|
currentPlanCode = simDetails.planCode;
|
|
this.logger.log(`Current SIM plan details before change`, {
|
|
account,
|
|
currentPlanCode: simDetails.planCode,
|
|
status: simDetails.status,
|
|
simType: simDetails.simType,
|
|
});
|
|
} catch (detailsError) {
|
|
this.logger.warn(`Could not fetch current SIM details`, {
|
|
account,
|
|
error: getErrorMessage(detailsError),
|
|
});
|
|
}
|
|
|
|
// PA05-21 requires runTime parameter in YYYYMMDD format (8 digits, date only)
|
|
// If not provided, default to 1st of next month
|
|
let runTime = options.scheduledAt || undefined;
|
|
if (!runTime) {
|
|
const nextMonth = new Date();
|
|
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
|
nextMonth.setDate(1);
|
|
const year = nextMonth.getFullYear();
|
|
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
|
const day = "01";
|
|
runTime = `${year}${month}${day}`;
|
|
this.logger.log(`No scheduledAt provided, defaulting to 1st of next month: ${runTime}`, {
|
|
account,
|
|
runTime,
|
|
});
|
|
}
|
|
|
|
const request: Omit<FreebitPlanChangeRequest, "authKey"> = {
|
|
account,
|
|
planCode: newPlanCode, // Use camelCase as required by Freebit API
|
|
runTime: runTime, // Always include runTime for PA05-21
|
|
// Only include globalip flag when explicitly requested
|
|
...(options.assignGlobalIp === true ? { globalip: "1" } : {}),
|
|
};
|
|
|
|
this.logger.log(`Attempting to change SIM plan via PA05-21`, {
|
|
account,
|
|
currentPlanCode,
|
|
newPlanCode,
|
|
planCode: newPlanCode,
|
|
globalip: request.globalip,
|
|
runTime: request.runTime,
|
|
scheduledAt: options.scheduledAt,
|
|
});
|
|
|
|
const response = await this.client.makeAuthenticatedRequest<
|
|
FreebitPlanChangeResponse,
|
|
typeof request
|
|
>("/mvno/changePlan/", request);
|
|
|
|
this.logger.log(`Successfully changed plan for account ${account} to ${newPlanCode}`, {
|
|
account,
|
|
newPlanCode,
|
|
assignGlobalIp: options.assignGlobalIp,
|
|
scheduled: !!options.scheduledAt,
|
|
response: {
|
|
resultCode: response.resultCode,
|
|
statusCode: response.status?.statusCode,
|
|
message: response.status?.message,
|
|
},
|
|
});
|
|
this.stampOperation(account, "plan");
|
|
|
|
return {
|
|
ipv4: response.ipv4,
|
|
ipv6: response.ipv6,
|
|
};
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
|
|
// Extract Freebit error details if available
|
|
const errorDetails: Record<string, unknown> = {
|
|
account,
|
|
newPlanCode,
|
|
planCode: newPlanCode, // Use camelCase
|
|
globalip: options.assignGlobalIp ? "1" : undefined,
|
|
runTime: options.scheduledAt,
|
|
error: message,
|
|
};
|
|
|
|
if (error instanceof Error) {
|
|
errorDetails.errorName = error.name;
|
|
errorDetails.errorMessage = error.message;
|
|
|
|
// Check if it's a FreebitError with additional properties
|
|
if ('resultCode' in error) {
|
|
errorDetails.resultCode = error.resultCode;
|
|
}
|
|
if ('statusCode' in error) {
|
|
errorDetails.statusCode = error.statusCode;
|
|
}
|
|
if ('statusMessage' in error) {
|
|
errorDetails.statusMessage = error.statusMessage;
|
|
}
|
|
}
|
|
|
|
this.logger.error(`Failed to change SIM plan for account ${account}`, errorDetails);
|
|
throw new BadRequestException(`Failed to change SIM plan: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update SIM features (voice options and network type)
|
|
*
|
|
* IMPORTANT TIMING CONSTRAINTS from Freebit API:
|
|
* - PA05-06 (voice features): Runs with immediate effect
|
|
* - PA05-38 (contract line): Runs with immediate effect
|
|
* - PA05-21 (plan change): Requires runTime parameter, scheduled for 1st of following month
|
|
* - These must run 30 minutes apart to avoid canceling each other
|
|
* - PA05-06 and PA05-38 should be done first, then PA05-21 last (since it's scheduled)
|
|
* - PA05-21 and PA02-04 (cancellation) cannot coexist
|
|
*/
|
|
async updateSimFeatures(
|
|
account: string,
|
|
features: {
|
|
voiceMailEnabled?: boolean;
|
|
callWaitingEnabled?: boolean;
|
|
internationalRoamingEnabled?: boolean;
|
|
networkType?: "4G" | "5G";
|
|
}
|
|
): Promise<void> {
|
|
try {
|
|
const voiceFeatures = {
|
|
voiceMailEnabled: features.voiceMailEnabled,
|
|
callWaitingEnabled: features.callWaitingEnabled,
|
|
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
|
};
|
|
|
|
const hasVoiceFeatures = Object.values(voiceFeatures).some(value => typeof value === "boolean");
|
|
const hasNetworkTypeChange = typeof features.networkType === "string";
|
|
|
|
// Execute in sequence with 30-minute delays as per Freebit API requirements
|
|
if (hasVoiceFeatures && hasNetworkTypeChange) {
|
|
// Both voice features and network type change requested
|
|
this.logger.log(`Updating both voice features and network type with required 30-minute delay`, {
|
|
account,
|
|
hasVoiceFeatures,
|
|
hasNetworkTypeChange,
|
|
});
|
|
|
|
// Step 1: Update voice features immediately (PA05-06)
|
|
await this.updateVoiceFeatures(account, voiceFeatures);
|
|
this.logger.log(`Voice features updated, scheduling network type change in 30 minutes`, {
|
|
account,
|
|
networkType: features.networkType,
|
|
});
|
|
|
|
// Step 2: Schedule network type change 30 minutes later (PA05-38)
|
|
// Note: This uses setTimeout which is not ideal for production
|
|
// Consider using a job queue like Bull or agenda for production
|
|
setTimeout(async () => {
|
|
try {
|
|
await this.updateNetworkType(account, features.networkType!);
|
|
this.logger.log(`Network type change completed after 30-minute delay`, {
|
|
account,
|
|
networkType: features.networkType,
|
|
});
|
|
} catch (error) {
|
|
this.logger.error(`Failed to update network type after 30-minute delay`, {
|
|
account,
|
|
networkType: features.networkType,
|
|
error: getErrorMessage(error),
|
|
});
|
|
}
|
|
}, 30 * 60 * 1000); // 30 minutes
|
|
|
|
this.logger.log(`Voice features updated immediately, network type scheduled for 30 minutes`, {
|
|
account,
|
|
voiceMailEnabled: features.voiceMailEnabled,
|
|
callWaitingEnabled: features.callWaitingEnabled,
|
|
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
|
networkType: features.networkType,
|
|
});
|
|
|
|
} else if (hasVoiceFeatures) {
|
|
// Only voice features (PA05-06)
|
|
await this.updateVoiceFeatures(account, voiceFeatures);
|
|
this.logger.log(`Voice features updated successfully`, {
|
|
account,
|
|
voiceMailEnabled: features.voiceMailEnabled,
|
|
callWaitingEnabled: features.callWaitingEnabled,
|
|
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
|
});
|
|
|
|
} else if (hasNetworkTypeChange) {
|
|
// Only network type change (PA05-38)
|
|
await this.updateNetworkType(account, features.networkType!);
|
|
this.logger.log(`Network type updated successfully`, {
|
|
account,
|
|
networkType: features.networkType,
|
|
});
|
|
}
|
|
|
|
this.logger.log(`Successfully updated SIM features for account ${account}`, {
|
|
account,
|
|
voiceMailEnabled: features.voiceMailEnabled,
|
|
callWaitingEnabled: features.callWaitingEnabled,
|
|
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
|
networkType: features.networkType,
|
|
});
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
this.logger.error(`Failed to update SIM features for account ${account}`, {
|
|
account,
|
|
features,
|
|
error: message,
|
|
errorStack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
throw new BadRequestException(`Failed to update SIM features: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update voice features (voicemail, call waiting, international roaming)
|
|
* Uses PA05-06 MVNO Voice Option Change endpoint - runs with immediate effect
|
|
*
|
|
* Error codes specific to PA05-06:
|
|
* - 243: Voice option (list) problem
|
|
* - 244: Voicemail parameter problem
|
|
* - 245: Call waiting parameter problem
|
|
* - 250: WORLD WING parameter problem
|
|
*/
|
|
private async updateVoiceFeatures(
|
|
account: string,
|
|
features: {
|
|
voiceMailEnabled?: boolean;
|
|
callWaitingEnabled?: boolean;
|
|
internationalRoamingEnabled?: boolean;
|
|
}
|
|
): Promise<void> {
|
|
try {
|
|
this.assertOperationSpacing(account, "voice");
|
|
|
|
const buildVoiceOptionPayload = (): Omit<FreebitVoiceOptionRequest, "authKey"> => {
|
|
const talkOption: FreebitVoiceOptionSettings = {};
|
|
|
|
if (typeof features.voiceMailEnabled === "boolean") {
|
|
talkOption.voiceMail = features.voiceMailEnabled ? "10" : "20";
|
|
}
|
|
|
|
if (typeof features.callWaitingEnabled === "boolean") {
|
|
talkOption.callWaiting = features.callWaitingEnabled ? "10" : "20";
|
|
}
|
|
|
|
if (typeof features.internationalRoamingEnabled === "boolean") {
|
|
talkOption.worldWing = features.internationalRoamingEnabled ? "10" : "20";
|
|
if (features.internationalRoamingEnabled) {
|
|
talkOption.worldWingCreditLimit = "50000"; // minimum permitted when enabling
|
|
}
|
|
}
|
|
|
|
if (Object.keys(talkOption).length === 0) {
|
|
throw new BadRequestException("No voice options specified for update");
|
|
}
|
|
|
|
return {
|
|
account,
|
|
userConfirmed: "10",
|
|
aladinOperated: "10",
|
|
talkOption,
|
|
};
|
|
};
|
|
|
|
const voiceOptionPayload = buildVoiceOptionPayload();
|
|
|
|
this.logger.debug("Submitting voice option change via /mvno/talkoption/changeOrder/ (PA05-06)", {
|
|
account,
|
|
payload: voiceOptionPayload,
|
|
});
|
|
|
|
await this.client.makeAuthenticatedRequest<
|
|
FreebitVoiceOptionResponse,
|
|
typeof voiceOptionPayload
|
|
>("/mvno/talkoption/changeOrder/", voiceOptionPayload);
|
|
|
|
this.logger.log("Voice option change completed via PA05-06", {
|
|
account,
|
|
voiceMailEnabled: features.voiceMailEnabled,
|
|
callWaitingEnabled: features.callWaitingEnabled,
|
|
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
|
});
|
|
this.stampOperation(account, "voice");
|
|
|
|
// Save to database for future retrieval
|
|
if (this.voiceOptionsService) {
|
|
try {
|
|
await this.voiceOptionsService.saveVoiceOptions(account, features);
|
|
} catch (dbError) {
|
|
this.logger.warn("Failed to save voice options to database (non-fatal)", {
|
|
account,
|
|
error: getErrorMessage(dbError),
|
|
});
|
|
}
|
|
}
|
|
|
|
return;
|
|
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
this.logger.error(`Failed to update voice features for account ${account}`, {
|
|
account,
|
|
features,
|
|
error: message,
|
|
errorStack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
throw new BadRequestException(`Failed to update voice features: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update network type (4G/5G)
|
|
* Uses PA05-38 contract line change - runs with immediate effect
|
|
* NOTE: Must be called 30 minutes after PA05-06 if both are being updated
|
|
*/
|
|
private async updateNetworkType(account: string, networkType: "4G" | "5G"): Promise<void> {
|
|
try {
|
|
this.assertOperationSpacing(account, "network");
|
|
let eid: string | undefined;
|
|
let productNumber: string | undefined;
|
|
try {
|
|
const details = await this.getSimDetails(account);
|
|
if (details.eid) {
|
|
eid = details.eid;
|
|
} else if (details.iccid) {
|
|
productNumber = details.iccid;
|
|
}
|
|
this.logger.debug(`Resolved SIM identifiers for contract line change`, {
|
|
account,
|
|
eid,
|
|
productNumber,
|
|
currentNetworkType: details.networkType,
|
|
});
|
|
if (details.networkType?.toUpperCase() === networkType.toUpperCase()) {
|
|
this.logger.log(`Network type already ${networkType} for account ${account}; skipping update.`, {
|
|
account,
|
|
networkType,
|
|
});
|
|
return;
|
|
}
|
|
} catch (resolveError) {
|
|
this.logger.warn(`Unable to resolve SIM identifiers before contract line change`, {
|
|
account,
|
|
error: getErrorMessage(resolveError),
|
|
});
|
|
}
|
|
|
|
const request: Omit<FreebitContractLineChangeRequest, "authKey"> = {
|
|
account,
|
|
contractLine: networkType,
|
|
...(eid ? { eid } : {}),
|
|
...(productNumber ? { productNumber } : {}),
|
|
};
|
|
|
|
this.logger.debug(`Updating network type via PA05-38 for account ${account}`, {
|
|
account,
|
|
networkType,
|
|
request,
|
|
});
|
|
|
|
const response = await this.client.makeAuthenticatedJsonRequest<
|
|
FreebitContractLineChangeResponse,
|
|
typeof request
|
|
>("/mvno/contractline/change/", request);
|
|
|
|
this.logger.log(`Successfully updated network type for account ${account}`, {
|
|
account,
|
|
networkType,
|
|
resultCode: response.resultCode,
|
|
statusCode: response.status?.statusCode,
|
|
message: response.status?.message,
|
|
});
|
|
this.stampOperation(account, "network");
|
|
|
|
// Save to database for future retrieval
|
|
if (this.voiceOptionsService) {
|
|
try {
|
|
await this.voiceOptionsService.saveVoiceOptions(account, { networkType });
|
|
} catch (dbError) {
|
|
this.logger.warn("Failed to save network type to database (non-fatal)", {
|
|
account,
|
|
error: getErrorMessage(dbError),
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
this.logger.error(`Failed to update network type for account ${account}`, {
|
|
account,
|
|
networkType,
|
|
error: message,
|
|
errorStack: error instanceof Error ? error.stack : undefined,
|
|
});
|
|
throw new BadRequestException(`Failed to update network type: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel SIM service
|
|
* Uses PA02-04 cancellation endpoint
|
|
*
|
|
* IMPORTANT CONSTRAINTS:
|
|
* - Must be sent with runDate as 1st of client's cancellation month n+1
|
|
* (e.g., cancel end of Jan = runDate 20250201)
|
|
* - After PA02-04 is sent, any subsequent PA05-21 calls will cancel it
|
|
* - PA05-21 and PA02-04 cannot coexist
|
|
* - Must prevent clients from making further changes after cancellation is requested
|
|
*/
|
|
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
|
try {
|
|
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
|
|
account,
|
|
runTime: scheduledAt,
|
|
};
|
|
|
|
this.logger.log(`Cancelling SIM service via PA02-04 for account ${account}`, {
|
|
account,
|
|
runTime: scheduledAt,
|
|
note: "After this, PA05-21 plan changes will cancel the cancellation",
|
|
});
|
|
|
|
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
|
|
"/mvno/releasePlan/",
|
|
request
|
|
);
|
|
|
|
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
|
|
account,
|
|
runTime: scheduledAt,
|
|
});
|
|
this.stampOperation(account, "cancellation");
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
this.logger.error(`Failed to cancel SIM for account ${account}`, {
|
|
account,
|
|
scheduledAt,
|
|
error: message,
|
|
});
|
|
throw new BadRequestException(`Failed to cancel SIM: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reissue eSIM profile (simple version)
|
|
*/
|
|
async reissueEsimProfile(account: string): Promise<void> {
|
|
try {
|
|
const request: Omit<FreebitEsimReissueRequest, "authKey"> = {
|
|
requestDatas: [{ kind: "MVNO", account }],
|
|
};
|
|
|
|
await this.client.makeAuthenticatedRequest<FreebitEsimReissueResponse, typeof request>(
|
|
"/mvno/reissueEsim/",
|
|
request
|
|
);
|
|
|
|
this.logger.log(`Successfully reissued eSIM profile for account ${account}`);
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
this.logger.error(`Failed to reissue eSIM profile for account ${account}`, {
|
|
account,
|
|
error: message,
|
|
});
|
|
throw new BadRequestException(`Failed to reissue eSIM profile: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reissue eSIM profile with enhanced options
|
|
*/
|
|
async reissueEsimProfileEnhanced(
|
|
account: string,
|
|
newEid: string,
|
|
options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {}
|
|
): Promise<void> {
|
|
try {
|
|
const request: Omit<FreebitEsimAddAccountRequest, "authKey"> = {
|
|
aladinOperated: "20",
|
|
account,
|
|
eid: newEid,
|
|
addKind: "R",
|
|
planCode: options.planCode,
|
|
};
|
|
|
|
await this.client.makeAuthenticatedRequest<FreebitEsimAddAccountResponse, typeof request>(
|
|
"/mvno/esim/addAcnt/",
|
|
request
|
|
);
|
|
|
|
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
|
|
account,
|
|
newEid,
|
|
oldProductNumber: options.oldProductNumber,
|
|
oldEid: options.oldEid,
|
|
});
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
|
|
account,
|
|
newEid,
|
|
error: message,
|
|
});
|
|
throw new BadRequestException(`Failed to reissue eSIM profile: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Activate new eSIM account using PA05-41 (addAcct)
|
|
*/
|
|
async activateEsimAccountNew(params: {
|
|
account: string;
|
|
eid: string;
|
|
planCode?: string;
|
|
contractLine?: "4G" | "5G";
|
|
aladinOperated?: "10" | "20";
|
|
shipDate?: string;
|
|
addKind?: "N" | "M" | "R"; // N:新規, M:MNP転入, R:再発行
|
|
simKind?: "E0" | "E2" | "E3"; // E0:音声あり, E2:SMSなし, E3:SMSあり (Required except when addKind='R')
|
|
repAccount?: string; // 代表番号
|
|
deliveryCode?: string; // 顧客コード
|
|
globalIp?: "10" | "20"; // 10:なし, 20:あり
|
|
mnp?: { reserveNumber: string; reserveExpireDate?: string };
|
|
identity?: {
|
|
firstnameKanji?: string;
|
|
lastnameKanji?: string;
|
|
firstnameZenKana?: string;
|
|
lastnameZenKana?: string;
|
|
gender?: string;
|
|
birthday?: string;
|
|
};
|
|
}): Promise<void> {
|
|
const {
|
|
account,
|
|
eid,
|
|
planCode,
|
|
contractLine,
|
|
aladinOperated = "10",
|
|
shipDate,
|
|
addKind,
|
|
simKind,
|
|
repAccount,
|
|
deliveryCode,
|
|
globalIp,
|
|
mnp,
|
|
identity,
|
|
} = params;
|
|
|
|
if (!account || !eid) {
|
|
throw new BadRequestException("activateEsimAccountNew requires account and eid");
|
|
}
|
|
|
|
const finalAddKind = addKind || "N";
|
|
|
|
// Validate simKind: Required except when addKind is 'R' (reissue)
|
|
if (finalAddKind !== "R" && !simKind) {
|
|
throw new BadRequestException(
|
|
"simKind is required for eSIM activation (use 'E0' for voice, 'E3' for SMS, 'E2' for data-only)"
|
|
);
|
|
}
|
|
|
|
try {
|
|
const payload: FreebitEsimAccountActivationRequest = {
|
|
authKey: await this.auth.getAuthKey(),
|
|
aladinOperated,
|
|
createType: "new",
|
|
eid,
|
|
account,
|
|
simkind: simKind || "E0", // Default to voice-enabled if not specified
|
|
addKind: finalAddKind,
|
|
planCode,
|
|
contractLine,
|
|
shipDate,
|
|
repAccount,
|
|
deliveryCode,
|
|
globalIp,
|
|
...(mnp ? { mnp } : {}),
|
|
...(identity ? identity : {}),
|
|
} as FreebitEsimAccountActivationRequest;
|
|
|
|
// Use JSON request for PA05-41
|
|
await this.client.makeAuthenticatedJsonRequest<
|
|
FreebitEsimAccountActivationResponse,
|
|
FreebitEsimAccountActivationRequest
|
|
>("/mvno/esim/addAcct/", payload);
|
|
|
|
this.logger.log("Successfully activated new eSIM account via PA05-41", {
|
|
account,
|
|
planCode,
|
|
contractLine,
|
|
addKind: addKind || "N",
|
|
scheduled: !!shipDate,
|
|
mnp: !!mnp,
|
|
});
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
this.logger.error(`Failed to activate new eSIM account ${account}`, {
|
|
account,
|
|
eid,
|
|
planCode,
|
|
addKind,
|
|
error: message,
|
|
});
|
|
throw new BadRequestException(`Failed to activate new eSIM account: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Health check - test API connectivity
|
|
*/
|
|
async healthCheck(): Promise<boolean> {
|
|
try {
|
|
// Try a simple endpoint first
|
|
const simpleCheck = await this.client.makeSimpleRequest("/");
|
|
if (simpleCheck) {
|
|
return true;
|
|
}
|
|
|
|
// If simple check fails, try authenticated request
|
|
await this.auth.getAuthKey();
|
|
return true;
|
|
} catch (error) {
|
|
this.logger.debug("Freebit health check failed", {
|
|
error: getErrorMessage(error),
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
}
|