import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { FreebitClientService } from "./freebit-client.service.js"; import { FreebitRateLimiterService } from "./freebit-rate-limiter.service.js"; import { FreebitAccountService } from "./freebit-account.service.js"; import type { SimVoiceOptionsService } from "@bff/modules/subscriptions/sim-management/services/sim-voice-options.service.js"; import type { FreebitVoiceOptionSettings, FreebitVoiceOptionRequest, FreebitVoiceOptionResponse, FreebitContractLineChangeRequest, FreebitContractLineChangeResponse, } from "../interfaces/freebit.types.js"; export interface VoiceFeatures { voiceMailEnabled?: boolean; callWaitingEnabled?: boolean; internationalRoamingEnabled?: boolean; } /** * Service for Freebit voice features and network type operations. * Handles PA05-06 (voice options) and PA05-38 (network type) operations. */ @Injectable() export class FreebitVoiceService { constructor( private readonly client: FreebitClientService, private readonly rateLimiter: FreebitRateLimiterService, private readonly accountService: FreebitAccountService, @Inject(Logger) private readonly logger: Logger, @Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: SimVoiceOptionsService ) {} /** * 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 * - These must run 30 minutes apart to avoid canceling each other */ 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"; // Freebit API requires voice features and network type changes to be 30 minutes apart. if (hasVoiceFeatures && hasNetworkTypeChange) { throw new BadRequestException( "Cannot update voice features and network type simultaneously. " + "Voice and network type changes must be 30 minutes apart per Freebit API requirements. " + "Use SimManagementQueueService to schedule the network type change after voice updates." ); } // Validate that at least one feature is specified if (!hasVoiceFeatures && !hasNetworkTypeChange) { throw new BadRequestException( "No features specified for update. Please provide at least one of: " + "voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, or networkType." ); } if (hasVoiceFeatures) { 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) { 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 = extractErrorMessage(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 */ async updateVoiceFeatures(account: string, features: VoiceFeatures): Promise { try { await this.rateLimiter.executeWithSpacing(account, "voice", async () => { 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"; } } 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, }); // 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: extractErrorMessage(dbError), }); } } }); } catch (error) { const message = extractErrorMessage(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 */ async updateNetworkType(account: string, networkType: "4G" | "5G"): Promise { let eid: string | undefined; let productNumber: string | undefined; // PA05-38 may require MSISDN (phone number) instead of internal account ID let apiAccount = account; try { try { const details = await this.accountService.getSimDetails(account); if (details.eid) { eid = details.eid; } else if (details.iccid) { productNumber = details.iccid; } // Use MSISDN if available, as PA05-38 expects phone number format if (details.msisdn && details.msisdn.length >= 10) { apiAccount = details.msisdn; } this.logger.debug(`Resolved SIM identifiers for contract line change`, { originalAccount: account, apiAccount, eid, productNumber, msisdn: details.msisdn, 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: extractErrorMessage(resolveError), }); } await this.rateLimiter.executeWithSpacing(account, "network", async () => { const request: Omit = { account: apiAccount, contractLine: networkType, ...(eid ? { eid } : {}), ...(productNumber ? { productNumber } : {}), }; this.logger.debug(`Updating network type via PA05-38`, { originalAccount: account, apiAccount, networkType, request, }); // PA05-38 uses form-urlencoded format (json={...}), not pure JSON const response = await this.client.makeAuthenticatedRequest< 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, }); // 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: extractErrorMessage(dbError), }); } } }); } catch (error) { const message = extractErrorMessage(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}`); } } }