Assist_Design/apps/bff/src/vendors/freebit/freebit.service.ts
tema bccc476283 Refactor SIM management and Freebit service for improved error handling and type safety
- Consolidated error handling in SimManagementService and FreebititService to provide clearer logging and user feedback.
- Enhanced type safety in FreebititService by refining type definitions for API requests and responses.
- Updated various components to ensure consistent error handling and improved user experience during SIM management actions.
2025-09-10 18:31:16 +09:00

998 lines
32 KiB
TypeScript

import {
Injectable,
Inject,
BadRequestException,
InternalServerErrorException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import type {
FreebititConfig,
FreebititAuthRequest,
FreebititAuthResponse,
FreebititAccountDetailsRequest,
FreebititAccountDetailsResponse,
FreebititTrafficInfoRequest,
FreebititTrafficInfoResponse,
FreebititTopUpRequest,
FreebititTopUpResponse,
FreebititQuotaHistoryRequest,
FreebititQuotaHistoryResponse,
FreebititPlanChangeRequest,
FreebititPlanChangeResponse,
FreebititCancelPlanRequest,
FreebititCancelPlanResponse,
FreebititEsimAddAccountRequest,
FreebititEsimAddAccountResponse,
SimDetails,
SimUsage,
SimTopUpHistory,
FreebititAddSpecRequest,
FreebititAddSpecResponse,
FreebititVoiceOptionChangeResponse,
FreebititContractLineChangeResponse,
FreebititCancelAccountRequest,
FreebititCancelAccountResponse,
} from "./interfaces/freebit.types";
// Workaround for TS name resolution under isolatedModules where generics may lose context
// Import the activation interfaces as value imports (harmless at runtime) to satisfy the type checker
import {
FreebititEsimAccountActivationRequest,
FreebititEsimAccountActivationResponse,
} from "./interfaces/freebit.types";
@Injectable()
export class FreebititService {
private readonly config: FreebititConfig;
private authKeyCache: {
token: string;
expiresAt: number;
} | null = null;
constructor(
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
) {
this.config = {
baseUrl:
this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1-q.mvno.net/emptool/api/",
oemId: this.configService.get<string>("FREEBIT_OEM_ID") || "PASI",
oemKey: this.configService.get<string>("FREEBIT_OEM_KEY") || "",
timeout: this.configService.get<number>("FREEBIT_TIMEOUT") || 30000,
retryAttempts: this.configService.get<number>("FREEBIT_RETRY_ATTEMPTS") || 3,
detailsEndpoint:
this.configService.get<string>("FREEBIT_DETAILS_ENDPOINT") || "/master/getAcnt/",
};
// Warn if critical configuration is missing
if (!this.config.oemKey) {
this.logger.warn("FREEBIT_OEM_KEY is not configured. SIM management features will not work.");
}
this.logger.debug("Freebit service initialized", {
baseUrl: this.config.baseUrl,
oemId: this.config.oemId,
hasOemKey: !!this.config.oemKey,
});
}
/**
* Map Freebit SIM status to portal status
*/
private mapSimStatus(freebititStatus: string): "active" | "suspended" | "cancelled" | "pending" {
switch (freebititStatus) {
case "active":
return "active";
case "suspended":
return "suspended";
case "temporary":
case "waiting":
return "pending";
case "obsolete":
return "cancelled";
default:
return "pending";
}
}
/**
* Get or refresh authentication token
*/
private async getAuthKey(): Promise<string> {
// Check if we have a valid cached token
if (this.authKeyCache && this.authKeyCache.expiresAt > Date.now()) {
return this.authKeyCache.token;
}
try {
// Check if configuration is available
if (!this.config.oemKey) {
throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing");
}
const request: FreebititAuthRequest = {
oemId: this.config.oemId,
oemKey: this.config.oemKey,
};
const response = await fetch(`${this.config.baseUrl}/authOem/`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: `json=${JSON.stringify(request)}`,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as FreebititAuthResponse;
if (data.resultCode !== "100") {
throw new FreebititErrorImpl(
`Authentication failed: ${data.status.message}`,
data.resultCode,
data.status.statusCode,
data.status.message
);
}
// Cache the token for 50 minutes (assuming 60min expiry)
this.authKeyCache = {
token: data.authKey,
expiresAt: Date.now() + 50 * 60 * 1000,
};
this.logger.log("Successfully authenticated with Freebit API");
return data.authKey;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error("Failed to authenticate with Freebit API", { error: message });
throw new InternalServerErrorException("Failed to authenticate with Freebit API");
}
}
/**
* Make authenticated API request with error handling
*/
private async makeAuthenticatedRequest<
T extends {
resultCode: string | number;
status?: { message?: string; statusCode?: string | number };
},
>(endpoint: string, data: unknown): Promise<T> {
const authKey = await this.getAuthKey();
const requestData = { ...(data as Record<string, unknown>), authKey };
try {
const url = `${this.config.baseUrl}${endpoint}`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: `json=${JSON.stringify(requestData)}`,
});
if (!response.ok) {
const text: string | null = await response.text().catch(() => null);
const bodySnippet: string | undefined = text ? text.slice(0, 500) : undefined;
this.logger.error("Freebit API non-OK response", {
endpoint,
url,
status: response.status,
statusText: response.statusText,
body: bodySnippet,
});
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const responseData = (await response.json()) as T;
// Check for API-level errors (some endpoints return resultCode '101' with message 'OK')
const rc = String(
(responseData as { resultCode?: string | number } | undefined)?.resultCode ?? ""
);
const statusObj =
(
responseData as
| { status?: { message?: string; statusCode?: string | number } }
| undefined
)?.status ?? {};
const errorMessage = String(
(statusObj as { message?: string }).message ??
(responseData as { message?: string } | undefined)?.message ??
"Unknown error"
);
const statusCodeStr = String(
(statusObj as { statusCode?: string | number }).statusCode ??
(responseData as { statusCode?: string | number } | undefined)?.statusCode ??
""
);
const msgUpper = errorMessage.toUpperCase();
const isOkByRc = rc === "100" || rc === "101";
const isOkByMessage = msgUpper === "OK" || msgUpper === "SUCCESS";
const isOkByStatus = statusCodeStr === "200";
if (!(isOkByRc || isOkByMessage || isOkByStatus)) {
// Provide more specific error messages for common cases
let userFriendlyMessage = `API Error: ${errorMessage}`;
if (errorMessage === "NG") {
userFriendlyMessage = `Account not found or invalid in Freebit system. Please verify the account number exists and is properly configured.`;
} else if (errorMessage.toLowerCase().includes("auth")) {
userFriendlyMessage = `Authentication failed with Freebit API. Please check API credentials.`;
} else if (errorMessage.toLowerCase().includes("timeout")) {
userFriendlyMessage = `Request timeout to Freebit API. Please try again later.`;
}
this.logger.error("Freebit API error response", {
endpoint,
resultCode: rc,
statusCode: statusCodeStr,
message: errorMessage,
userFriendlyMessage,
});
throw new FreebititErrorImpl(userFriendlyMessage, rc, statusCodeStr, errorMessage);
}
this.logger.debug("Freebit API Request Success", {
endpoint,
resultCode: rc,
});
return responseData;
} catch (error) {
if (error instanceof FreebititErrorImpl) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Freebit API request failed: ${endpoint}`, { error: message });
throw new InternalServerErrorException(`Freebit API request failed: ${message}`);
}
}
// Make authenticated JSON POST request (for endpoints that require JSON body)
private async makeAuthenticatedJsonRequest<T>(
endpoint: string,
body: Record<string, unknown>
): Promise<T> {
const authKey = await this.getAuthKey();
const url = `${this.config.baseUrl}${endpoint}`;
const payload = { ...body, authKey };
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const text = await response.text().catch(() => null);
this.logger.error("Freebit JSON API non-OK", {
endpoint,
status: response.status,
statusText: response.statusText,
body: text?.slice(0, 500),
});
throw new InternalServerErrorException(`HTTP ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as T & {
resultCode?: string | number;
message?: string;
status?: { message?: string; statusCode?: string | number };
statusCode?: string | number;
};
const rc = String(data?.resultCode ?? "");
if (rc !== "100") {
const message = data?.message || data?.status?.message || "Unknown error";
this.logger.error("Freebit JSON API error response", {
endpoint,
resultCode: rc,
statusCode: data?.statusCode || data?.status?.statusCode,
message,
});
throw new FreebititErrorImpl(
`API Error: ${message}`,
rc,
String(data?.statusCode || ""),
message
);
}
this.logger.debug("Freebit JSON API Request Success", { endpoint, resultCode: rc });
return data;
}
/**
* Get detailed SIM account information
*/
async getSimDetails(account: string): Promise<SimDetails> {
try {
const request: Omit<FreebititAccountDetailsRequest, "authKey"> = {
version: "2",
requestDatas: [{ kind: "MVNO", account }],
};
const configured = this.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: FreebititAccountDetailsResponse | 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.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(
ep,
request
);
break; // success
} catch (err: unknown) {
lastError = err;
if (err instanceof Error && err.message.includes("HTTP 404")) {
// try next candidate
continue;
}
// non-404 error, rethrow
throw err;
}
}
if (!response) {
if (lastError instanceof Error) {
throw lastError;
}
throw new InternalServerErrorException("Failed to fetch SIM details: all endpoints failed");
}
type AcctDetailItem = {
kind?: string;
account?: string | number;
state?: string;
startDate?: string | number;
relationCode?: string;
resultCode?: string | number;
planCode?: string;
iccid?: string | number;
imsi?: string | number;
eid?: string;
contractLine?: string;
size?: string;
sms?: number;
talk?: number;
ipv4?: string;
ipv6?: string;
quota?: number;
async?: { func: string; date: string | number };
voicemail?: number;
voiceMail?: number;
callwaiting?: number;
callWaiting?: number;
worldwing?: number;
worldWing?: number;
};
const datas = response.responseDatas as unknown;
const list = Array.isArray(datas)
? (datas as AcctDetailItem[])
: datas
? [datas as AcctDetailItem]
: [];
if (!list.length) {
throw new BadRequestException("No SIM details found for this account");
}
// Prefer the MVNO entry if present
const mvno = list.find(d => String(d.kind ?? "").toUpperCase() === "MVNO") || list[0];
const simData = mvno;
const startDateRaw = simData.startDate ? String(simData.startDate) : undefined;
const startDate =
startDateRaw && /^\d{8}$/.test(startDateRaw)
? `${startDateRaw.slice(0, 4)}-${startDateRaw.slice(4, 6)}-${startDateRaw.slice(6, 8)}`
: startDateRaw;
const simDetails: SimDetails = {
account: String(simData.account ?? account),
msisdn: String(simData.account ?? account),
iccid: simData.iccid ? String(simData.iccid) : undefined,
imsi: simData.imsi ? String(simData.imsi) : undefined,
eid: simData.eid,
planCode: String(simData.planCode ?? ""),
status: this.mapSimStatus(String(simData.state || "pending")),
simType: simData.eid ? "esim" : "physical",
size: ((): "standard" | "nano" | "micro" | "esim" => {
const sizeVal = String(simData.size ?? "").toLowerCase();
if (
sizeVal === "standard" ||
sizeVal === "nano" ||
sizeVal === "micro" ||
sizeVal === "esim"
) {
return sizeVal;
}
return simData.eid ? "esim" : "nano";
})(),
hasVoice: simData.talk === 10,
hasSms: simData.sms === 10,
remainingQuotaKb: typeof simData.quota === "number" ? simData.quota : 0,
remainingQuotaMb:
typeof simData.quota === "number" ? Math.round((simData.quota / 1000) * 100) / 100 : 0,
startDate,
ipv4: simData.ipv4,
ipv6: simData.ipv6,
voiceMailEnabled: simData.voicemail === 10 || simData.voiceMail === 10,
callWaitingEnabled: simData.callwaiting === 10 || simData.callWaiting === 10,
internationalRoamingEnabled: simData.worldwing === 10 || simData.worldWing === 10,
networkType: simData.contractLine || undefined,
pendingOperations: simData.async
? [
{
operation: simData.async.func,
scheduledDate: String(simData.async.date),
},
]
: undefined,
};
this.logger.log(`Retrieved SIM details for account ${account}`, {
account,
status: simDetails.status,
planCode: simDetails.planCode,
});
return simDetails;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to get SIM details for account ${account}`, { error: message });
throw error as Error;
}
}
/**
* Get SIM data usage information
*/
async getSimUsage(account: string): Promise<SimUsage> {
try {
const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account };
const response = await this.makeAuthenticatedRequest<FreebititTrafficInfoResponse>(
"/mvno/getTrafficInfo/",
request
);
const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
usageKb: parseInt(usage, 10) || 0,
usageMb: Math.round((parseInt(usage, 10) / 1000) * 100) / 100,
}));
const simUsage: SimUsage = {
account,
todayUsageKb,
todayUsageMb: Math.round((todayUsageKb / 1000) * 100) / 100,
recentDaysUsage: recentDaysData,
isBlacklisted: response.traffic.blackList === "10",
};
this.logger.log(`Retrieved SIM usage for account ${account}`, {
account,
todayUsageMb: simUsage.todayUsageMb,
isBlacklisted: simUsage.isBlacklisted,
});
return simUsage;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to get SIM usage for account ${account}`, { error: message });
throw error as Error;
}
}
/**
* Top up SIM data quota
*/
async topUpSim(
account: string,
quotaMb: number,
options: {
campaignCode?: string;
expiryDate?: string;
scheduledAt?: string;
} = {}
): Promise<void> {
try {
// Units per endpoint:
// - Immediate (PA04-04 /master/addSpec/): quota in MB (string), requires kind: 'MVNO'
// - Scheduled (PA05-22 /mvno/eachQuota/): quota in KB (string), accepts runTime
const quotaKb = quotaMb * 1000; // KB using decimal base to align with Freebit examples
const quotaMbStr = String(Math.round(quotaMb));
const quotaKbStr = String(Math.round(quotaKb));
const isScheduled = !!options.scheduledAt;
const endpoint = isScheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
let request: Omit<FreebititTopUpRequest, "authKey">;
if (isScheduled) {
// PA05-22: KB + runTime
request = {
account,
quota: quotaKbStr,
quotaCode: options.campaignCode,
expire: options.expiryDate,
runTime: options.scheduledAt,
};
} else {
// PA04-04: MB + kind
request = {
account,
kind: "MVNO",
quota: quotaMbStr,
quotaCode: options.campaignCode,
expire: options.expiryDate,
};
}
await this.makeAuthenticatedRequest<FreebititTopUpResponse>(endpoint, request);
this.logger.log(`Successfully topped up SIM ${account}`, {
account,
endpoint,
quotaMb,
quotaKb,
units: isScheduled ? "KB (PA05-22)" : "MB (PA04-04)",
campaignCode: options.campaignCode,
scheduled: isScheduled,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to top up SIM ${account}`, { error: message, account, quotaMb });
throw error as Error;
}
}
/**
* Get SIM top-up history
*/
async getSimTopUpHistory(
account: string,
fromDate: string,
toDate: string
): Promise<SimTopUpHistory> {
try {
const request: Omit<FreebititQuotaHistoryRequest, "authKey"> = {
account,
fromDate,
toDate,
};
const response = await this.makeAuthenticatedRequest<FreebititQuotaHistoryResponse>(
"/mvno/getQuotaHistory/",
request
);
const history: SimTopUpHistory = {
account,
totalAdditions: response.total,
additionCount: response.count,
history: response.quotaHistory.map(item => ({
quotaKb: parseInt(item.quota, 10),
quotaMb: Math.round((parseInt(item.quota, 10) / 1000) * 100) / 100,
addedDate: item.date,
expiryDate: item.expire,
campaignCode: item.quotaCode,
})),
};
this.logger.log(`Retrieved SIM top-up history for account ${account}`, {
account,
totalAdditions: history.totalAdditions,
additionCount: history.additionCount,
});
return history;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to get SIM top-up history for account ${account}`, {
error: message,
});
throw error as Error;
}
}
/**
* Change SIM plan
*/
async changeSimPlan(
account: string,
newPlanCode: string,
options: {
assignGlobalIp?: boolean;
scheduledAt?: string;
} = {}
): Promise<{ ipv4?: string; ipv6?: string }> {
try {
const request: Omit<FreebititPlanChangeRequest, "authKey"> = {
account,
planCode: newPlanCode,
globalip: options.assignGlobalIp ? "1" : "0",
runTime: options.scheduledAt,
};
const response = await this.makeAuthenticatedRequest<FreebititPlanChangeResponse>(
"/mvno/changePlan/",
request
);
this.logger.log(`Successfully changed SIM plan for account ${account}`, {
account,
newPlanCode,
assignGlobalIp: options.assignGlobalIp,
scheduled: !!options.scheduledAt,
});
return {
ipv4: response.ipv4,
ipv6: response.ipv6,
};
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to change SIM plan for account ${account}`, {
error: message,
account,
newPlanCode,
});
throw error as Error;
}
}
/**
* Update SIM optional features (voicemail, call waiting, international roaming, network type)
* Uses AddSpec endpoint for immediate changes
*/
async updateSimFeatures(
account: string,
features: {
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: string; // '4G' | '5G'
}
): Promise<void> {
try {
const doVoice =
typeof features.voiceMailEnabled === "boolean" ||
typeof features.callWaitingEnabled === "boolean" ||
typeof features.internationalRoamingEnabled === "boolean";
const doContract = typeof features.networkType === "string";
if (doVoice) {
const talkOption: {
voiceMail?: "10" | "20";
callWaiting?: "10" | "20";
worldWing?: "10" | "20";
} = {};
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";
}
await this.makeAuthenticatedRequest<FreebititVoiceOptionChangeResponse>(
"/mvno/talkoption/changeOrder/",
{
account,
userConfirmed: "10",
aladinOperated: "10",
talkOption,
}
);
this.logger.log("Applied voice option change (PA05-06)", { account, talkOption });
}
if (doContract && features.networkType) {
// Contract line change endpoint expects form-encoded payload (json=...)
await this.makeAuthenticatedRequest<FreebititContractLineChangeResponse>(
"/mvno/contractline/change/",
{
account,
contractLine: features.networkType,
}
);
this.logger.log("Applied contract line change (PA05-38)", {
account,
contractLine: features.networkType,
});
}
this.logger.log(`Updated SIM features for account ${account}`, {
account,
voiceMailEnabled: features.voiceMailEnabled,
callWaitingEnabled: features.callWaitingEnabled,
internationalRoamingEnabled: features.internationalRoamingEnabled,
networkType: features.networkType,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to update SIM features for account ${account}`, {
error: message,
account,
});
throw error as Error;
}
}
/**
* Cancel SIM service via PA02-04 (master/cnclAcnt)
*/
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
try {
const req: Omit<FreebititCancelAccountRequest, "authKey"> = {
kind: "MVNO",
account,
runDate: scheduledAt,
};
await this.makeAuthenticatedRequest<FreebititCancelAccountResponse>("/master/cnclAcnt/", req);
this.logger.log(`Successfully requested cancellation (PA02-04) for account ${account}`, {
account,
runDate: scheduledAt,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to request cancellation (PA02-04) for account ${account}`, {
error: message,
account,
});
throw error as Error;
}
}
/**
* Reissue eSIM profile using reissueProfile endpoint
*/
async reissueEsimProfile(account: string): Promise<void> {
try {
// Use PA05-41 eSIM Account Activation API (addAcct) for reissue
const authKey = await this.getAuthKey();
// Fetch details to get current EID and plan/network where available
const details = await this.getSimDetails(account);
if (details.simType !== "esim") {
throw new BadRequestException("This operation is only available for eSIM subscriptions");
}
if (!details.eid) {
throw new BadRequestException("eSIM EID not found for this account");
}
const payload: FreebititEsimAccountActivationRequest = {
authKey,
aladinOperated: "20",
createType: "reissue",
eid: details.eid, // existing EID used for reissue
account,
simkind: "esim",
addKind: "R",
// Optional enrichments omitted to minimize validation mismatches
};
const url = `${this.config.baseUrl}/mvno/esim/addAcct/`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
this.logger.error("Freebit PA05-41 HTTP error", {
url,
status: response.status,
statusText: response.statusText,
body: text?.slice(0, 500),
});
throw new InternalServerErrorException(`HTTP ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as FreebititEsimAccountActivationResponse;
const rc =
typeof data.resultCode === "number" ? String(data.resultCode) : data.resultCode || "";
if (rc !== "100") {
const message = data.message || "Unknown error";
this.logger.error("Freebit PA05-41 API error response", {
endpoint: "/mvno/esim/addAcct/",
resultCode: data.resultCode,
statusCode: data.statusCode,
message,
});
throw new FreebititErrorImpl(
`API Error: ${message}`,
rc || "0",
data.statusCode || "0",
message
);
}
this.logger.log(`Successfully reissued eSIM profile via PA05-41 for account ${account}`, {
account,
});
} catch (error: unknown) {
if (error instanceof BadRequestException) throw error;
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to reissue eSIM profile via PA05-41 for account ${account}`, {
error: message,
account,
});
throw error as Error;
}
}
/**
* Reissue eSIM profile using addAcnt endpoint (enhanced method based on Salesforce implementation)
*/
async reissueEsimProfileEnhanced(
account: string,
newEid: string,
options: {
oldProductNumber?: string;
oldEid?: string;
planCode?: string;
} = {}
): Promise<void> {
try {
const request: Omit<FreebititEsimAddAccountRequest, "authKey"> = {
aladinOperated: "20",
account,
eid: newEid,
addKind: "R", // R = reissue
reissue: {
oldProductNumber: options.oldProductNumber,
oldEid: options.oldEid,
},
};
// Add optional fields
if (options.planCode) {
request.planCode = options.planCode;
}
await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse>(
"/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: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
error: message,
account,
newEid,
});
throw error as Error;
}
}
/**
* Activate a new eSIM account via PA05-41 addAcct (JSON API)
* This supports optional scheduling (shipDate) and MNP payload.
*/
async activateEsimAccountNew(params: {
account: string; // MSISDN to be activated (required by Freebit)
eid: string; // 32-digit EID
planCode?: string;
contractLine?: "4G" | "5G";
aladinOperated?: "10" | "20";
shipDate?: string; // YYYYMMDD; if provided we send as scheduled activation date
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,
mnp,
identity,
} = params;
if (!account || !eid) {
throw new BadRequestException("activateEsimAccountNew requires account and eid");
}
const payload: FreebititEsimAccountActivationRequest = {
authKey: await this.getAuthKey(),
aladinOperated,
createType: "new",
eid,
account,
simkind: "esim",
planCode,
contractLine,
shipDate,
...(mnp ? { mnp } : {}),
...(identity ? identity : {}),
} as FreebititEsimAccountActivationRequest;
await this.makeAuthenticatedJsonRequest<FreebititEsimAccountActivationResponse>(
"/mvno/esim/addAcct/",
payload as unknown as Record<string, unknown>
);
this.logger.log("Activated new eSIM account via PA05-41", {
account,
planCode,
contractLine,
scheduled: !!shipDate,
mnp: !!mnp,
});
}
/**
* Health check for Freebit API
*/
async healthCheck(): Promise<boolean> {
try {
await this.getAuthKey();
return true;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error("Freebit API health check failed", { error: message });
return false;
}
}
}
// Custom error class for Freebit API errors
class FreebititErrorImpl extends Error {
public readonly resultCode: string;
public readonly statusCode: string;
public readonly freebititMessage: string;
constructor(message: string, resultCode: string, statusCode: string, freebititMessage: string) {
super(message);
this.name = "FreebititError";
this.resultCode = resultCode;
this.statusCode = statusCode;
this.freebititMessage = freebititMessage;
}
}