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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|