tema 675f7d5cfd Remove cached profile fields migration and update CSRF middleware for new public auth endpoints
- 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.
2025-11-21 17:12:34 +09:00

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