Assist_Design/apps/bff/src/vendors/freebit/freebit.service.ts
tema 735828cf32 Implement SIM features update functionality and enhance UI components
- Added new SimFeaturesUpdateRequest interface to handle optional SIM feature updates.
- Implemented updateSimFeatures method in SimManagementService to process feature updates including voicemail, call waiting, international roaming, and network type.
- Expanded SubscriptionsController with a new endpoint for updating SIM features.
- Introduced SimFeatureToggles component for managing service options in the UI.
- Enhanced DataUsageChart and SimDetailsCard components to support embedded rendering and improved styling.
- Updated layout and design for better user experience in the SIM management section.
2025-09-05 15:39:43 +09:00

663 lines
20 KiB
TypeScript

import { Injectable, Inject, BadRequestException, InternalServerErrorException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Logger } from 'nestjs-pino';
import {
FreebititConfig,
FreebititAuthRequest,
FreebititAuthResponse,
FreebititAccountDetailsRequest,
FreebititAccountDetailsResponse,
FreebititTrafficInfoRequest,
FreebititTrafficInfoResponse,
FreebititTopUpRequest,
FreebititTopUpResponse,
FreebititQuotaHistoryRequest,
FreebititQuotaHistoryResponse,
FreebititPlanChangeRequest,
FreebititPlanChangeResponse,
FreebititCancelPlanRequest,
FreebititCancelPlanResponse,
FreebititEsimReissueRequest,
FreebititEsimReissueResponse,
FreebititEsimAddAccountRequest,
FreebititEsimAddAccountResponse,
SimDetails,
SimUsage,
SimTopUpHistory,
FreebititError,
FreebititAddSpecRequest,
FreebititAddSpecResponse
} from './interfaces/freebit.types';
@Injectable()
export class FreebititService {
private readonly config: FreebititConfig;
private authKeyCache: {
token: string;
expiresAt: number;
} | null = null;
constructor(
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger,
) {
this.config = {
baseUrl: this.configService.get<string>('FREEBIT_BASE_URL') || 'https://i1.mvno.net/emptool/api',
oemId: this.configService.get<string>('FREEBIT_OEM_ID') || 'PASI',
oemKey: this.configService.get<string>('FREEBIT_OEM_KEY') || '',
timeout: this.configService.get<number>('FREEBIT_TIMEOUT') || 30000,
retryAttempts: this.configService.get<number>('FREEBIT_RETRY_ATTEMPTS') || 3,
detailsEndpoint: this.configService.get<string>('FREEBIT_DETAILS_ENDPOINT') || '/master/getAcnt/',
};
// Warn if critical configuration is missing
if (!this.config.oemKey) {
this.logger.warn('FREEBIT_OEM_KEY is not configured. SIM management features will not work.');
}
this.logger.debug('Freebit service initialized', {
baseUrl: this.config.baseUrl,
oemId: this.config.oemId,
hasOemKey: !!this.config.oemKey,
});
}
/**
* Map Freebit SIM status to portal status
*/
private mapSimStatus(freebititStatus: string): 'active' | 'suspended' | 'cancelled' | 'pending' {
switch (freebititStatus) {
case 'active':
return 'active';
case 'suspended':
return 'suspended';
case 'temporary':
case 'waiting':
return 'pending';
case 'obsolete':
return 'cancelled';
default:
return 'pending';
}
}
/**
* Get or refresh authentication token
*/
private async getAuthKey(): Promise<string> {
// Check if we have a valid cached token
if (this.authKeyCache && this.authKeyCache.expiresAt > Date.now()) {
return this.authKeyCache.token;
}
try {
// Check if configuration is available
if (!this.config.oemKey) {
throw new Error('Freebit API not configured: FREEBIT_OEM_KEY is missing');
}
const request: FreebititAuthRequest = {
oemId: this.config.oemId,
oemKey: this.config.oemKey,
};
const response = await fetch(`${this.config.baseUrl}/authOem/`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `json=${JSON.stringify(request)}`,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json() as FreebititAuthResponse;
if (data.resultCode !== '100') {
throw new FreebititErrorImpl(
`Authentication failed: ${data.status.message}`,
data.resultCode,
data.status.statusCode,
data.status.message
);
}
// Cache the token for 50 minutes (assuming 60min expiry)
this.authKeyCache = {
token: data.authKey,
expiresAt: Date.now() + 50 * 60 * 1000,
};
this.logger.log('Successfully authenticated with Freebit API');
return data.authKey;
} catch (error: any) {
this.logger.error('Failed to authenticate with Freebit API', { error: error.message });
throw new InternalServerErrorException('Failed to authenticate with Freebit API');
}
}
/**
* Make authenticated API request with error handling
*/
private async makeAuthenticatedRequest<T>(
endpoint: string,
data: any
): Promise<T> {
const authKey = await this.getAuthKey();
const requestData = { ...data, authKey };
try {
const url = `${this.config.baseUrl}${endpoint}`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `json=${JSON.stringify(requestData)}`,
});
if (!response.ok) {
let bodySnippet: string | undefined;
try {
const text = await response.text();
bodySnippet = text ? text.slice(0, 500) : undefined;
} catch {}
this.logger.error('Freebit API non-OK response', {
endpoint,
url,
status: response.status,
statusText: response.statusText,
body: bodySnippet,
});
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const responseData = await response.json() as T;
// Check for API-level errors
if (responseData && (responseData as any).resultCode !== '100') {
const errorData = responseData as any;
throw new FreebititErrorImpl(
`API Error: ${errorData.status?.message || 'Unknown error'}`,
errorData.resultCode,
errorData.status?.statusCode,
errorData.status?.message
);
}
this.logger.debug('Freebit API Request Success', {
endpoint,
resultCode: (responseData as any).resultCode,
});
return responseData;
} catch (error) {
if (error instanceof FreebititErrorImpl) {
throw error;
}
this.logger.error(`Freebit API request failed: ${endpoint}`, { error: (error as any).message });
throw new InternalServerErrorException(`Freebit API request failed: ${(error as any).message}`);
}
}
/**
* Get detailed SIM account information
*/
async getSimDetails(account: string): Promise<SimDetails> {
try {
const request: Omit<FreebititAccountDetailsRequest, 'authKey'> = {
version: '2',
requestDatas: [{ kind: 'MVNO', account }],
};
const configured = this.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: FreebititAccountDetailsResponse | undefined;
let lastError: any;
for (const ep of candidates) {
try {
if (ep !== candidates[0]) {
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
}
response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(ep, request);
break; // success
} catch (err: any) {
lastError = err;
if (typeof err?.message === 'string' && err.message.includes('HTTP 404')) {
// try next candidate
continue;
}
// non-404 error, rethrow
throw err;
}
}
if (!response) {
throw lastError || new InternalServerErrorException('Failed to fetch SIM details: all endpoints failed');
}
const datas = (response as any).responseDatas;
const list = Array.isArray(datas) ? datas : (datas ? [datas] : []);
if (!list.length) {
throw new BadRequestException('No SIM details found for this account');
}
// Prefer the MVNO entry if present
const mvno = list.find((d: any) => (d.kind || '').toString().toUpperCase() === 'MVNO') || list[0];
const simData = mvno as any;
const startDateRaw = simData.startDate ? String(simData.startDate) : undefined;
const startDate = startDateRaw && /^\d{8}$/.test(startDateRaw)
? `${startDateRaw.slice(0,4)}-${startDateRaw.slice(4,6)}-${startDateRaw.slice(6,8)}`
: startDateRaw;
const simDetails: SimDetails = {
account: String(simData.account ?? account),
msisdn: String(simData.account ?? account),
iccid: simData.iccid ? String(simData.iccid) : undefined,
imsi: simData.imsi ? String(simData.imsi) : undefined,
eid: simData.eid,
planCode: simData.planCode,
status: this.mapSimStatus(String(simData.state || 'pending')),
simType: simData.eid ? 'esim' : 'physical',
size: simData.size,
hasVoice: simData.talk === 10,
hasSms: simData.sms === 10,
remainingQuotaKb: typeof simData.quota === 'number' ? simData.quota : 0,
remainingQuotaMb: typeof simData.quota === 'number' ? Math.round((simData.quota / 1024) * 100) / 100 : 0,
startDate,
ipv4: simData.ipv4,
ipv6: simData.ipv6,
voiceMailEnabled: simData.voicemail === 10 || simData.voiceMail === 10,
callWaitingEnabled: simData.callwaiting === 10 || simData.callWaiting === 10,
internationalRoamingEnabled: simData.worldwing === 10 || simData.worldWing === 10,
networkType: simData.contractLine || undefined,
pendingOperations: simData.async ? [{
operation: simData.async.func,
scheduledDate: String(simData.async.date),
}] : undefined,
};
this.logger.log(`Retrieved SIM details for account ${account}`, {
account,
status: simDetails.status,
planCode: simDetails.planCode,
});
return simDetails;
} catch (error: any) {
this.logger.error(`Failed to get SIM details for account ${account}`, { error: error.message });
throw error;
}
}
/**
* Get SIM data usage information
*/
async getSimUsage(account: string): Promise<SimUsage> {
try {
const request: Omit<FreebititTrafficInfoRequest, 'authKey'> = { account };
const response = await this.makeAuthenticatedRequest<FreebititTrafficInfoResponse>(
'/mvno/getTrafficInfo/',
request
);
const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
const recentDaysData = response.traffic.inRecentDays.split(',').map((usage, index) => ({
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
usageKb: parseInt(usage, 10) || 0,
usageMb: Math.round(parseInt(usage, 10) / 1024 * 100) / 100,
}));
const simUsage: SimUsage = {
account,
todayUsageKb,
todayUsageMb: Math.round(todayUsageKb / 1024 * 100) / 100,
recentDaysUsage: recentDaysData,
isBlacklisted: response.traffic.blackList === '10',
};
this.logger.log(`Retrieved SIM usage for account ${account}`, {
account,
todayUsageMb: simUsage.todayUsageMb,
isBlacklisted: simUsage.isBlacklisted,
});
return simUsage;
} catch (error: any) {
this.logger.error(`Failed to get SIM usage for account ${account}`, { error: error.message });
throw error;
}
}
/**
* Top up SIM data quota
*/
async topUpSim(account: string, quotaMb: number, options: {
campaignCode?: string;
expiryDate?: string;
scheduledAt?: string;
} = {}): Promise<void> {
try {
const quotaKb = quotaMb * 1024;
const request: Omit<FreebititTopUpRequest, 'authKey'> = {
account,
quota: quotaKb,
quotaCode: options.campaignCode,
expire: options.expiryDate,
};
// Use PA05-22 for scheduled top-ups, PA04-04 for immediate
const endpoint = options.scheduledAt ? '/mvno/eachQuota/' : '/master/addSpec/';
if (options.scheduledAt && endpoint === '/mvno/eachQuota/') {
(request as any).runTime = options.scheduledAt;
}
await this.makeAuthenticatedRequest<FreebititTopUpResponse>(endpoint, request);
this.logger.log(`Successfully topped up SIM ${account}`, {
account,
quotaMb,
quotaKb,
campaignCode: options.campaignCode,
scheduled: !!options.scheduledAt,
});
} catch (error: any) {
this.logger.error(`Failed to top up SIM ${account}`, {
error: error.message,
account,
quotaMb,
});
throw error;
}
}
/**
* Get SIM top-up history
*/
async getSimTopUpHistory(account: string, fromDate: string, toDate: string): Promise<SimTopUpHistory> {
try {
const request: Omit<FreebititQuotaHistoryRequest, 'authKey'> = {
account,
fromDate,
toDate,
};
const response = await this.makeAuthenticatedRequest<FreebititQuotaHistoryResponse>(
'/mvno/getQuotaHistory/',
request
);
const history: SimTopUpHistory = {
account,
totalAdditions: response.total,
additionCount: response.count,
history: response.quotaHistory.map(item => ({
quotaKb: parseInt(item.quota, 10),
quotaMb: Math.round(parseInt(item.quota, 10) / 1024 * 100) / 100,
addedDate: item.date,
expiryDate: item.expire,
campaignCode: item.quotaCode,
})),
};
this.logger.log(`Retrieved SIM top-up history for account ${account}`, {
account,
totalAdditions: history.totalAdditions,
additionCount: history.additionCount,
});
return history;
} catch (error: any) {
this.logger.error(`Failed to get SIM top-up history for account ${account}`, { error: error.message });
throw error;
}
}
/**
* Change SIM plan
*/
async changeSimPlan(account: string, newPlanCode: string, options: {
assignGlobalIp?: boolean;
scheduledAt?: string;
} = {}): Promise<{ ipv4?: string; ipv6?: string }> {
try {
const request: Omit<FreebititPlanChangeRequest, 'authKey'> = {
account,
plancode: newPlanCode,
globalip: options.assignGlobalIp ? '1' : '0',
runTime: options.scheduledAt,
};
const response = await this.makeAuthenticatedRequest<FreebititPlanChangeResponse>(
'/mvno/changePlan/',
request
);
this.logger.log(`Successfully changed SIM plan for account ${account}`, {
account,
newPlanCode,
assignGlobalIp: options.assignGlobalIp,
scheduled: !!options.scheduledAt,
});
return {
ipv4: response.ipv4,
ipv6: response.ipv6,
};
} catch (error: any) {
this.logger.error(`Failed to change SIM plan for account ${account}`, {
error: error.message,
account,
newPlanCode,
});
throw error;
}
}
/**
* Update SIM optional features (voicemail, call waiting, international roaming, network type)
* Uses AddSpec endpoint for immediate changes
*/
async updateSimFeatures(account: string, features: {
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: string; // '4G' | '5G'
}): Promise<void> {
try {
const request: Omit<FreebititAddSpecRequest, 'authKey'> = {
account,
};
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;
}
await this.makeAuthenticatedRequest<FreebititAddSpecResponse>('/master/addSpec/', request);
this.logger.log(`Updated SIM features for account ${account}`, {
account,
voiceMailEnabled: features.voiceMailEnabled,
callWaitingEnabled: features.callWaitingEnabled,
internationalRoamingEnabled: features.internationalRoamingEnabled,
networkType: features.networkType,
});
} catch (error: any) {
this.logger.error(`Failed to update SIM features for account ${account}`, {
error: error.message,
account,
});
throw error;
}
}
/**
* Cancel SIM service
*/
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
try {
const request: Omit<FreebititCancelPlanRequest, 'authKey'> = {
account,
runTime: scheduledAt,
};
await this.makeAuthenticatedRequest<FreebititCancelPlanResponse>(
'/mvno/releasePlan/',
request
);
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
account,
scheduled: !!scheduledAt,
});
} catch (error: any) {
this.logger.error(`Failed to cancel SIM for account ${account}`, {
error: error.message,
account,
});
throw error;
}
}
/**
* Reissue eSIM profile using reissueProfile endpoint
*/
async reissueEsimProfile(account: string): Promise<void> {
try {
const request: Omit<FreebititEsimReissueRequest, 'authKey'> = { account };
await this.makeAuthenticatedRequest<FreebititEsimReissueResponse>(
'/esim/reissueProfile/',
request
);
this.logger.log(`Successfully reissued eSIM profile for account ${account}`, { account });
} catch (error: any) {
this.logger.error(`Failed to reissue eSIM profile for account ${account}`, {
error: error.message,
account,
});
throw error;
}
}
/**
* Reissue eSIM profile using addAcnt endpoint (enhanced method based on Salesforce implementation)
*/
async reissueEsimProfileEnhanced(
account: string,
newEid: string,
options: {
oldProductNumber?: string;
oldEid?: string;
planCode?: string;
} = {}
): Promise<void> {
try {
const request: Omit<FreebititEsimAddAccountRequest, 'authKey'> = {
aladinOperated: '20',
account,
eid: newEid,
addKind: 'R', // R = reissue
reissue: {
oldProductNumber: options.oldProductNumber,
oldEid: options.oldEid,
},
};
// Add optional fields
if (options.planCode) {
request.planCode = options.planCode;
}
await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse>(
'/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: any) {
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
error: error.message,
account,
newEid,
});
throw error;
}
}
/**
* Health check for Freebit API
*/
async healthCheck(): Promise<boolean> {
try {
await this.getAuthKey();
return true;
} catch (error: any) {
this.logger.error('Freebit API health check failed', { error: error.message });
return false;
}
}
}
// Custom error class for Freebit API errors
class FreebititErrorImpl extends Error {
public readonly resultCode: string;
public readonly statusCode: string;
public readonly freebititMessage: string;
constructor(
message: string,
resultCode: string,
statusCode: string,
freebititMessage: string
) {
super(message);
this.name = 'FreebititError';
this.resultCode = resultCode;
this.statusCode = statusCode;
this.freebititMessage = freebititMessage;
}
}