import { Injectable, Inject, Optional } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions.js"; import type { FreebitAccountDetailsResponse, FreebitTrafficInfoResponse, FreebitQuotaHistoryResponse, SimDetails, SimUsage, SimTopUpHistory, } from "../interfaces/freebit.types.js"; import type { VoiceOptionsService } from "@bff/modules/voice-options/services/voice-options.service.js"; const NOT_IN_API_RESPONSE = "(not in API response)"; @Injectable() export class FreebitMapperService { constructor( @Inject(Logger) private readonly logger: Logger, @Optional() @Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: VoiceOptionsService ) {} private parseOptionFlag(value: unknown, defaultValue: boolean = false): boolean { // If value is undefined or null, return the default if (value === undefined || value === null) { return defaultValue; } if (typeof value === "boolean") { return value; } if (typeof value === "number") { return value === 10 || value === 1; } if (typeof value === "string") { const normalized = value.trim().toLowerCase(); if (normalized === "on" || normalized === "true") { return true; } if (normalized === "off" || normalized === "false") { return false; } const numeric = Number(normalized); if (!Number.isNaN(numeric)) { return numeric === 10 || numeric === 1; } } return defaultValue; } /** * Map SIM status from Freebit API to domain status */ mapSimStatus(status: string): "active" | "suspended" | "cancelled" | "pending" { switch (status) { case "active": return "active"; case "suspended": return "suspended"; case "temporary": case "waiting": return "pending"; case "obsolete": return "cancelled"; default: return "pending"; } } /** * Map Freebit account details response to SimDetails */ async mapToSimDetails(response: FreebitAccountDetailsResponse): Promise { const account = response.responseDatas[0]; if (!account) { throw new FreebitOperationException("No account data in response"); } // Debug: Log raw voice option fields from API response this.logger.debug("[FreebitMapper] Raw API voice option fields", { account: account.account, voicemail: account.voicemail, voiceMail: account.voiceMail, callwaiting: account.callwaiting, callWaiting: account.callWaiting, worldwing: account.worldwing, worldWing: account.worldWing, talk: account.talk, sms: account.sms, }); let simType: "standard" | "nano" | "micro" | "esim" = "standard"; if (account.eid) { simType = "esim"; } else if (account.simSize) { simType = account.simSize; } // Try to get voice options from database first // Default to false - show as disabled unless API confirms enabled let voiceMailEnabled = false; let callWaitingEnabled = false; let internationalRoamingEnabled = false; let networkType = String(account.networkType ?? account.contractLine ?? "4G"); // Try to load stored options from database first let storedOptions = null; if (this.voiceOptionsService) { try { storedOptions = await this.voiceOptionsService.getVoiceOptions( String(account.account ?? "") ); } catch (error) { this.logger.warn("[FreebitMapper] Failed to load voice options from database", { account: account.account, error, }); } } if (storedOptions) { // Use stored options from database voiceMailEnabled = storedOptions.voiceMailEnabled; callWaitingEnabled = storedOptions.callWaitingEnabled; internationalRoamingEnabled = storedOptions.internationalRoamingEnabled; networkType = storedOptions.networkType; this.logger.debug("[FreebitMapper] Loaded voice options from database", { account: account.account, options: storedOptions, }); } else { // No stored options, parse from API response // Default to false - disabled unless API explicitly returns 10 (enabled) voiceMailEnabled = this.parseOptionFlag(account.voicemail ?? account.voiceMail, false); callWaitingEnabled = this.parseOptionFlag(account.callwaiting ?? account.callWaiting, false); internationalRoamingEnabled = this.parseOptionFlag( account.worldwing ?? account.worldWing, false ); this.logger.debug( "[FreebitMapper] No stored options found, using API values (default: disabled)", { account: account.account, rawVoiceMail: account.voicemail ?? account.voiceMail ?? NOT_IN_API_RESPONSE, rawCallWaiting: account.callwaiting ?? account.callWaiting ?? NOT_IN_API_RESPONSE, rawWorldWing: account.worldwing ?? account.worldWing ?? NOT_IN_API_RESPONSE, parsedVoiceMailEnabled: voiceMailEnabled, parsedCallWaitingEnabled: callWaitingEnabled, parsedInternationalRoamingEnabled: internationalRoamingEnabled, } ); } // Convert quota from KB to MB if needed // Freebit API returns quota in KB, but remainingQuotaMb should be in MB let remainingQuotaMb = 0; let remainingQuotaKb = 0; if (account.remainingQuotaMb != null) { // If API explicitly provides remainingQuotaMb, use it directly remainingQuotaMb = Number(account.remainingQuotaMb); remainingQuotaKb = Number(account.remainingQuotaKb ?? remainingQuotaMb * 1024); } else if (account.quota != null) { // If only quota is provided, it's in KB - convert to MB (1 MB = 1024 KB) remainingQuotaKb = Number(account.quota); remainingQuotaMb = remainingQuotaKb / 1024; } else if (account.remainingQuotaKb != null) { // If only remainingQuotaKb is provided, convert to MB (1 MB = 1024 KB) remainingQuotaKb = Number(account.remainingQuotaKb); remainingQuotaMb = remainingQuotaKb / 1024; } // Log raw account data in dev to debug MSISDN availability if (process.env["NODE_ENV"] !== "production") { this.logger.debug("[FREEBIT ACCOUNT DATA]", { account: account.account, msisdn: account.msisdn, eid: account.eid, iccid: account.iccid, }); } return { account: String(account.account ?? ""), status: this.mapSimStatus(String(account.state ?? account.status ?? "pending")), planCode: String(account.planCode ?? ""), planName: String(account.planName ?? ""), simType, iccid: String(account.iccid ?? ""), eid: String(account.eid ?? ""), msisdn: String(account.msisdn ?? account.account ?? ""), imsi: String(account.imsi ?? ""), remainingQuotaMb, remainingQuotaKb, voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType, activatedAt: account.startDate ? String(account.startDate) : undefined, expiresAt: account.async ? String(account.async.date) : undefined, ipv4: account.ipv4, ipv6: account.ipv6, startDate: account.startDate ? String(account.startDate) : undefined, hasVoice: account.talk === 10, hasSms: account.sms === 10, }; } /** * Map Freebit traffic info response to SimUsage */ mapToSimUsage(response: FreebitTrafficInfoResponse): SimUsage { if (!response.traffic) { throw new FreebitOperationException("No traffic data in response"); } const todayUsageKb = Number.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: Number.parseInt(usage, 10) || 0, usageMb: Math.round(((Number.parseInt(usage, 10) || 0) / 1024) * 100) / 100, })); return { account: String(response.account ?? ""), todayUsageMb: Math.round((todayUsageKb / 1024) * 100) / 100, todayUsageKb, recentDaysUsage: recentDaysData, isBlacklisted: response.traffic.blackList === "10", }; } /** * Map Freebit quota history response to SimTopUpHistory */ mapToSimTopUpHistory(response: FreebitQuotaHistoryResponse, account: string): SimTopUpHistory { if (!response.quotaHistory) { throw new FreebitOperationException("No history data in response"); } return { account, totalAdditions: Number(response.total) || 0, additionCount: Number(response.count) || 0, history: response.quotaHistory.map(item => ({ quotaKb: Number.parseInt(item.quota, 10), quotaMb: Math.round((Number.parseInt(item.quota, 10) / 1024) * 100) / 100, addedDate: item.date, expiryDate: item.expire, campaignCode: item.quotaCode, })), }; } /** * Normalize account identifier (remove formatting) */ normalizeAccount(account: string): string { return account.replace(/[-\s()]/g, ""); } /** * Validate account format */ validateAccount(account: string): boolean { const normalized = this.normalizeAccount(account); // Basic validation - should be digits, typically 10-11 digits for Japanese phone numbers return /^\d{10,11}$/.test(normalized); } /** * Format date for Freebit API (YYYYMMDD) */ formatDateForApi(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}${month}${day}`; } /** * Parse date from Freebit API format (YYYYMMDD) */ parseDateFromApi(dateString: string): Date | null { if (!/^\d{8}$/.test(dateString)) { return null; } const year = Number.parseInt(dateString.slice(0, 4), 10); const month = Number.parseInt(dateString.slice(4, 6), 10) - 1; // Month is 0-indexed const day = Number.parseInt(dateString.slice(6, 8), 10); return new Date(year, month, day); } }