import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common'; import { Logger } from 'nestjs-pino'; import { FreebititService } from '../vendors/freebit/freebit.service'; import { MappingsService } from '../mappings/mappings.service'; import { SubscriptionsService } from './subscriptions.service'; import { SimDetails, SimUsage, SimTopUpHistory } from '../vendors/freebit/interfaces/freebit.types'; import { SimUsageStoreService } from './sim-usage-store.service'; import { getErrorMessage } from '../common/utils/error.util'; export interface SimTopUpRequest { quotaMb: number; campaignCode?: string; expiryDate?: string; // YYYYMMDD scheduledAt?: string; // YYYYMMDD or YYYY-MM-DD HH:MM:SS } export interface SimPlanChangeRequest { newPlanCode: string; assignGlobalIp?: boolean; scheduledAt?: string; // YYYYMMDD } export interface SimCancelRequest { scheduledAt?: string; // YYYYMMDD - optional, immediate if omitted } export interface SimTopUpHistoryRequest { fromDate: string; // YYYYMMDD toDate: string; // YYYYMMDD } export interface SimFeaturesUpdateRequest { voiceMailEnabled?: boolean; callWaitingEnabled?: boolean; internationalRoamingEnabled?: boolean; networkType?: '4G' | '5G'; } @Injectable() export class SimManagementService { constructor( private readonly freebititService: FreebititService, private readonly mappingsService: MappingsService, private readonly subscriptionsService: SubscriptionsService, @Inject(Logger) private readonly logger: Logger, private readonly usageStore: SimUsageStoreService, ) {} /** * Debug method to check subscription data for SIM services */ async debugSimSubscription(userId: string, subscriptionId: number): Promise { try { const subscription = await this.subscriptionsService.getSubscriptionById(userId, subscriptionId); return { subscriptionId, productName: subscription.productName, domain: subscription.domain, orderNumber: subscription.orderNumber, customFields: subscription.customFields, isSimService: subscription.productName.toLowerCase().includes('sim') || subscription.groupName?.toLowerCase().includes('sim'), groupName: subscription.groupName, status: subscription.status, }; } catch (error) { this.logger.error(`Failed to debug subscription ${subscriptionId}`, { error: getErrorMessage(error), }); throw error; } } /** * Check if a subscription is a SIM service */ private async validateSimSubscription(userId: string, subscriptionId: number): Promise<{ account: string }> { try { // Get subscription details to verify it's a SIM service const subscription = await this.subscriptionsService.getSubscriptionById(userId, subscriptionId); // Check if this is a SIM service (you may need to adjust this logic based on your product naming) const isSimService = subscription.productName.toLowerCase().includes('sim') || subscription.groupName?.toLowerCase().includes('sim'); if (!isSimService) { throw new BadRequestException('This subscription is not a SIM service'); } // For SIM services, the account identifier (phone number) can be stored in multiple places let account = ''; // 1. Try domain field first if (subscription.domain && subscription.domain.trim()) { account = subscription.domain.trim(); } // 2. If no domain, check custom fields for phone number/MSISDN if (!account && subscription.customFields) { const phoneFields = ['phone', 'msisdn', 'phonenumber', 'phone_number', 'mobile', 'sim_phone']; for (const fieldName of phoneFields) { if (subscription.customFields[fieldName]) { account = subscription.customFields[fieldName]; break; } } } // 3. If still no account, check if subscription ID looks like a phone number if (!account && subscription.orderNumber) { const orderNum = subscription.orderNumber.toString(); if (/^\d{10,11}$/.test(orderNum)) { account = orderNum; } } // 4. Final fallback - for testing, use a dummy phone number based on subscription ID if (!account) { // Generate a test phone number: 080 + last 8 digits of subscription ID const subIdStr = subscriptionId.toString().padStart(8, '0'); account = `080${subIdStr.slice(-8)}`; this.logger.warn(`No SIM account identifier found for subscription ${subscriptionId}, using generated number: ${account}`, { userId, subscriptionId, productName: subscription.productName, domain: subscription.domain, customFields: subscription.customFields ? Object.keys(subscription.customFields) : [], }); } // Clean up the account format (remove hyphens, spaces, etc.) account = account.replace(/[-\s()]/g, ''); // Validate phone number format (10-11 digits, optionally starting with +81) const cleanAccount = account.replace(/^\+81/, '0'); // Convert +81 to 0 if (!/^0\d{9,10}$/.test(cleanAccount)) { throw new BadRequestException(`Invalid SIM account format: ${account}. Expected Japanese phone number format (10-11 digits starting with 0).`); } // Use the cleaned format account = cleanAccount; return { account }; } catch (error) { this.logger.error(`Failed to validate SIM subscription ${subscriptionId} for user ${userId}`, { error: getErrorMessage(error), }); throw error; } } /** * Get SIM details for a subscription */ async getSimDetails(userId: string, subscriptionId: number): Promise { try { const { account } = await this.validateSimSubscription(userId, subscriptionId); const simDetails = await this.freebititService.getSimDetails(account); this.logger.log(`Retrieved SIM details for subscription ${subscriptionId}`, { userId, subscriptionId, account, status: simDetails.status, }); return simDetails; } catch (error) { this.logger.error(`Failed to get SIM details for subscription ${subscriptionId}`, { error: getErrorMessage(error), userId, subscriptionId, }); throw error; } } /** * Get SIM data usage for a subscription */ async getSimUsage(userId: string, subscriptionId: number): Promise { try { const { account } = await this.validateSimSubscription(userId, subscriptionId); const simUsage = await this.freebititService.getSimUsage(account); // Persist today's usage for monthly charts and cleanup previous months try { await this.usageStore.upsertToday(account, simUsage.todayUsageMb); await this.usageStore.cleanupPreviousMonths(); const stored = await this.usageStore.getLastNDays(account, 30); if (stored.length > 0) { simUsage.recentDaysUsage = stored.map(d => ({ date: d.date, usageKb: Math.round(d.usageMb * 1024), usageMb: d.usageMb, })); } } catch (e) { this.logger.warn('SIM usage persistence failed (non-fatal)', { account, error: getErrorMessage(e) }); } this.logger.log(`Retrieved SIM usage for subscription ${subscriptionId}`, { userId, subscriptionId, account, todayUsageMb: simUsage.todayUsageMb, }); return simUsage; } catch (error) { this.logger.error(`Failed to get SIM usage for subscription ${subscriptionId}`, { error: getErrorMessage(error), userId, subscriptionId, }); throw error; } } /** * Top up SIM data quota */ async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise { try { const { account } = await this.validateSimSubscription(userId, subscriptionId); // Validate quota amount if (request.quotaMb <= 0 || request.quotaMb > 100000) { throw new BadRequestException('Quota must be between 1MB and 100GB'); } // Validate date formats if provided if (request.expiryDate && !/^\d{8}$/.test(request.expiryDate)) { throw new BadRequestException('Expiry date must be in YYYYMMDD format'); } if (request.scheduledAt && !/^\d{8}$/.test(request.scheduledAt.replace(/[-:\s]/g, ''))) { throw new BadRequestException('Scheduled date must be in YYYYMMDD format'); } await this.freebititService.topUpSim(account, request.quotaMb, { campaignCode: request.campaignCode, expiryDate: request.expiryDate, scheduledAt: request.scheduledAt, }); this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, { userId, subscriptionId, account, quotaMb: request.quotaMb, scheduled: !!request.scheduledAt, }); } catch (error) { this.logger.error(`Failed to top up SIM for subscription ${subscriptionId}`, { error: getErrorMessage(error), userId, subscriptionId, quotaMb: request.quotaMb, }); throw error; } } /** * Get SIM top-up history */ async getSimTopUpHistory( userId: string, subscriptionId: number, request: SimTopUpHistoryRequest ): Promise { try { const { account } = await this.validateSimSubscription(userId, subscriptionId); // Validate date format if (!/^\d{8}$/.test(request.fromDate) || !/^\d{8}$/.test(request.toDate)) { throw new BadRequestException('Dates must be in YYYYMMDD format'); } const history = await this.freebititService.getSimTopUpHistory( account, request.fromDate, request.toDate ); this.logger.log(`Retrieved SIM top-up history for subscription ${subscriptionId}`, { userId, subscriptionId, account, totalAdditions: history.totalAdditions, }); return history; } catch (error) { this.logger.error(`Failed to get SIM top-up history for subscription ${subscriptionId}`, { error: getErrorMessage(error), userId, subscriptionId, }); throw error; } } /** * Change SIM plan */ async changeSimPlan( userId: string, subscriptionId: number, request: SimPlanChangeRequest ): Promise<{ ipv4?: string; ipv6?: string }> { try { const { account } = await this.validateSimSubscription(userId, subscriptionId); // Validate plan code format if (!request.newPlanCode || request.newPlanCode.length < 3) { throw new BadRequestException('Invalid plan code'); } // Validate scheduled date if provided if (request.scheduledAt && !/^\d{8}$/.test(request.scheduledAt)) { throw new BadRequestException('Scheduled date must be in YYYYMMDD format'); } const result = await this.freebititService.changeSimPlan(account, request.newPlanCode, { assignGlobalIp: request.assignGlobalIp, scheduledAt: request.scheduledAt, }); this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, { userId, subscriptionId, account, newPlanCode: request.newPlanCode, scheduled: !!request.scheduledAt, }); return result; } catch (error) { this.logger.error(`Failed to change SIM plan for subscription ${subscriptionId}`, { error: getErrorMessage(error), userId, subscriptionId, newPlanCode: request.newPlanCode, }); throw error; } } /** * Update SIM features (voicemail, call waiting, roaming, network type) */ async updateSimFeatures( userId: string, subscriptionId: number, request: SimFeaturesUpdateRequest ): Promise { try { const { account } = await this.validateSimSubscription(userId, subscriptionId); // Validate network type if provided if (request.networkType && !['4G', '5G'].includes(request.networkType)) { throw new BadRequestException('networkType must be either "4G" or "5G"'); } await this.freebititService.updateSimFeatures(account, request); this.logger.log(`Updated SIM features for subscription ${subscriptionId}`, { userId, subscriptionId, account, ...request, }); } catch (error) { this.logger.error(`Failed to update SIM features for subscription ${subscriptionId}`, { error: getErrorMessage(error), userId, subscriptionId, ...request, }); throw error; } } /** * Cancel SIM service */ async cancelSim(userId: string, subscriptionId: number, request: SimCancelRequest = {}): Promise { try { const { account } = await this.validateSimSubscription(userId, subscriptionId); // Validate scheduled date if provided if (request.scheduledAt && !/^\d{8}$/.test(request.scheduledAt)) { throw new BadRequestException('Scheduled date must be in YYYYMMDD format'); } await this.freebititService.cancelSim(account, request.scheduledAt); this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, { userId, subscriptionId, account, scheduled: !!request.scheduledAt, }); } catch (error) { this.logger.error(`Failed to cancel SIM for subscription ${subscriptionId}`, { error: getErrorMessage(error), userId, subscriptionId, }); throw error; } } /** * Reissue eSIM profile */ async reissueEsimProfile(userId: string, subscriptionId: number): Promise { try { const { account } = await this.validateSimSubscription(userId, subscriptionId); // First check if this is actually an eSIM const simDetails = await this.freebititService.getSimDetails(account); if (simDetails.simType !== 'esim') { throw new BadRequestException('This operation is only available for eSIM subscriptions'); } await this.freebititService.reissueEsimProfile(account); this.logger.log(`Successfully reissued eSIM profile for subscription ${subscriptionId}`, { userId, subscriptionId, account, }); } catch (error) { this.logger.error(`Failed to reissue eSIM profile for subscription ${subscriptionId}`, { error: getErrorMessage(error), userId, subscriptionId, }); throw error; } } /** * Get comprehensive SIM information (details + usage combined) */ async getSimInfo(userId: string, subscriptionId: number): Promise<{ details: SimDetails; usage: SimUsage; }> { try { const [details, usage] = await Promise.all([ this.getSimDetails(userId, subscriptionId), this.getSimUsage(userId, subscriptionId), ]); // If Freebit doesn't return remaining quota, derive it from plan code (e.g., PASI_50G) // by subtracting measured usage (today + recentDays) from the plan cap. const normalizeNumber = (n: number) => (isFinite(n) && n > 0 ? n : 0); const usedMb = normalizeNumber(usage.todayUsageMb) + usage.recentDaysUsage.reduce((sum, d) => sum + normalizeNumber(d.usageMb), 0); const planCapMatch = (details.planCode || '').match(/(\d+)\s*G/i); if ((details.remainingQuotaMb === 0 || details.remainingQuotaMb == null) && planCapMatch) { const capGb = parseInt(planCapMatch[1], 10); if (!isNaN(capGb) && capGb > 0) { const capMb = capGb * 1024; const remainingMb = Math.max(capMb - usedMb, 0); details.remainingQuotaMb = Math.round(remainingMb * 100) / 100; details.remainingQuotaKb = Math.round(details.remainingQuotaMb * 1024); } } return { details, usage }; } catch (error) { this.logger.error(`Failed to get comprehensive SIM info for subscription ${subscriptionId}`, { error: getErrorMessage(error), userId, subscriptionId, }); throw error; } } }