diff --git a/apps/bff/src/subscriptions/sim-management.service.ts b/apps/bff/src/subscriptions/sim-management.service.ts index 8a69ab6c..c562f33b 100644 --- a/apps/bff/src/subscriptions/sim-management.service.ts +++ b/apps/bff/src/subscriptions/sim-management.service.ts @@ -640,7 +640,52 @@ export class SimManagementService { throw new BadRequestException('networkType must be either "4G" or "5G"'); } - await this.freebititService.updateSimFeatures(account, request); + const doVoice = + typeof request.voiceMailEnabled === 'boolean' || + typeof request.callWaitingEnabled === 'boolean' || + typeof request.internationalRoamingEnabled === 'boolean'; + const doContract = typeof request.networkType === 'string'; + + if (doVoice && doContract) { + // First apply voice options immediately (PA05-06) + await this.freebititService.updateSimFeatures(account, { + voiceMailEnabled: request.voiceMailEnabled, + callWaitingEnabled: request.callWaitingEnabled, + internationalRoamingEnabled: request.internationalRoamingEnabled, + }); + + // Then schedule contract line change after 30 minutes (PA05-38) + const delayMs = 30 * 60 * 1000; + setTimeout(() => { + this.freebititService + .updateSimFeatures(account, { networkType: request.networkType }) + .then(() => + this.logger.log('Deferred contract line change executed after 30 minutes', { + userId, + subscriptionId, + account, + networkType: request.networkType, + }) + ) + .catch(err => + this.logger.error('Deferred contract line change failed', { + error: getErrorMessage(err), + userId, + subscriptionId, + account, + }) + ); + }, delayMs); + + this.logger.log('Scheduled contract line change 30 minutes after voice option change', { + userId, + subscriptionId, + account, + networkType: request.networkType, + }); + } else { + await this.freebititService.updateSimFeatures(account, request); + } this.logger.log(`Updated SIM features for subscription ${subscriptionId}`, { userId, diff --git a/apps/bff/src/vendors/freebit/freebit.service.ts b/apps/bff/src/vendors/freebit/freebit.service.ts index 323208a7..2819e7e6 100644 --- a/apps/bff/src/vendors/freebit/freebit.service.ts +++ b/apps/bff/src/vendors/freebit/freebit.service.ts @@ -182,10 +182,16 @@ export class FreebititService { const responseData = (await response.json()) as T; - // Check for API-level errors - const rc = String(responseData?.resultCode ?? ""); - if (rc !== "100") { - const errorMessage = String(responseData.status?.message ?? "Unknown error"); + // Check for API-level errors (some endpoints return resultCode '101' with message 'OK') + const rc = String((responseData as any)?.resultCode ?? ""); + const statusObj: any = (responseData as any)?.status ?? {}; + const errorMessage = String((statusObj?.message ?? (responseData as any)?.message ?? "Unknown error")); + const statusCodeStr = String(statusObj?.statusCode ?? (responseData as any)?.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}`; @@ -200,7 +206,7 @@ export class FreebititService { this.logger.error("Freebit API error response", { endpoint, resultCode: rc, - statusCode: responseData.status?.statusCode, + statusCode: statusCodeStr, message: errorMessage, userFriendlyMessage, }); @@ -208,7 +214,7 @@ export class FreebititService { throw new FreebititErrorImpl( userFriendlyMessage, rc, - String(responseData.status?.statusCode ?? ""), + statusCodeStr, errorMessage ); } @@ -230,6 +236,42 @@ export class FreebititService { } } + // Make authenticated JSON POST request (for endpoints that require JSON body) + private async makeAuthenticatedJsonRequest(endpoint: string, body: Record): Promise { + 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; + const rc = String((data as any)?.resultCode ?? ''); + if (rc !== '100') { + const message = (data as any)?.message || (data as any)?.status?.message || 'Unknown error'; + this.logger.error('Freebit JSON API error response', { + endpoint, + resultCode: rc, + statusCode: (data as any)?.statusCode || (data as any)?.status?.statusCode, + message, + }); + throw new FreebititErrorImpl(`API Error: ${message}`, rc, String((data as any)?.statusCode || ''), message); + } + this.logger.debug('Freebit JSON API Request Success', { endpoint, resultCode: rc }); + return data; + } + /** * Get detailed SIM account information */ @@ -602,30 +644,48 @@ export class FreebititService { } ): Promise { try { - const request: Omit = { - account, - kind: "MVNO", - }; + const doVoice = + typeof features.voiceMailEnabled === 'boolean' || + typeof features.callWaitingEnabled === 'boolean' || + typeof features.internationalRoamingEnabled === 'boolean'; + const doContract = typeof features.networkType === 'string'; - if (typeof features.voiceMailEnabled === "boolean") { - request.voiceMail = features.voiceMailEnabled ? ("10" as const) : ("20" as const); - request.voicemail = request.voiceMail; // include alternate casing for compatibility - } - if (typeof features.callWaitingEnabled === "boolean") { - request.callWaiting = features.callWaitingEnabled ? ("10" as const) : ("20" as const); - request.callwaiting = request.callWaiting; - } - if (typeof features.internationalRoamingEnabled === "boolean") { - request.worldWing = features.internationalRoamingEnabled - ? ("10" as const) - : ("20" as const); - request.worldwing = request.worldWing; - } - if (features.networkType) { - request.contractLine = features.networkType; + if (doVoice) { + const talkOption: any = {}; + 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( + '/mvno/talkoption/changeOrder/', + { + account, + userConfirmed: '10', + aladinOperated: '10', + talkOption, + } + ); + this.logger.log('Applied voice option change (PA05-06)', { account, talkOption }); } - await this.makeAuthenticatedRequest("/master/addSpec/", request); + if (doContract && features.networkType) { + await this.makeAuthenticatedJsonRequest( + '/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, @@ -636,10 +696,7 @@ export class FreebititService { }); } 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, - }); + this.logger.error(`Failed to update SIM features for account ${account}`, { error: message, account }); throw error as Error; } } diff --git a/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts b/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts index c95f4543..513cbb35 100644 --- a/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts +++ b/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts @@ -187,6 +187,48 @@ export interface FreebititPlanChangeResponse { ipv6?: string; } +// PA05-06: MVNO Voice Option Change +export interface FreebititVoiceOptionChangeRequest { + authKey: string; + account: string; + userConfirmed: '10' | '20'; + aladinOperated: '10' | '20'; + talkOption: { + voiceMail?: '10' | '20'; + callWaiting?: '10' | '20'; + worldWing?: '10' | '20'; + worldCall?: '10' | '20'; + callTransfer?: '10' | '20'; + callTransferNoId?: '10' | '20'; + worldCallCreditLimit?: string; + worldWingCreditLimit?: string; + }; +} + +export interface FreebititVoiceOptionChangeResponse { + resultCode: string; + status: { + message: string; + statusCode: string; + }; +} + +// PA05-38: MVNO Contract Change (4G/5G) +export interface FreebititContractLineChangeRequest { + authKey: string; + account: string; + contractLine: '4G' | '5G'; + productNumber?: string; + eid?: string; +} + +export interface FreebititContractLineChangeResponse { + resultCode: string | number; + status?: unknown; + statusCode?: string; + message?: string; +} + export interface FreebititCancelPlanRequest { authKey: string; account: string;