Assist_Design/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts

267 lines
8.6 KiB
TypeScript
Raw Normal View History

import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type {
FreebitAccountDetailsResponse,
FreebitTrafficInfoResponse,
FreebitQuotaHistoryResponse,
SimDetails,
SimUsage,
SimTopUpHistory,
} from "../interfaces/freebit.types.js";
import type { SimVoiceOptionsService } from "@bff/modules/subscriptions/sim-management/services/sim-voice-options.service.js";
@Injectable()
export class FreebitMapperService {
constructor(
@Inject(Logger) private readonly logger: Logger,
@Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: SimVoiceOptionsService
) {}
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<SimDetails> {
const account = response.responseDatas[0];
if (!account) {
throw new Error("No account data in response");
}
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
let voiceMailEnabled = true;
let callWaitingEnabled = true;
let internationalRoamingEnabled = true;
let networkType = String(account.networkType ?? account.contractLine ?? "4G");
if (this.voiceOptionsService) {
try {
const storedOptions = await this.voiceOptionsService.getVoiceOptions(
String(account.account ?? "")
);
if (storedOptions) {
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, check API response
voiceMailEnabled = this.parseOptionFlag(account.voicemail ?? account.voiceMail, true);
callWaitingEnabled = this.parseOptionFlag(
account.callwaiting ?? account.callWaiting,
true
);
internationalRoamingEnabled = this.parseOptionFlag(
account.worldwing ?? account.worldWing,
true
);
this.logger.debug(
"[FreebitMapper] No stored options found, using defaults or API values",
{
account: account.account,
voiceMailEnabled,
callWaitingEnabled,
internationalRoamingEnabled,
}
);
}
} catch (error) {
this.logger.warn("[FreebitMapper] Failed to load voice options from database", {
account: account.account,
error,
});
}
}
// 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 * 1000);
} else if (account.quota != null) {
// If only quota is provided, it's in KB - convert to MB
remainingQuotaKb = Number(account.quota);
remainingQuotaMb = remainingQuotaKb / 1000;
} else if (account.remainingQuotaKb != null) {
// If only remainingQuotaKb is provided, convert to MB
remainingQuotaKb = Number(account.remainingQuotaKb);
remainingQuotaMb = remainingQuotaKb / 1000;
}
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 Error("No traffic data in response");
}
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) || 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 Error("No history data in response");
}
return {
account,
totalAdditions: Number(response.total) || 0,
additionCount: Number(response.count) || 0,
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,
})),
};
}
/**
* 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 = parseInt(dateString.substring(0, 4), 10);
const month = parseInt(dateString.substring(4, 6), 10) - 1; // Month is 0-indexed
const day = parseInt(dateString.substring(6, 8), 10);
return new Date(year, month, day);
}
}