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 { try { const request: Omit = { 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 { try { const request: Omit = { 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 { try { const quotaKb = Math.round(quotaMb * 1024); const baseRequest: Omit = { 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( 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 { try { const request: Omit = { 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 = { 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 = { 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 { 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 { try { this.assertOperationSpacing(account, "voice"); const buildVoiceOptionPayload = (): Omit => { 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 { 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 = { 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 { try { const request: Omit = { 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( "/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 { try { const request: Omit = { requestDatas: [{ kind: "MVNO", account }], }; await this.client.makeAuthenticatedRequest( "/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 { try { const request: Omit = { aladinOperated: "20", account, eid: newEid, addKind: "R", planCode: options.planCode, }; await this.client.makeAuthenticatedRequest( "/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 { 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 { 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; } } }