563 lines
17 KiB
TypeScript
563 lines
17 KiB
TypeScript
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
|
import { Logger } from "nestjs-pino";
|
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
|
import type { SimDetails, SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim";
|
|
import { Freebit as FreebitProvider } from "@customer-portal/domain/sim/providers/freebit";
|
|
import { FreebitClientService } from "./freebit-client.service";
|
|
import { FreebitAuthService } from "./freebit-auth.service";
|
|
|
|
// Type imports from domain (following clean import pattern from README)
|
|
import type {
|
|
TopUpResponse,
|
|
PlanChangeResponse,
|
|
AddSpecResponse,
|
|
CancelPlanResponse,
|
|
EsimReissueResponse,
|
|
EsimAddAccountResponse,
|
|
EsimActivationResponse,
|
|
QuotaHistoryRequest,
|
|
FreebitTopUpRequest,
|
|
FreebitPlanChangeRequest,
|
|
FreebitCancelPlanRequest,
|
|
FreebitEsimReissueRequest,
|
|
FreebitEsimActivationRequest,
|
|
FreebitEsimActivationParams,
|
|
FreebitAccountDetailsRequest,
|
|
FreebitTrafficInfoRequest,
|
|
FreebitQuotaHistoryRequest,
|
|
FreebitEsimAddAccountRequest,
|
|
FreebitAccountDetailsRaw,
|
|
FreebitTrafficInfoRaw,
|
|
FreebitQuotaHistoryRaw,
|
|
} from "@customer-portal/domain/sim/providers/freebit";
|
|
|
|
@Injectable()
|
|
export class FreebitOperationsService {
|
|
constructor(
|
|
private readonly client: FreebitClientService,
|
|
private readonly auth: FreebitAuthService,
|
|
@Inject(Logger) private readonly logger: Logger
|
|
) {}
|
|
|
|
/**
|
|
* Get SIM account details with endpoint fallback
|
|
*/
|
|
async getSimDetails(account: string): Promise<SimDetails> {
|
|
try {
|
|
const request: FreebitAccountDetailsRequest = FreebitProvider.schemas.accountDetails.parse({
|
|
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: FreebitAccountDetailsRaw | 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<FreebitAccountDetailsRaw, 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 FreebitProvider.transformFreebitAccountDetails(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: FreebitTrafficInfoRequest = FreebitProvider.schemas.trafficInfo.parse({ account });
|
|
|
|
const response = await this.client.makeAuthenticatedRequest<
|
|
FreebitTrafficInfoRaw,
|
|
typeof request
|
|
>("/mvno/getTrafficInfo/", request);
|
|
|
|
return FreebitProvider.transformFreebitTrafficInfo(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 payload: FreebitTopUpRequest = FreebitProvider.schemas.topUp.parse({ account, quotaMb, options });
|
|
const quotaKb = Math.round(payload.quotaMb * 1024);
|
|
const baseRequest = {
|
|
account: payload.account,
|
|
quota: quotaKb,
|
|
quotaCode: payload.options?.campaignCode,
|
|
expire: payload.options?.expiryDate,
|
|
};
|
|
|
|
const scheduled = Boolean(payload.options?.scheduledAt);
|
|
const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
|
|
const request = scheduled
|
|
? { ...baseRequest, runTime: payload.options?.scheduledAt }
|
|
: baseRequest;
|
|
|
|
await this.client.makeAuthenticatedRequest<TopUpResponse, 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: FreebitQuotaHistoryRequest = FreebitProvider.schemas.quotaHistory.parse({
|
|
account,
|
|
fromDate,
|
|
toDate,
|
|
});
|
|
|
|
const response = await this.client.makeAuthenticatedRequest<
|
|
FreebitQuotaHistoryRaw,
|
|
QuotaHistoryRequest
|
|
>("/mvno/getQuotaHistory/", request);
|
|
|
|
return FreebitProvider.transformFreebitQuotaHistory(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
|
|
*/
|
|
async changeSimPlan(
|
|
account: string,
|
|
newPlanCode: string,
|
|
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
|
// Import and validate with the schema
|
|
const parsed: FreebitPlanChangeRequest = FreebitProvider.schemas.planChange.parse({
|
|
account,
|
|
newPlanCode,
|
|
assignGlobalIp: options.assignGlobalIp,
|
|
scheduledAt: options.scheduledAt,
|
|
});
|
|
|
|
try {
|
|
const request = {
|
|
account: parsed.account,
|
|
plancode: parsed.newPlanCode,
|
|
globalip: parsed.assignGlobalIp ? "1" : "0",
|
|
runTime: parsed.scheduledAt,
|
|
};
|
|
|
|
const response = await this.client.makeAuthenticatedRequest<PlanChangeResponse, typeof request>(
|
|
"/mvno/changePlan/",
|
|
request
|
|
);
|
|
|
|
this.logger.log(`Successfully changed plan for account ${parsed.account} to ${parsed.newPlanCode}`, {
|
|
account: parsed.account,
|
|
newPlanCode: parsed.newPlanCode,
|
|
assignGlobalIp: parsed.assignGlobalIp,
|
|
scheduled: Boolean(parsed.scheduledAt),
|
|
});
|
|
|
|
return {
|
|
ipv4: response.ipv4,
|
|
ipv6: response.ipv6,
|
|
};
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
this.logger.error(`Failed to change SIM plan for account ${account}`, {
|
|
account,
|
|
newPlanCode,
|
|
error: message,
|
|
});
|
|
throw new BadRequestException(`Failed to change SIM plan: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update SIM features (voice options and network type)
|
|
*/
|
|
async updateSimFeatures(
|
|
account: string,
|
|
features: {
|
|
voiceMailEnabled?: boolean;
|
|
callWaitingEnabled?: boolean;
|
|
internationalRoamingEnabled?: boolean;
|
|
networkType?: "4G" | "5G";
|
|
}
|
|
): Promise<void> {
|
|
try {
|
|
// Import and validate with the new schema
|
|
const parsed = FreebitProvider.schemas.simFeatures.parse({
|
|
account,
|
|
voiceMailEnabled: features.voiceMailEnabled,
|
|
callWaitingEnabled: features.callWaitingEnabled,
|
|
callForwardingEnabled: undefined, // Not supported in this interface yet
|
|
callerIdEnabled: undefined,
|
|
});
|
|
|
|
const payload: Record<string, unknown> = {
|
|
account: parsed.account,
|
|
};
|
|
|
|
if (typeof parsed.voiceMailEnabled === "boolean") {
|
|
const flag = parsed.voiceMailEnabled ? "10" : "20";
|
|
payload.voiceMail = flag;
|
|
payload.voicemail = flag;
|
|
}
|
|
|
|
if (typeof parsed.callWaitingEnabled === "boolean") {
|
|
const flag = parsed.callWaitingEnabled ? "10" : "20";
|
|
payload.callWaiting = flag;
|
|
payload.callwaiting = flag;
|
|
}
|
|
|
|
if (typeof features.internationalRoamingEnabled === "boolean") {
|
|
const flag = features.internationalRoamingEnabled ? "10" : "20";
|
|
payload.worldWing = flag;
|
|
payload.worldwing = flag;
|
|
}
|
|
|
|
if (features.networkType) {
|
|
payload.contractLine = features.networkType;
|
|
}
|
|
|
|
await this.client.makeAuthenticatedRequest<AddSpecResponse, typeof payload>(
|
|
"/master/addSpec/",
|
|
payload
|
|
);
|
|
|
|
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,
|
|
});
|
|
throw new BadRequestException(`Failed to update SIM features: ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancel SIM service
|
|
*/
|
|
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
|
try {
|
|
const parsed: FreebitCancelPlanRequest = FreebitProvider.schemas.cancelPlan.parse({
|
|
account,
|
|
runDate: scheduledAt,
|
|
});
|
|
|
|
const request = {
|
|
account: parsed.account,
|
|
runTime: parsed.runDate,
|
|
};
|
|
|
|
await this.client.makeAuthenticatedRequest<CancelPlanResponse, typeof request>(
|
|
"/mvno/releasePlan/",
|
|
request
|
|
);
|
|
|
|
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
|
|
account,
|
|
runTime: scheduledAt,
|
|
});
|
|
} 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 = {
|
|
requestDatas: [{ kind: "MVNO", account }],
|
|
};
|
|
|
|
await this.client.makeAuthenticatedRequest<EsimReissueResponse, 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 parsed: FreebitEsimReissueRequest = FreebitProvider.schemas.esimReissue.parse({
|
|
account,
|
|
newEid,
|
|
oldEid: options.oldEid,
|
|
planCode: options.planCode,
|
|
oldProductNumber: options.oldProductNumber,
|
|
});
|
|
|
|
const requestPayload = FreebitProvider.schemas.esimAddAccount.parse({
|
|
aladinOperated: "20",
|
|
account: parsed.account,
|
|
eid: parsed.newEid,
|
|
addKind: "R",
|
|
planCode: parsed.planCode,
|
|
});
|
|
|
|
const payload: FreebitEsimAddAccountRequest = {
|
|
...requestPayload,
|
|
authKey: await this.auth.getAuthKey(),
|
|
};
|
|
|
|
await this.client.makeAuthenticatedRequest<EsimAddAccountResponse, FreebitEsimAddAccountRequest>(
|
|
"/mvno/esim/addAcnt/",
|
|
payload
|
|
);
|
|
|
|
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${parsed.account}`, {
|
|
account: parsed.account,
|
|
newEid: parsed.newEid,
|
|
oldProductNumber: parsed.oldProductNumber,
|
|
oldEid: parsed.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;
|
|
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;
|
|
|
|
// Import schemas dynamically to avoid circular dependencies
|
|
const validatedParams: FreebitEsimActivationParams = FreebitProvider.schemas.esimActivationParams.parse({
|
|
account,
|
|
eid,
|
|
planCode,
|
|
contractLine,
|
|
aladinOperated,
|
|
shipDate,
|
|
mnp,
|
|
identity,
|
|
});
|
|
|
|
if (!validatedParams.account || !validatedParams.eid) {
|
|
throw new BadRequestException("activateEsimAccountNew requires account and eid");
|
|
}
|
|
|
|
try {
|
|
const payload: FreebitEsimActivationRequest = {
|
|
authKey: await this.auth.getAuthKey(),
|
|
aladinOperated: validatedParams.aladinOperated,
|
|
createType: "new",
|
|
account: validatedParams.account,
|
|
eid: validatedParams.eid,
|
|
simkind: "esim",
|
|
planCode: validatedParams.planCode,
|
|
contractLine: validatedParams.contractLine,
|
|
shipDate: validatedParams.shipDate,
|
|
...(validatedParams.mnp ? { mnp: validatedParams.mnp } : {}),
|
|
...(validatedParams.identity ? validatedParams.identity : {}),
|
|
};
|
|
|
|
// Validate the full API request payload
|
|
FreebitProvider.schemas.esimActivationRequest.parse(payload);
|
|
|
|
// Use JSON request for PA05-41
|
|
await this.client.makeAuthenticatedJsonRequest<EsimActivationResponse, typeof payload>(
|
|
"/mvno/esim/addAcct/",
|
|
payload
|
|
);
|
|
|
|
this.logger.log("Successfully activated new eSIM account via PA05-41", {
|
|
account,
|
|
planCode,
|
|
contractLine,
|
|
scheduled: !!shipDate,
|
|
mnp: !!mnp,
|
|
});
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
this.logger.error(`Failed to activate new eSIM account ${account}`, {
|
|
account,
|
|
eid,
|
|
planCode,
|
|
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;
|
|
}
|
|
}
|
|
}
|