Assist_Design/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts

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