- 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.
998 lines
32 KiB
TypeScript
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;
|
|
}
|
|
}
|