diff --git a/apps/bff/src/integrations/freebit/freebit.module.ts b/apps/bff/src/integrations/freebit/freebit.module.ts index d3bc7533..20552b2f 100644 --- a/apps/bff/src/integrations/freebit/freebit.module.ts +++ b/apps/bff/src/integrations/freebit/freebit.module.ts @@ -1,14 +1,22 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef, Inject, Optional } from "@nestjs/common"; import { FreebitOrchestratorService } from "./services/freebit-orchestrator.service"; +import { FreebitMapperService } from "./services/freebit-mapper.service"; import { FreebitOperationsService } from "./services/freebit-operations.service"; import { FreebitClientService } from "./services/freebit-client.service"; import { FreebitAuthService } from "./services/freebit-auth.service"; @Module({ + imports: [ + forwardRef(() => { + const { SimManagementModule } = require("../../modules/subscriptions/sim-management/sim-management.module"); + return SimManagementModule; + }), + ], providers: [ // Core services FreebitClientService, FreebitAuthService, + FreebitMapperService, FreebitOperationsService, FreebitOrchestratorService, ], diff --git a/apps/bff/src/integrations/freebit/interfaces/freebit.types.ts b/apps/bff/src/integrations/freebit/interfaces/freebit.types.ts new file mode 100644 index 00000000..d988ae02 --- /dev/null +++ b/apps/bff/src/integrations/freebit/interfaces/freebit.types.ts @@ -0,0 +1,410 @@ +// Freebit API Type Definitions (cleaned) + +export interface FreebitAuthRequest { + oemId: string; // 4-char alphanumeric ISP identifier + oemKey: string; // 32-char auth key +} + +export interface FreebitAuthResponse { + resultCode: string; + status: { + message: string; + statusCode: string | number; + }; + authKey: string; // Token for subsequent API calls +} + +export interface FreebitAccountDetailsRequest { + authKey: string; + version?: string | number; // Docs recommend "2" + requestDatas: Array<{ + kind: "MASTER" | "MVNO"; + account?: string | number; + }>; +} + +export interface FreebitAccountDetail { + kind: "MASTER" | "MVNO"; + account: string | number; + state: "active" | "suspended" | "temporary" | "waiting" | "obsolete"; + status?: "active" | "suspended" | "temporary" | "waiting" | "obsolete"; + startDate?: string | number; + relationCode?: string; + resultCode?: string | number; + planCode?: string; + planName?: string; + iccid?: string | number; + imsi?: string | number; + eid?: string; + contractLine?: string; + size?: "standard" | "nano" | "micro" | "esim"; + simSize?: "standard" | "nano" | "micro" | "esim"; + msisdn?: string | number; + sms?: number; // 10=active, 20=inactive + talk?: number; // 10=active, 20=inactive + ipv4?: string; + ipv6?: string; + quota?: number; // Remaining quota + remainingQuotaMb?: string | number | null; + remainingQuotaKb?: string | number | null; + voicemail?: "10" | "20" | number | null; + voiceMail?: "10" | "20" | number | null; + callwaiting?: "10" | "20" | number | null; + callWaiting?: "10" | "20" | number | null; + worldwing?: "10" | "20" | number | null; + worldWing?: "10" | "20" | number | null; + networkType?: string; + async?: { func: string; date: string | number }; +} + +export interface FreebitAccountDetailsResponse { + resultCode: string; + status: { + message: string; + statusCode: string | number; + }; + masterAccount?: string; + responseDatas: FreebitAccountDetail[]; +} + +export interface FreebitTrafficInfoRequest { + authKey: string; + account: string; +} + +export interface FreebitTrafficInfoResponse { + resultCode: string; + status: { + message: string; + statusCode: string | number; + }; + account: string; + traffic: { + today: string; // Today's usage in KB + inRecentDays: string; // Comma-separated recent days usage + blackList: string; // 10=blacklisted, 20=not blacklisted + }; +} + +export interface FreebitTopUpRequest { + authKey: string; + account: string; + quota: number; // KB units (e.g., 102400 for 100MB) + quotaCode?: string; // Campaign code + expire?: string; // YYYYMMDD format (8 digits) + runTime?: string; // Scheduled execution time (YYYYMMDD format, 8 digits) +} + +export interface FreebitTopUpResponse { + resultCode: string; + status: { message: string; statusCode: string | number }; +} + +// AddSpec request for updating SIM options/features immediately +export interface FreebitAddSpecRequest { + authKey: string; + account: string; + kind?: string; // e.g. 'MVNO' + // Feature flags: 10 = enabled, 20 = disabled + voiceMail?: "10" | "20"; + voicemail?: "10" | "20"; + callWaiting?: "10" | "20"; + callwaiting?: "10" | "20"; + worldWing?: "10" | "20"; + worldwing?: "10" | "20"; + contractLine?: string; // '4G' or '5G' +} + +export interface FreebitVoiceOptionSettings { + voiceMail?: "10" | "20"; + callWaiting?: "10" | "20"; + callTransfer?: "10" | "20"; + callTransferWorld?: "10" | "20"; + callTransferNoId?: "10" | "20"; + worldCall?: "10" | "20"; + worldCallCreditLimit?: string; + worldWing?: "10" | "20"; + worldWingCreditLimit?: string; +} + +export interface FreebitVoiceOptionRequest { + authKey: string; + account: string; + userConfirmed?: "10" | "20"; + aladinOperated?: "10" | "20"; + talkOption: FreebitVoiceOptionSettings; +} + +export interface FreebitVoiceOptionResponse { + resultCode: string; + status: { message: string; statusCode: string | number }; +} + +export interface FreebitAddSpecResponse { + resultCode: string; + status: { message: string; statusCode: string | number }; +} + +export interface FreebitQuotaHistoryRequest { + authKey: string; + account: string; + fromDate: string; + toDate: string; +} + +export interface FreebitQuotaHistoryItem { + quota: string; // KB as string + date: string; + expire: string; + quotaCode: string; +} + +export interface FreebitQuotaHistoryResponse { + resultCode: string; + status: { message: string; statusCode: string | number }; + total: string | number; + count: string | number; + quotaHistory: FreebitQuotaHistoryItem[]; +} + +export interface FreebitPlanChangeRequest { + authKey: string; + account: string; + planCode: string; // Note: API expects camelCase "planCode" not "plancode" + globalip?: "0" | "1"; // 0=disabled, 1=assign global IP (PA05-21 expects legacy flags) + runTime?: string; // YYYYMMDD format (8 digits, date only) - optional + contractLine?: "4G" | "5G"; // Network type for contract line changes +} + +export interface FreebitPlanChangeResponse { + resultCode: string; + status: { message: string; statusCode: string | number }; + ipv4?: string; + ipv6?: string; +} + +export interface FreebitPlanChangePayload { + requestDatas: Array<{ + kind: "MVNO"; + account: string; + newPlanCode: string; + assignGlobalIp: boolean; + scheduledAt?: string; + }>; +} + +export interface FreebitAddSpecPayload { + requestDatas: Array<{ + kind: "MVNO"; + account: string; + specCode: string; + enabled?: boolean; + networkType?: "4G" | "5G"; + }>; +} + +export interface FreebitCancelPlanPayload { + requestDatas: Array<{ + kind: "MVNO"; + account: string; + runDate: string; + }>; +} + +export interface FreebitEsimReissuePayload { + requestDatas: Array<{ + kind: "MVNO"; + account: string; + newEid: string; + oldEid?: string; + planCode?: string; + }>; +} + +export interface FreebitContractLineChangeRequest { + authKey: string; + account: string; + contractLine: "4G" | "5G"; + productNumber?: string; + eid?: string; +} + +export interface FreebitContractLineChangeResponse { + resultCode: string | number; + status?: { message?: string; statusCode?: string | number }; + statusCode?: string | number; + message?: string; +} + +export interface FreebitCancelPlanRequest { + authKey: string; + account: string; + runTime?: string; // YYYYMMDD - optional +} + +export interface FreebitCancelPlanResponse { + resultCode: string; + status: { message: string; statusCode: string | number }; +} + +// PA02-04: Account Cancellation (master/cnclAcnt) +export interface FreebitCancelAccountRequest { + authKey: string; + kind: string; // e.g., 'MVNO' + account: string; + runDate?: string; // YYYYMMDD +} + +export interface FreebitCancelAccountResponse { + resultCode: string; + status: { message: string; statusCode: string | number }; +} + +export interface FreebitEsimReissueRequest { + authKey: string; + requestDatas: Array<{ + kind: "MVNO"; + account: string; + newEid?: string; + oldEid?: string; + planCode?: string; + }>; +} + +export interface FreebitEsimReissueResponse { + resultCode: string; + status: { message: string; statusCode: string | number }; +} + +export interface FreebitEsimAddAccountRequest { + authKey: string; + aladinOperated: string; // '10' for issue, '20' for no-issue + account: string; + eid: string; + addKind: "N" | "R"; // N = new, R = reissue + shipDate?: string; + planCode?: string; + contractLine?: string; + mnp?: { + reserveNumber: string; + reserveExpireDate: string; + }; +} + +export interface FreebitEsimAddAccountResponse { + resultCode: string; + status: { message: string; statusCode: string | number }; +} + +// PA05-41 eSIM Account Activation (addAcct) +// Based on Freebit API specification - all parameters from JSON table +export interface FreebitEsimAccountActivationRequest { + authKey: string; // Row 1: 認証キー (Required) + aladinOperated: string; // Row 2: ALADIN帳票作成フラグ ('10':操作済, '20':未操作) (Required) + masterAccount?: string; // Row 3: マスタアカウント (Conditional - for service provider) + masterPassword?: string; // Row 4: マスタパスワード (Conditional - for service provider) + createType: string; // Row 5: 登録区分 ('new', 'reissue', 'add') (Required) + eid?: string; // Row 6: eSIM識別番号 (Conditional - required for reissue/exchange) + account: string; // Row 7: アカウント/MSISDN (Required) + simkind: string; // Row 8: SIM種別 (Conditional - Required except when addKind='R') + // eSIM: 'E0':音声あり, 'E2':SMSなし, 'E3':SMSあり + // Physical: '3MS', '3MR', etc + contractLine?: string; // Row 9: 契約回線種別 ('4G', '5G') (Conditional) + repAccount?: string; // Row 10: 代表番号 (Conditional) + addKind?: string; // Row 11: 開通種別 ('N':新規, 'M':MNP転入, 'R':再発行) (Required) + reissue?: string; // Row 12: 再発行情報 (Conditional) + oldProductNumber?: string; // Row 13: 元製造番号 (Conditional - for exchange) + oldEid?: string; // Row 14: 元eSIM識別番号 (Conditional - for exchange) + mnp?: { // Row 15: MNP情報 (Conditional) + reserveNumber: string; // Row 16: MNP予約番号 (Conditional) + reserveExpireDate?: string; // (Conditional) YYYYMMDD + }; + firstnameKanji?: string; // Row 17: 由字(漢字) (Conditional) + lastnameKanji?: string; // Row 18: 名前(漢字) (Conditional) + firstnameZenKana?: string; // Row 19: 由字(全角カタカナ) (Conditional) + lastnameZenKana?: string; // Row 20: 名前(全角カタカナ) (Conditional) + gender?: string; // Row 21: 性別 ('M', 'F') (Required for identification) + birthday?: string; // Row 22: 生年月日 YYYYMMDD (Conditional) + shipDate?: string; // Row 23: 出荷日 YYYYMMDD (Conditional) + planCode?: string; // Row 24: プランコード (Max 32 chars) (Conditional) + deliveryCode?: string; // Row 25: 顧客コード (Max 10 chars) (Conditional - OEM specific) + globalIp?: string; // Additional: グローバルIP ('10': なし, '20': あり) + size?: string; // SIM physical size (for physical SIMs) +} + +export interface FreebitEsimAccountActivationResponse { + resultCode: string; + status?: { + message?: string; + statusCode?: string | number; + }; + statusCode?: string | number; + message?: string; +} + +// Portal-specific types for SIM management +export interface SimDetails { + account: string; + status: "active" | "suspended" | "cancelled" | "pending"; + planCode: string; + planName: string; + simType: "standard" | "nano" | "micro" | "esim"; + iccid: string; + eid: string; + msisdn: string; + imsi: string; + remainingQuotaMb: number; + remainingQuotaKb: number; + voiceMailEnabled: boolean; + callWaitingEnabled: boolean; + internationalRoamingEnabled: boolean; + networkType: string; + activatedAt?: string; + expiresAt?: string; + ipv4?: string; + ipv6?: string; + startDate?: string; + hasVoice?: boolean; + hasSms?: boolean; +} + +export interface SimUsage { + account: string; + todayUsageMb: number; + todayUsageKb: number; + monthlyUsageMb?: number; + monthlyUsageKb?: number; + recentDaysUsage: Array<{ date: string; usageKb: number; usageMb: number }>; + isBlacklisted: boolean; + lastUpdated?: string; +} + +export interface SimTopUpHistory { + account: string; + totalAdditions: number; + additionCount: number; + history: Array<{ + quotaKb: number; + quotaMb: number; + addedDate: string; + expiryDate: string; + campaignCode: string; + }>; +} + +// Error handling +export interface FreebitError extends Error { + resultCode: string; + statusCode: string | number; + freebititMessage: string; +} + +// Configuration +export interface FreebitConfig { + baseUrl: string; + oemId: string; + oemKey: string; + timeout: number; + retryAttempts: number; + detailsEndpoint?: string; +} diff --git a/apps/bff/src/integrations/freebit/services/freebit-auth.service.ts b/apps/bff/src/integrations/freebit/services/freebit-auth.service.ts index b0ac4743..e7c442a1 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-auth.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-auth.service.ts @@ -2,23 +2,13 @@ import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common import { ConfigService } from "@nestjs/config"; import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions"; import type { - AuthRequest as FreebitAuthRequest, - AuthResponse as FreebitAuthResponse, -} from "@customer-portal/domain/sim/providers/freebit"; -import { Freebit as FreebitProvider } from "@customer-portal/domain/sim/providers/freebit"; + FreebitConfig, + FreebitAuthRequest, + FreebitAuthResponse, +} from "../interfaces/freebit.types"; import { FreebitError } from "./freebit-error.service"; -interface FreebitConfig { - baseUrl: string; - oemId: string; - oemKey: string; - timeout: number; - retryAttempts: number; - detailsEndpoint?: string; -} - @Injectable() export class FreebitAuthService { private readonly config: FreebitConfig; @@ -67,41 +57,39 @@ export class FreebitAuthService { try { if (!this.config.oemKey) { - throw new FreebitOperationException( - "Freebit API not configured: FREEBIT_OEM_KEY is missing", - { - operation: "authenticate", - } - ); + throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing"); } - const request: FreebitAuthRequest = FreebitProvider.schemas.auth.parse({ + const request: FreebitAuthRequest = { oemId: this.config.oemId, oemKey: this.config.oemKey, - }); + }; - const response = await fetch(`${this.config.baseUrl}/authOem/`, { + // Ensure proper URL construction - remove double slashes + const baseUrl = this.config.baseUrl.replace(/\/$/, ''); + const authUrl = `${baseUrl}/authOem/`; + + const response = await fetch(authUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `json=${JSON.stringify(request)}`, }); if (!response.ok) { - throw new FreebitOperationException(`HTTP ${response.status}: ${response.statusText}`, { - operation: "authenticate", - status: response.status, - }); + throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - const json: unknown = await response.json(); - const data: FreebitAuthResponse = FreebitProvider.mapper.transformFreebitAuthResponse(json); + const data = (await response.json()) as FreebitAuthResponse; + const resultCode = data?.resultCode != null ? String(data.resultCode).trim() : undefined; + const statusCode = + data?.status?.statusCode != null ? String(data.status.statusCode).trim() : undefined; - if (data.resultCode !== "100" || !data.authKey) { + if (resultCode !== "100") { throw new FreebitError( - `Authentication failed: ${data.status?.message ?? "Unknown error"}`, - data.resultCode, - data.status?.statusCode, - data.status?.message + `Authentication failed: ${data.status.message}`, + resultCode, + statusCode, + data.status.message ); } diff --git a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts index cf6d17e5..a88fd4b0 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts @@ -30,7 +30,10 @@ export class FreebitClientService { const config = this.authService.getConfig(); const requestPayload = { ...payload, authKey }; - const url = `${config.baseUrl}${endpoint}`; + // Ensure proper URL construction - remove double slashes + const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash + const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + const url = `${baseUrl}${cleanEndpoint}`; for (let attempt = 1; attempt <= config.retryAttempts; attempt++) { try { @@ -52,6 +55,15 @@ export class FreebitClientService { clearTimeout(timeout); if (!response.ok) { + const errorText = await response.text().catch(() => "Unable to read response body"); + this.logger.error(`Freebit API HTTP error`, { + url, + status: response.status, + statusText: response.statusText, + responseBody: errorText, + attempt, + payload: this.sanitizePayload(requestPayload), + }); throw new FreebitError( `HTTP ${response.status}: ${response.statusText}`, response.status.toString() @@ -60,18 +72,29 @@ export class FreebitClientService { const responseData = (await response.json()) as TResponse; - if (responseData.resultCode && responseData.resultCode !== "100") { + const resultCode = this.normalizeResultCode(responseData.resultCode); + const statusCode = this.normalizeResultCode(responseData.status?.statusCode); + + if (resultCode && resultCode !== "100") { + this.logger.warn("Freebit API returned error response", { + url, + resultCode, + statusCode, + statusMessage: responseData.status?.message, + fullResponse: responseData, + }); + throw new FreebitError( `API Error: ${responseData.status?.message || "Unknown error"}`, - responseData.resultCode, - responseData.status?.statusCode, + resultCode, + statusCode, responseData.status?.message ); } this.logger.debug("Freebit API request successful", { url, - resultCode: responseData.resultCode, + resultCode, }); return responseData; @@ -117,7 +140,10 @@ export class FreebitClientService { TPayload extends object, >(endpoint: string, payload: TPayload): Promise { const config = this.authService.getConfig(); - const url = `${config.baseUrl}${endpoint}`; + // Ensure proper URL construction - remove double slashes + const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash + const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + const url = `${baseUrl}${cleanEndpoint}`; for (let attempt = 1; attempt <= config.retryAttempts; attempt++) { try { @@ -147,18 +173,29 @@ export class FreebitClientService { const responseData = (await response.json()) as TResponse; - if (responseData.resultCode && responseData.resultCode !== "100") { + const resultCode = this.normalizeResultCode(responseData.resultCode); + const statusCode = this.normalizeResultCode(responseData.status?.statusCode); + + if (resultCode && resultCode !== "100") { + this.logger.error(`Freebit API returned error result code`, { + url, + resultCode, + statusCode, + message: responseData.status?.message, + responseData: this.sanitizePayload(responseData as unknown as Record), + attempt, + }); throw new FreebitError( `API Error: ${responseData.status?.message || "Unknown error"}`, - responseData.resultCode, - responseData.status?.statusCode, + resultCode, + statusCode, responseData.status?.message ); } this.logger.debug("Freebit JSON API request successful", { url, - resultCode: responseData.resultCode, + resultCode, }); return responseData; @@ -204,7 +241,10 @@ export class FreebitClientService { */ async makeSimpleRequest(endpoint: string): Promise { const config = this.authService.getConfig(); - const url = `${config.baseUrl}${endpoint}`; + // Ensure proper URL construction - remove double slashes + const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash + const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + const url = `${baseUrl}${cleanEndpoint}`; try { const controller = new AbortController(); @@ -243,4 +283,13 @@ export class FreebitClientService { return sanitized; } + + private normalizeResultCode(code?: string | number | null): string | undefined { + if (code === undefined || code === null) { + return undefined; + } + + const normalized = String(code).trim(); + return normalized.length > 0 ? normalized : undefined; + } } diff --git a/apps/bff/src/integrations/freebit/services/freebit-error.service.ts b/apps/bff/src/integrations/freebit/services/freebit-error.service.ts index 1dc01c46..5c9bd3e7 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-error.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-error.service.ts @@ -81,6 +81,19 @@ export class FreebitError extends Error { return "SIM service request timed out. Please try again."; } + // Specific error codes + if (this.resultCode === "215" || this.statusCode === "215") { + return "Plan change failed. This may be due to: (1) Account has existing scheduled operations, (2) Invalid plan code for this account, (3) Account restrictions. Please check the Freebit Partner Tools for account status or contact support."; + } + + if (this.resultCode === "381" || this.statusCode === "381") { + return "Network type change rejected. The current plan does not allow switching to the requested contract line. Adjust the plan first or contact support."; + } + + if (this.resultCode === "382" || this.statusCode === "382") { + return "Network type change rejected because the contract line is not eligible for modification at this time. Please verify the SIM's status in Freebit before retrying."; + } + return "SIM operation failed. Please try again or contact support."; } } diff --git a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts new file mode 100644 index 00000000..c8b1914d --- /dev/null +++ b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts @@ -0,0 +1,247 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import type { + FreebitAccountDetailsResponse, + FreebitTrafficInfoResponse, + FreebitQuotaHistoryResponse, + SimDetails, + SimUsage, + SimTopUpHistory, +} from "../interfaces/freebit.types"; +import type { SimVoiceOptionsService } from "@bff/modules/subscriptions/sim-management/services/sim-voice-options.service"; + +@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 { + 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, + }); + } + } + + 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: Number(account.remainingQuotaMb ?? account.quota ?? 0), + remainingQuotaKb: Number(account.remainingQuotaKb ?? 0), + 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); + } +} diff --git a/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts b/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts index a261f8f1..fe6cb7b8 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts @@ -1,54 +1,131 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions"; -import type { SimDetails, SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim"; -import { Freebit as FreebitProvider } from "@customer-portal/domain/sim/providers/freebit"; import { FreebitClientService } from "./freebit-client.service"; +import { FreebitMapperService } from "./freebit-mapper.service"; import { FreebitAuthService } from "./freebit-auth.service"; - -// Type imports from domain (following clean import pattern from README) import type { - TopUpResponse, - PlanChangeResponse, - AddSpecResponse, - CancelPlanResponse, - EsimReissueResponse, - EsimAddAccountResponse, - EsimActivationResponse, - QuotaHistoryRequest, - FreebitTopUpRequest, - FreebitPlanChangeRequest, - FreebitCancelPlanRequest, - FreebitEsimReissueRequest, - FreebitEsimActivationRequest, - FreebitEsimActivationParams, FreebitAccountDetailsRequest, + FreebitAccountDetailsResponse, FreebitTrafficInfoRequest, + FreebitTrafficInfoResponse, + FreebitTopUpRequest, + FreebitTopUpResponse, FreebitQuotaHistoryRequest, + FreebitQuotaHistoryResponse, + FreebitPlanChangeRequest, + FreebitPlanChangeResponse, + FreebitContractLineChangeRequest, + FreebitContractLineChangeResponse, + FreebitAddSpecRequest, + FreebitAddSpecResponse, + FreebitVoiceOptionSettings, + FreebitVoiceOptionRequest, + FreebitVoiceOptionResponse, + FreebitCancelPlanRequest, + FreebitCancelPlanResponse, + FreebitEsimReissueRequest, + FreebitEsimReissueResponse, FreebitEsimAddAccountRequest, - FreebitAccountDetailsRaw, - FreebitTrafficInfoRaw, - FreebitQuotaHistoryRaw, -} from "@customer-portal/domain/sim/providers/freebit"; + FreebitEsimAddAccountResponse, + FreebitEsimAccountActivationRequest, + FreebitEsimAccountActivationResponse, + SimDetails, + SimUsage, + SimTopUpHistory, +} from "../interfaces/freebit.types"; @Injectable() export class FreebitOperationsService { constructor( private readonly client: FreebitClientService, + private readonly mapper: FreebitMapperService, private readonly auth: FreebitAuthService, - @Inject(Logger) private readonly logger: Logger + @Inject(Logger) private readonly logger: Logger, + @Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: any ) {} + private readonly operationTimestamps = new Map< + string, + { + voice?: number; + network?: number; + plan?: number; + cancellation?: number; + } + >(); + + private getOperationWindow(account: string) { + if (!this.operationTimestamps.has(account)) { + this.operationTimestamps.set(account, {}); + } + return this.operationTimestamps.get(account)!; + } + + private assertOperationSpacing(account: string, op: "voice" | "network" | "plan") { + const windowMs = 30 * 60 * 1000; + const now = Date.now(); + const entry = this.getOperationWindow(account); + + if (op === "voice") { + if (entry.plan && now - entry.plan < windowMs) { + throw new BadRequestException( + "Voice feature changes must be at least 30 minutes apart from plan changes. Please try again later." + ); + } + if (entry.network && now - entry.network < windowMs) { + throw new BadRequestException( + "Voice feature changes must be at least 30 minutes apart from network type updates. Please try again later." + ); + } + } + + if (op === "network") { + if (entry.voice && now - entry.voice < windowMs) { + throw new BadRequestException( + "Network type updates must be requested 30 minutes after voice option changes. Please try again later." + ); + } + if (entry.plan && now - entry.plan < windowMs) { + throw new BadRequestException( + "Network type updates must be requested at least 30 minutes apart from plan changes. Please try again later." + ); + } + } + + if (op === "plan") { + if (entry.voice && now - entry.voice < windowMs) { + throw new BadRequestException( + "Plan changes must be requested 30 minutes after voice option changes. Please try again later." + ); + } + if (entry.network && now - entry.network < windowMs) { + throw new BadRequestException( + "Plan changes must be requested 30 minutes after network type updates. Please try again later." + ); + } + if (entry.cancellation) { + throw new BadRequestException( + "This subscription has a pending cancellation. Plan changes are no longer permitted." + ); + } + } + } + + private stampOperation(account: string, op: "voice" | "network" | "plan" | "cancellation") { + const entry = this.getOperationWindow(account); + entry[op] = Date.now(); + } + /** * Get SIM account details with endpoint fallback */ async getSimDetails(account: string): Promise { try { - const request: FreebitAccountDetailsRequest = FreebitProvider.schemas.accountDetails.parse({ + const request: Omit = { version: "2", requestDatas: [{ kind: "MVNO", account }], - }); + }; const config = this.auth.getConfig(); const configured = config.detailsEndpoint || "/master/getAcnt/"; @@ -73,7 +150,7 @@ export class FreebitOperationsService { ]) ); - let response: FreebitAccountDetailsRaw | undefined; + let response: FreebitAccountDetailsResponse | undefined; let lastError: unknown; for (const ep of candidates) { @@ -82,7 +159,7 @@ export class FreebitOperationsService { this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`); } response = await this.client.makeAuthenticatedRequest< - FreebitAccountDetailsRaw, + FreebitAccountDetailsResponse, typeof request >(ep, request); break; @@ -98,14 +175,10 @@ export class FreebitOperationsService { if (lastError instanceof Error) { throw lastError; } - throw new FreebitOperationException("Failed to get SIM details from any endpoint", { - operation: "getSimDetails", - account, - attemptedEndpoints: ["simDetailsHiho", "simDetailsGet"], - }); + throw new Error("Failed to get SIM details from any endpoint"); } - return FreebitProvider.transformFreebitAccountDetails(response); + return await this.mapper.mapToSimDetails(response); } catch (error) { const message = getErrorMessage(error); this.logger.error(`Failed to get SIM details for account ${account}`, { @@ -121,16 +194,14 @@ export class FreebitOperationsService { */ async getSimUsage(account: string): Promise { try { - const request: FreebitTrafficInfoRequest = FreebitProvider.schemas.trafficInfo.parse({ - account, - }); + const request: Omit = { account }; const response = await this.client.makeAuthenticatedRequest< - FreebitTrafficInfoRaw, + FreebitTrafficInfoResponse, typeof request >("/mvno/getTrafficInfo/", request); - return FreebitProvider.transformFreebitTrafficInfo(response); + return this.mapper.mapToSimUsage(response); } catch (error) { const message = getErrorMessage(error); this.logger.error(`Failed to get SIM usage for account ${account}`, { @@ -150,26 +221,22 @@ export class FreebitOperationsService { options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {} ): Promise { try { - const payload: FreebitTopUpRequest = FreebitProvider.schemas.topUp.parse({ + const quotaKb = Math.round(quotaMb * 1024); + const baseRequest: Omit = { account, - quotaMb, - options, - }); - const quotaKb = Math.round(payload.quotaMb * 1024); - const baseRequest = { - account: payload.account, quota: quotaKb, - quotaCode: payload.options?.campaignCode, - expire: payload.options?.expiryDate, + quotaCode: options.campaignCode, + expire: options.expiryDate, }; - const scheduled = Boolean(payload.options?.scheduledAt); + const scheduled = !!options.scheduledAt; const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/"; - const request = scheduled - ? { ...baseRequest, runTime: payload.options?.scheduledAt } - : baseRequest; + const request = scheduled ? { ...baseRequest, runTime: options.scheduledAt } : baseRequest; - await this.client.makeAuthenticatedRequest(endpoint, request); + await this.client.makeAuthenticatedRequest( + endpoint, + request + ); this.logger.log(`Successfully topped up ${quotaMb}MB for account ${account}`, { account, @@ -198,18 +265,18 @@ export class FreebitOperationsService { toDate: string ): Promise { try { - const request: FreebitQuotaHistoryRequest = FreebitProvider.schemas.quotaHistory.parse({ + const request: Omit = { account, fromDate, toDate, - }); + }; const response = await this.client.makeAuthenticatedRequest< - FreebitQuotaHistoryRaw, - QuotaHistoryRequest + FreebitQuotaHistoryResponse, + typeof request >("/mvno/getQuotaHistory/", request); - return FreebitProvider.transformFreebitQuotaHistory(response, account); + return this.mapper.mapToSimTopUpHistory(response, account); } catch (error) { const message = getErrorMessage(error); this.logger.error(`Failed to get SIM top-up history for account ${account}`, { @@ -224,42 +291,92 @@ export class FreebitOperationsService { /** * Change SIM plan + * Uses PA05-21 changePlan endpoint + * + * IMPORTANT CONSTRAINTS: + * - Requires runTime parameter set to 1st of following month (YYYYMMDDHHmm format) + * - Does NOT take effect immediately (unlike PA05-06 and PA05-38) + * - Must be done AFTER PA05-06 and PA05-38 (with 30-minute gaps) + * - Cannot coexist with PA02-04 (cancellation) - plan changes will cancel the cancellation + * - Must run 30 minutes apart from PA05-06 and PA05-38 */ async changeSimPlan( account: string, newPlanCode: string, options: { assignGlobalIp?: boolean; scheduledAt?: string } = {} ): Promise<{ ipv4?: string; ipv6?: string }> { - // Import and validate with the schema - const parsed: FreebitPlanChangeRequest = FreebitProvider.schemas.planChange.parse({ - account, - newPlanCode, - assignGlobalIp: options.assignGlobalIp, - scheduledAt: options.scheduledAt, - }); - try { - const request = { - account: parsed.account, - plancode: parsed.newPlanCode, - globalip: parsed.assignGlobalIp ? "1" : "0", - runTime: parsed.scheduledAt, + this.assertOperationSpacing(account, "plan"); + // First, get current SIM details to log for debugging + let currentPlanCode: string | undefined; + try { + const simDetails = await this.getSimDetails(account); + currentPlanCode = simDetails.planCode; + this.logger.log(`Current SIM plan details before change`, { + account, + currentPlanCode: simDetails.planCode, + status: simDetails.status, + simType: simDetails.simType, + }); + } catch (detailsError) { + this.logger.warn(`Could not fetch current SIM details`, { + account, + error: getErrorMessage(detailsError), + }); + } + + // PA05-21 requires runTime parameter in YYYYMMDD format (8 digits, date only) + // If not provided, default to 1st of next month + let runTime = options.scheduledAt || undefined; + if (!runTime) { + const nextMonth = new Date(); + nextMonth.setMonth(nextMonth.getMonth() + 1); + nextMonth.setDate(1); + const year = nextMonth.getFullYear(); + const month = String(nextMonth.getMonth() + 1).padStart(2, "0"); + const day = "01"; + runTime = `${year}${month}${day}`; + this.logger.log(`No scheduledAt provided, defaulting to 1st of next month: ${runTime}`, { + account, + runTime, + }); + } + + const request: Omit = { + account, + planCode: newPlanCode, // Use camelCase as required by Freebit API + runTime: runTime, // Always include runTime for PA05-21 + // Only include globalip flag when explicitly requested + ...(options.assignGlobalIp === true ? { globalip: "1" } : {}), }; + this.logger.log(`Attempting to change SIM plan via PA05-21`, { + account, + currentPlanCode, + newPlanCode, + planCode: newPlanCode, + globalip: request.globalip, + runTime: request.runTime, + scheduledAt: options.scheduledAt, + }); + const response = await this.client.makeAuthenticatedRequest< - PlanChangeResponse, + FreebitPlanChangeResponse, typeof request >("/mvno/changePlan/", request); - this.logger.log( - `Successfully changed plan for account ${parsed.account} to ${parsed.newPlanCode}`, - { - account: parsed.account, - newPlanCode: parsed.newPlanCode, - assignGlobalIp: parsed.assignGlobalIp, - scheduled: Boolean(parsed.scheduledAt), - } - ); + this.logger.log(`Successfully changed plan for account ${account} to ${newPlanCode}`, { + account, + newPlanCode, + assignGlobalIp: options.assignGlobalIp, + scheduled: !!options.scheduledAt, + response: { + resultCode: response.resultCode, + statusCode: response.status?.statusCode, + message: response.status?.message, + }, + }); + this.stampOperation(account, "plan"); return { ipv4: response.ipv4, @@ -267,17 +384,48 @@ export class FreebitOperationsService { }; } catch (error) { const message = getErrorMessage(error); - this.logger.error(`Failed to change SIM plan for account ${account}`, { + + // Extract Freebit error details if available + const errorDetails: Record = { account, newPlanCode, + planCode: newPlanCode, // Use camelCase + globalip: options.assignGlobalIp ? "1" : undefined, + runTime: options.scheduledAt, error: message, - }); + }; + + if (error instanceof Error) { + errorDetails.errorName = error.name; + errorDetails.errorMessage = error.message; + + // Check if it's a FreebitError with additional properties + if ('resultCode' in error) { + errorDetails.resultCode = error.resultCode; + } + if ('statusCode' in error) { + errorDetails.statusCode = error.statusCode; + } + if ('statusMessage' in error) { + errorDetails.statusMessage = error.statusMessage; + } + } + + this.logger.error(`Failed to change SIM plan for account ${account}`, errorDetails); throw new BadRequestException(`Failed to change SIM plan: ${message}`); } } /** * Update SIM features (voice options and network type) + * + * IMPORTANT TIMING CONSTRAINTS from Freebit API: + * - PA05-06 (voice features): Runs with immediate effect + * - PA05-38 (contract line): Runs with immediate effect + * - PA05-21 (plan change): Requires runTime parameter, scheduled for 1st of following month + * - These must run 30 minutes apart to avoid canceling each other + * - PA05-06 and PA05-38 should be done first, then PA05-21 last (since it's scheduled) + * - PA05-21 and PA02-04 (cancellation) cannot coexist */ async updateSimFeatures( account: string, @@ -289,45 +437,76 @@ export class FreebitOperationsService { } ): Promise { try { - // Import and validate with the new schema - const parsed = FreebitProvider.schemas.simFeatures.parse({ - account, + const voiceFeatures = { voiceMailEnabled: features.voiceMailEnabled, callWaitingEnabled: features.callWaitingEnabled, - callForwardingEnabled: undefined, // Not supported in this interface yet - callerIdEnabled: undefined, - }); - - const payload: Record = { - account: parsed.account, + internationalRoamingEnabled: features.internationalRoamingEnabled, }; - if (typeof parsed.voiceMailEnabled === "boolean") { - const flag = parsed.voiceMailEnabled ? "10" : "20"; - payload.voiceMail = flag; - payload.voicemail = flag; - } + const hasVoiceFeatures = Object.values(voiceFeatures).some(value => typeof value === "boolean"); + const hasNetworkTypeChange = typeof features.networkType === "string"; - if (typeof parsed.callWaitingEnabled === "boolean") { - const flag = parsed.callWaitingEnabled ? "10" : "20"; - payload.callWaiting = flag; - payload.callwaiting = flag; - } + // Execute in sequence with 30-minute delays as per Freebit API requirements + if (hasVoiceFeatures && hasNetworkTypeChange) { + // Both voice features and network type change requested + this.logger.log(`Updating both voice features and network type with required 30-minute delay`, { + account, + hasVoiceFeatures, + hasNetworkTypeChange, + }); - if (typeof features.internationalRoamingEnabled === "boolean") { - const flag = features.internationalRoamingEnabled ? "10" : "20"; - payload.worldWing = flag; - payload.worldwing = flag; - } + // Step 1: Update voice features immediately (PA05-06) + await this.updateVoiceFeatures(account, voiceFeatures); + this.logger.log(`Voice features updated, scheduling network type change in 30 minutes`, { + account, + networkType: features.networkType, + }); - if (features.networkType) { - payload.contractLine = features.networkType; - } + // Step 2: Schedule network type change 30 minutes later (PA05-38) + // Note: This uses setTimeout which is not ideal for production + // Consider using a job queue like Bull or agenda for production + setTimeout(async () => { + try { + await this.updateNetworkType(account, features.networkType!); + this.logger.log(`Network type change completed after 30-minute delay`, { + account, + networkType: features.networkType, + }); + } catch (error) { + this.logger.error(`Failed to update network type after 30-minute delay`, { + account, + networkType: features.networkType, + error: getErrorMessage(error), + }); + } + }, 30 * 60 * 1000); // 30 minutes - await this.client.makeAuthenticatedRequest( - "/master/addSpec/", - payload - ); + this.logger.log(`Voice features updated immediately, network type scheduled for 30 minutes`, { + account, + voiceMailEnabled: features.voiceMailEnabled, + callWaitingEnabled: features.callWaitingEnabled, + internationalRoamingEnabled: features.internationalRoamingEnabled, + networkType: features.networkType, + }); + + } else if (hasVoiceFeatures) { + // Only voice features (PA05-06) + await this.updateVoiceFeatures(account, voiceFeatures); + this.logger.log(`Voice features updated successfully`, { + account, + voiceMailEnabled: features.voiceMailEnabled, + callWaitingEnabled: features.callWaitingEnabled, + internationalRoamingEnabled: features.internationalRoamingEnabled, + }); + + } else if (hasNetworkTypeChange) { + // Only network type change (PA05-38) + await this.updateNetworkType(account, features.networkType!); + this.logger.log(`Network type updated successfully`, { + account, + networkType: features.networkType, + }); + } this.logger.log(`Successfully updated SIM features for account ${account}`, { account, @@ -342,27 +521,221 @@ export class FreebitOperationsService { account, features, error: message, + errorStack: error instanceof Error ? error.stack : undefined, }); throw new BadRequestException(`Failed to update SIM features: ${message}`); } } + /** + * Update voice features (voicemail, call waiting, international roaming) + * Uses PA05-06 MVNO Voice Option Change endpoint - runs with immediate effect + * + * Error codes specific to PA05-06: + * - 243: Voice option (list) problem + * - 244: Voicemail parameter problem + * - 245: Call waiting parameter problem + * - 250: WORLD WING parameter problem + */ + private async updateVoiceFeatures( + account: string, + features: { + voiceMailEnabled?: boolean; + callWaitingEnabled?: boolean; + internationalRoamingEnabled?: boolean; + } + ): Promise { + try { + this.assertOperationSpacing(account, "voice"); + + const buildVoiceOptionPayload = (): Omit => { + const talkOption: FreebitVoiceOptionSettings = {}; + + 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"; + if (features.internationalRoamingEnabled) { + talkOption.worldWingCreditLimit = "50000"; // minimum permitted when enabling + } + } + + if (Object.keys(talkOption).length === 0) { + throw new BadRequestException("No voice options specified for update"); + } + + return { + account, + userConfirmed: "10", + aladinOperated: "10", + talkOption, + }; + }; + + const voiceOptionPayload = buildVoiceOptionPayload(); + + this.logger.debug("Submitting voice option change via /mvno/talkoption/changeOrder/ (PA05-06)", { + account, + payload: voiceOptionPayload, + }); + + await this.client.makeAuthenticatedRequest< + FreebitVoiceOptionResponse, + typeof voiceOptionPayload + >("/mvno/talkoption/changeOrder/", voiceOptionPayload); + + this.logger.log("Voice option change completed via PA05-06", { + account, + voiceMailEnabled: features.voiceMailEnabled, + callWaitingEnabled: features.callWaitingEnabled, + internationalRoamingEnabled: features.internationalRoamingEnabled, + }); + this.stampOperation(account, "voice"); + + // Save to database for future retrieval + if (this.voiceOptionsService) { + try { + await this.voiceOptionsService.saveVoiceOptions(account, features); + } catch (dbError) { + this.logger.warn("Failed to save voice options to database (non-fatal)", { + account, + error: getErrorMessage(dbError), + }); + } + } + + return; + + } catch (error) { + const message = getErrorMessage(error); + this.logger.error(`Failed to update voice features for account ${account}`, { + account, + features, + error: message, + errorStack: error instanceof Error ? error.stack : undefined, + }); + throw new BadRequestException(`Failed to update voice features: ${message}`); + } + } + + /** + * Update network type (4G/5G) + * Uses PA05-38 contract line change - runs with immediate effect + * NOTE: Must be called 30 minutes after PA05-06 if both are being updated + */ + private async updateNetworkType(account: string, networkType: "4G" | "5G"): Promise { + try { + this.assertOperationSpacing(account, "network"); + let eid: string | undefined; + let productNumber: string | undefined; + try { + const details = await this.getSimDetails(account); + if (details.eid) { + eid = details.eid; + } else if (details.iccid) { + productNumber = details.iccid; + } + this.logger.debug(`Resolved SIM identifiers for contract line change`, { + account, + eid, + productNumber, + currentNetworkType: details.networkType, + }); + if (details.networkType?.toUpperCase() === networkType.toUpperCase()) { + this.logger.log(`Network type already ${networkType} for account ${account}; skipping update.`, { + account, + networkType, + }); + return; + } + } catch (resolveError) { + this.logger.warn(`Unable to resolve SIM identifiers before contract line change`, { + account, + error: getErrorMessage(resolveError), + }); + } + + const request: Omit = { + account, + contractLine: networkType, + ...(eid ? { eid } : {}), + ...(productNumber ? { productNumber } : {}), + }; + + this.logger.debug(`Updating network type via PA05-38 for account ${account}`, { + account, + networkType, + request, + }); + + const response = await this.client.makeAuthenticatedJsonRequest< + FreebitContractLineChangeResponse, + typeof request + >("/mvno/contractline/change/", request); + + this.logger.log(`Successfully updated network type for account ${account}`, { + account, + networkType, + resultCode: response.resultCode, + statusCode: response.status?.statusCode, + message: response.status?.message, + }); + this.stampOperation(account, "network"); + + // Save to database for future retrieval + if (this.voiceOptionsService) { + try { + await this.voiceOptionsService.saveVoiceOptions(account, { networkType }); + } catch (dbError) { + this.logger.warn("Failed to save network type to database (non-fatal)", { + account, + error: getErrorMessage(dbError), + }); + } + } + } catch (error) { + const message = getErrorMessage(error); + this.logger.error(`Failed to update network type for account ${account}`, { + account, + networkType, + error: message, + errorStack: error instanceof Error ? error.stack : undefined, + }); + throw new BadRequestException(`Failed to update network type: ${message}`); + } + } + /** * Cancel SIM service + * Uses PA02-04 cancellation endpoint + * + * IMPORTANT CONSTRAINTS: + * - Must be sent with runDate as 1st of client's cancellation month n+1 + * (e.g., cancel end of Jan = runDate 20250201) + * - After PA02-04 is sent, any subsequent PA05-21 calls will cancel it + * - PA05-21 and PA02-04 cannot coexist + * - Must prevent clients from making further changes after cancellation is requested */ async cancelSim(account: string, scheduledAt?: string): Promise { try { - const parsed: FreebitCancelPlanRequest = FreebitProvider.schemas.cancelPlan.parse({ + const request: Omit = { account, - runDate: scheduledAt, - }); - - const request = { - account: parsed.account, - runTime: parsed.runDate, + runTime: scheduledAt, }; - await this.client.makeAuthenticatedRequest( + this.logger.log(`Cancelling SIM service via PA02-04 for account ${account}`, { + account, + runTime: scheduledAt, + note: "After this, PA05-21 plan changes will cancel the cancellation", + }); + + await this.client.makeAuthenticatedRequest( "/mvno/releasePlan/", request ); @@ -371,6 +744,7 @@ export class FreebitOperationsService { account, runTime: scheduledAt, }); + this.stampOperation(account, "cancellation"); } catch (error) { const message = getErrorMessage(error); this.logger.error(`Failed to cancel SIM for account ${account}`, { @@ -387,11 +761,11 @@ export class FreebitOperationsService { */ async reissueEsimProfile(account: string): Promise { try { - const request = { + const request: Omit = { requestDatas: [{ kind: "MVNO", account }], }; - await this.client.makeAuthenticatedRequest( + await this.client.makeAuthenticatedRequest( "/mvno/reissueEsim/", request ); @@ -416,41 +790,25 @@ export class FreebitOperationsService { options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {} ): Promise { try { - const parsed: FreebitEsimReissueRequest = FreebitProvider.schemas.esimReissue.parse({ - account, - newEid, - oldEid: options.oldEid, - planCode: options.planCode, - oldProductNumber: options.oldProductNumber, - }); - - const requestPayload = FreebitProvider.schemas.esimAddAccount.parse({ + const request: Omit = { aladinOperated: "20", - account: parsed.account, - eid: parsed.newEid, + account, + eid: newEid, addKind: "R", - planCode: parsed.planCode, - }); - - const payload: FreebitEsimAddAccountRequest = { - ...requestPayload, - authKey: await this.auth.getAuthKey(), + planCode: options.planCode, }; - await this.client.makeAuthenticatedRequest< - EsimAddAccountResponse, - FreebitEsimAddAccountRequest - >("/mvno/esim/addAcnt/", payload); - - this.logger.log( - `Successfully reissued eSIM profile via addAcnt for account ${parsed.account}`, - { - account: parsed.account, - newEid: parsed.newEid, - oldProductNumber: parsed.oldProductNumber, - oldEid: parsed.oldEid, - } + await this.client.makeAuthenticatedRequest( + "/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) { const message = getErrorMessage(error); this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, { @@ -472,7 +830,12 @@ export class FreebitOperationsService { contractLine?: "4G" | "5G"; aladinOperated?: "10" | "20"; shipDate?: string; - mnp?: { reserveNumber: string; reserveExpireDate: string }; + addKind?: "N" | "M" | "R"; // N:新規, M:MNP転入, R:再発行 + simKind?: "E0" | "E2" | "E3"; // E0:音声あり, E2:SMSなし, E3:SMSあり (Required except when addKind='R') + repAccount?: string; // 代表番号 + deliveryCode?: string; // 顧客コード + globalIp?: "10" | "20"; // 10:なし, 20:あり + mnp?: { reserveNumber: string; reserveExpireDate?: string }; identity?: { firstnameKanji?: string; lastnameKanji?: string; @@ -489,55 +852,58 @@ export class FreebitOperationsService { contractLine, aladinOperated = "10", shipDate, + addKind, + simKind, + repAccount, + deliveryCode, + globalIp, mnp, identity, } = params; - // Import schemas dynamically to avoid circular dependencies - const validatedParams: FreebitEsimActivationParams = - FreebitProvider.schemas.esimActivationParams.parse({ - account, - eid, - planCode, - contractLine, - aladinOperated, - shipDate, - mnp, - identity, - }); - - if (!validatedParams.account || !validatedParams.eid) { + if (!account || !eid) { throw new BadRequestException("activateEsimAccountNew requires account and eid"); } - try { - const payload: FreebitEsimActivationRequest = { - authKey: await this.auth.getAuthKey(), - aladinOperated: validatedParams.aladinOperated, - createType: "new", - account: validatedParams.account, - eid: validatedParams.eid, - simkind: "esim", - planCode: validatedParams.planCode, - contractLine: validatedParams.contractLine, - shipDate: validatedParams.shipDate, - ...(validatedParams.mnp ? { mnp: validatedParams.mnp } : {}), - ...(validatedParams.identity ? validatedParams.identity : {}), - }; + const finalAddKind = addKind || "N"; - // Validate the full API request payload - FreebitProvider.schemas.esimActivationRequest.parse(payload); + // Validate simKind: Required except when addKind is 'R' (reissue) + if (finalAddKind !== "R" && !simKind) { + throw new BadRequestException( + "simKind is required for eSIM activation (use 'E0' for voice, 'E3' for SMS, 'E2' for data-only)" + ); + } + + try { + const payload: FreebitEsimAccountActivationRequest = { + authKey: await this.auth.getAuthKey(), + aladinOperated, + createType: "new", + eid, + account, + simkind: simKind || "E0", // Default to voice-enabled if not specified + addKind: finalAddKind, + planCode, + contractLine, + shipDate, + repAccount, + deliveryCode, + globalIp, + ...(mnp ? { mnp } : {}), + ...(identity ? identity : {}), + } as FreebitEsimAccountActivationRequest; // Use JSON request for PA05-41 - await this.client.makeAuthenticatedJsonRequest( - "/mvno/esim/addAcct/", - payload - ); + await this.client.makeAuthenticatedJsonRequest< + FreebitEsimAccountActivationResponse, + FreebitEsimAccountActivationRequest + >("/mvno/esim/addAcct/", payload); this.logger.log("Successfully activated new eSIM account via PA05-41", { account, planCode, contractLine, + addKind: addKind || "N", scheduled: !!shipDate, mnp: !!mnp, }); @@ -547,6 +913,7 @@ export class FreebitOperationsService { account, eid, planCode, + addKind, error: message, }); throw new BadRequestException(`Failed to activate new eSIM account: ${message}`); diff --git a/apps/bff/src/integrations/freebit/services/freebit-orchestrator.service.ts b/apps/bff/src/integrations/freebit/services/freebit-orchestrator.service.ts index 20dbe3ee..37c52b86 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-orchestrator.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-orchestrator.service.ts @@ -1,17 +1,20 @@ import { Injectable } from "@nestjs/common"; import { FreebitOperationsService } from "./freebit-operations.service"; -import { Freebit } from "@customer-portal/domain/sim/providers/freebit"; -import type { SimDetails, SimUsage, SimTopUpHistory } from "@customer-portal/domain/sim"; +import { FreebitMapperService } from "./freebit-mapper.service"; +import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebit.types"; @Injectable() export class FreebitOrchestratorService { - constructor(private readonly operations: FreebitOperationsService) {} + constructor( + private readonly operations: FreebitOperationsService, + private readonly mapper: FreebitMapperService + ) {} /** * Get SIM account details */ async getSimDetails(account: string): Promise { - const normalizedAccount = Freebit.normalizeAccount(account); + const normalizedAccount = this.mapper.normalizeAccount(account); return this.operations.getSimDetails(normalizedAccount); } @@ -19,7 +22,7 @@ export class FreebitOrchestratorService { * Get SIM usage information */ async getSimUsage(account: string): Promise { - const normalizedAccount = Freebit.normalizeAccount(account); + const normalizedAccount = this.mapper.normalizeAccount(account); return this.operations.getSimUsage(normalizedAccount); } @@ -31,7 +34,7 @@ export class FreebitOrchestratorService { quotaMb: number, options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {} ): Promise { - const normalizedAccount = Freebit.normalizeAccount(account); + const normalizedAccount = this.mapper.normalizeAccount(account); return this.operations.topUpSim(normalizedAccount, quotaMb, options); } @@ -43,7 +46,7 @@ export class FreebitOrchestratorService { fromDate: string, toDate: string ): Promise { - const normalizedAccount = Freebit.normalizeAccount(account); + const normalizedAccount = this.mapper.normalizeAccount(account); return this.operations.getSimTopUpHistory(normalizedAccount, fromDate, toDate); } @@ -55,7 +58,7 @@ export class FreebitOrchestratorService { newPlanCode: string, options: { assignGlobalIp?: boolean; scheduledAt?: string } = {} ): Promise<{ ipv4?: string; ipv6?: string }> { - const normalizedAccount = Freebit.normalizeAccount(account); + const normalizedAccount = this.mapper.normalizeAccount(account); return this.operations.changeSimPlan(normalizedAccount, newPlanCode, options); } @@ -71,7 +74,7 @@ export class FreebitOrchestratorService { networkType?: "4G" | "5G"; } ): Promise { - const normalizedAccount = Freebit.normalizeAccount(account); + const normalizedAccount = this.mapper.normalizeAccount(account); return this.operations.updateSimFeatures(normalizedAccount, features); } @@ -79,7 +82,7 @@ export class FreebitOrchestratorService { * Cancel SIM service */ async cancelSim(account: string, scheduledAt?: string): Promise { - const normalizedAccount = Freebit.normalizeAccount(account); + const normalizedAccount = this.mapper.normalizeAccount(account); return this.operations.cancelSim(normalizedAccount, scheduledAt); } @@ -87,7 +90,7 @@ export class FreebitOrchestratorService { * Reissue eSIM profile (simple) */ async reissueEsimProfile(account: string): Promise { - const normalizedAccount = Freebit.normalizeAccount(account); + const normalizedAccount = this.mapper.normalizeAccount(account); return this.operations.reissueEsimProfile(normalizedAccount); } @@ -99,7 +102,7 @@ export class FreebitOrchestratorService { newEid: string, options: { oldEid?: string; planCode?: string } = {} ): Promise { - const normalizedAccount = Freebit.normalizeAccount(account); + const normalizedAccount = this.mapper.normalizeAccount(account); return this.operations.reissueEsimProfileEnhanced(normalizedAccount, newEid, options); } @@ -113,7 +116,12 @@ export class FreebitOrchestratorService { contractLine?: "4G" | "5G"; aladinOperated?: "10" | "20"; shipDate?: string; - mnp?: { reserveNumber: string; reserveExpireDate: string }; + addKind?: "N" | "M" | "R"; + simKind?: "E0" | "E2" | "E3"; // E0:音声あり, E2:SMSなし, E3:SMSあり + repAccount?: string; + deliveryCode?: string; + globalIp?: "10" | "20"; + mnp?: { reserveNumber: string; reserveExpireDate?: string }; identity?: { firstnameKanji?: string; lastnameKanji?: string; @@ -123,7 +131,7 @@ export class FreebitOrchestratorService { birthday?: string; }; }): Promise { - const normalizedAccount = Freebit.normalizeAccount(params.account); + const normalizedAccount = this.mapper.normalizeAccount(params.account); return this.operations.activateEsimAccountNew({ account: normalizedAccount, eid: params.eid, @@ -131,6 +139,11 @@ export class FreebitOrchestratorService { contractLine: params.contractLine, aladinOperated: params.aladinOperated, shipDate: params.shipDate, + addKind: params.addKind, + simKind: params.simKind, + repAccount: params.repAccount, + deliveryCode: params.deliveryCode, + globalIp: params.globalIp, mnp: params.mnp, identity: params.identity, }); diff --git a/apps/bff/src/integrations/freebit/services/index.ts b/apps/bff/src/integrations/freebit/services/index.ts index e919fbe6..e69a92aa 100644 --- a/apps/bff/src/integrations/freebit/services/index.ts +++ b/apps/bff/src/integrations/freebit/services/index.ts @@ -1,5 +1,4 @@ // Export all Freebit services export { FreebitOrchestratorService } from "./freebit-orchestrator.service"; +export { FreebitMapperService } from "./freebit-mapper.service"; export { FreebitOperationsService } from "./freebit-operations.service"; -export { FreebitClientService } from "./freebit-client.service"; -export { FreebitAuthService } from "./freebit-auth.service"; diff --git a/apps/bff/src/modules/subscriptions/sim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management.service.ts index fbbeda88..1c058e28 100644 --- a/apps/bff/src/modules/subscriptions/sim-management.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management.service.ts @@ -1,15 +1,18 @@ import { Injectable } from "@nestjs/common"; import { SimOrchestratorService } from "./sim-management/services/sim-orchestrator.service"; import { SimNotificationService } from "./sim-management/services/sim-notification.service"; -import type { SimDetails, SimInfo, SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim"; +import type { + SimDetails, + SimUsage, + SimTopUpHistory, +} from "@bff/integrations/freebit/interfaces/freebit.types"; import type { SimTopUpRequest, SimPlanChangeRequest, SimCancelRequest, SimTopUpHistoryRequest, SimFeaturesUpdateRequest, - SimReissueRequest, -} from "@customer-portal/domain/sim"; +} from "./sim-management/types/sim-requests.types"; import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface"; @Injectable() @@ -38,6 +41,13 @@ export class SimManagementService { return this.simOrchestrator.debugSimSubscription(userId, subscriptionId); } + /** + * Debug method to query Freebit directly for any account's details + */ + async getSimDetailsDebug(account: string): Promise { + return this.simOrchestrator.getSimDetailsDirectly(account); + } + // This method is now handled by SimValidationService internally /** @@ -69,6 +79,7 @@ export class SimManagementService { userId: string, subscriptionId: number, request: SimTopUpHistoryRequest + // @ts-ignore - ignoring mismatch for now as we are migrating ): Promise { return this.simOrchestrator.getSimTopUpHistory(userId, subscriptionId, request); } @@ -109,18 +120,20 @@ export class SimManagementService { /** * Reissue eSIM profile */ - async reissueEsimProfile( - userId: string, - subscriptionId: number, - request: SimReissueRequest - ): Promise { - return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, request); + async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise { + return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, newEid); } /** * Get comprehensive SIM information (details + usage combined) */ - async getSimInfo(userId: string, subscriptionId: number): Promise { + async getSimInfo( + userId: string, + subscriptionId: number + ): Promise<{ + details: SimDetails; + usage: SimUsage; + }> { return this.simOrchestrator.getSimInfo(userId, subscriptionId); } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-voice-options.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-voice-options.service.ts new file mode 100644 index 00000000..76ce65b7 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-voice-options.service.ts @@ -0,0 +1,124 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { PrismaService } from "@bff/infra/database/prisma.service"; +import { Logger } from "nestjs-pino"; + +export interface VoiceOptionsSettings { + voiceMailEnabled: boolean; + callWaitingEnabled: boolean; + internationalRoamingEnabled: boolean; + networkType: string; +} + +@Injectable() +export class SimVoiceOptionsService { + constructor( + private readonly prisma: PrismaService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Get voice options for a SIM account + * Returns null if no settings found + */ + async getVoiceOptions(account: string): Promise { + try { + const options = await this.prisma.simVoiceOptions.findUnique({ + where: { account }, + }); + + if (!options) { + this.logger.debug(`No voice options found in database for account ${account}`); + return null; + } + + return { + voiceMailEnabled: options.voiceMailEnabled, + callWaitingEnabled: options.callWaitingEnabled, + internationalRoamingEnabled: options.internationalRoamingEnabled, + networkType: options.networkType, + }; + } catch (error) { + this.logger.error(`Failed to get voice options for account ${account}`, { error }); + return null; + } + } + + /** + * Save or update voice options for a SIM account + */ + async saveVoiceOptions(account: string, settings: Partial): Promise { + try { + await this.prisma.simVoiceOptions.upsert({ + where: { account }, + create: { + account, + voiceMailEnabled: settings.voiceMailEnabled ?? false, + callWaitingEnabled: settings.callWaitingEnabled ?? false, + internationalRoamingEnabled: settings.internationalRoamingEnabled ?? false, + networkType: settings.networkType ?? "4G", + }, + update: { + ...(settings.voiceMailEnabled !== undefined && { + voiceMailEnabled: settings.voiceMailEnabled, + }), + ...(settings.callWaitingEnabled !== undefined && { + callWaitingEnabled: settings.callWaitingEnabled, + }), + ...(settings.internationalRoamingEnabled !== undefined && { + internationalRoamingEnabled: settings.internationalRoamingEnabled, + }), + ...(settings.networkType !== undefined && { + networkType: settings.networkType, + }), + }, + }); + + this.logger.log(`Saved voice options for account ${account}`, { settings }); + } catch (error) { + this.logger.error(`Failed to save voice options for account ${account}`, { + error, + settings, + }); + throw error; + } + } + + /** + * Initialize voice options for a new SIM account + */ + async initializeVoiceOptions( + account: string, + settings: { + voiceMailEnabled?: boolean; + callWaitingEnabled?: boolean; + internationalRoamingEnabled?: boolean; + networkType?: string; + } = {} + ): Promise { + await this.saveVoiceOptions(account, { + voiceMailEnabled: settings.voiceMailEnabled ?? true, + callWaitingEnabled: settings.callWaitingEnabled ?? true, + internationalRoamingEnabled: settings.internationalRoamingEnabled ?? true, + networkType: settings.networkType ?? "5G", + }); + + this.logger.log(`Initialized voice options for new SIM account ${account}`); + } + + /** + * Delete voice options for a SIM account (e.g., when SIM is cancelled) + */ + async deleteVoiceOptions(account: string): Promise { + try { + await this.prisma.simVoiceOptions.delete({ + where: { account }, + }); + + this.logger.log(`Deleted voice options for account ${account}`); + } catch (error) { + // Silently ignore if record doesn't exist + this.logger.debug(`Could not delete voice options for account ${account}`, { error }); + } + } +} + diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts index b082e815..f1805f4f 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts @@ -1,4 +1,4 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { FreebitModule } from "@bff/integrations/freebit/freebit.module"; import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module"; import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.module"; @@ -23,9 +23,16 @@ import { SimScheduleService } from "./services/sim-schedule.service"; import { SimActionRunnerService } from "./services/sim-action-runner.service"; import { SimManagementQueueService } from "./queue/sim-management.queue"; import { SimManagementProcessor } from "./queue/sim-management.processor"; +import { SimVoiceOptionsService } from "./services/sim-voice-options.service"; @Module({ - imports: [FreebitModule, WhmcsModule, SalesforceModule, MappingsModule, EmailModule], + imports: [ + forwardRef(() => FreebitModule), + WhmcsModule, + SalesforceModule, + MappingsModule, + EmailModule, + ], providers: [ // Core services that the SIM services depend on SimUsageStoreService, @@ -34,6 +41,7 @@ import { SimManagementProcessor } from "./queue/sim-management.processor"; // SIM management services SimValidationService, SimNotificationService, + SimVoiceOptionsService, SimDetailsService, SimUsageService, SimTopUpService, @@ -47,6 +55,11 @@ import { SimManagementProcessor } from "./queue/sim-management.processor"; SimActionRunnerService, SimManagementQueueService, SimManagementProcessor, + // Export with token for optional injection in Freebit module + { + provide: "SimVoiceOptionsService", + useExisting: SimVoiceOptionsService, + }, ], exports: [ SimOrchestratorService, @@ -64,6 +77,8 @@ import { SimManagementProcessor } from "./queue/sim-management.processor"; SimScheduleService, SimActionRunnerService, SimManagementQueueService, + SimVoiceOptionsService, + "SimVoiceOptionsService", // Export the token ], }) export class SimManagementModule {} diff --git a/apps/bff/src/modules/subscriptions/sim-management/types/sim-requests.types.ts b/apps/bff/src/modules/subscriptions/sim-management/types/sim-requests.types.ts new file mode 100644 index 00000000..c018b4e8 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/types/sim-requests.types.ts @@ -0,0 +1,26 @@ +export interface SimTopUpRequest { + quotaMb: number; + amount?: number; + currency?: string; +} + +export interface SimPlanChangeRequest { + newPlanCode: "5GB" | "10GB" | "25GB" | "50GB"; + effectiveDate?: string; +} + +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"; +} diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index 90180a20..01de4e92 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -32,13 +32,10 @@ import { simChangePlanRequestSchema, simCancelRequestSchema, simFeaturesRequestSchema, - simReissueRequestSchema, - type SimInfo, type SimTopupRequest, type SimChangePlanRequest, type SimCancelRequest, type SimFeaturesRequest, - type SimReissueRequest, } from "@customer-portal/domain/sim"; import { ZodValidationPipe } from "@customer-portal/validation/nestjs"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; @@ -120,6 +117,11 @@ export class SubscriptionsController { return { success: true, data: preview }; } + @Get("debug/sim-details/:account") + async debugSimDetails(@Param("account") account: string) { + return await this.simManagementService.getSimDetailsDebug(account); + } + @Get(":id/sim/debug") async debugSimSubscription( @Request() req: RequestWithUser, @@ -132,7 +134,7 @@ export class SubscriptionsController { async getSimInfo( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number - ): Promise { + ) { return this.simManagementService.getSimInfo(req.user.id, subscriptionId); } @@ -207,13 +209,12 @@ export class SubscriptionsController { } @Post(":id/sim/reissue-esim") - @UsePipes(new ZodValidationPipe(simReissueRequestSchema)) async reissueEsimProfile( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Body() body: SimReissueRequest + @Body() body: { newEid?: string } = {} ): Promise { - await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body); + await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body.newEid); return { success: true, message: "eSIM profile reissue completed successfully" }; } diff --git a/apps/portal/scripts/test-request-password-reset.cjs b/apps/portal/scripts/test-request-password-reset.cjs old mode 100644 new mode 100755 diff --git a/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx index cb8f1ead..061f283a 100644 --- a/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx +++ b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx @@ -3,11 +3,7 @@ import React, { useState } from "react"; import { apiClient } from "@/lib/api"; import { XMarkIcon } from "@heroicons/react/24/outline"; -import { - SIM_PLAN_OPTIONS, - type SimPlanCode, - getSimPlanLabel, -} from "@customer-portal/domain/sim"; +import { mapToSimplifiedFormat } from "../utils/plan"; interface ChangePlanModalProps { subscriptionId: number; @@ -24,9 +20,16 @@ export function ChangePlanModal({ onSuccess, onError, }: ChangePlanModalProps) { - const allowedPlans = SIM_PLAN_OPTIONS.filter(option => option.code !== currentPlanCode); + const PLAN_CODES = ["5GB", "10GB", "25GB", "50GB"] as const; + type PlanCode = (typeof PLAN_CODES)[number]; - const [newPlanCode, setNewPlanCode] = useState<"" | SimPlanCode>(""); + const normalizedCurrentPlan = mapToSimplifiedFormat(currentPlanCode); + + const allowedPlans = (PLAN_CODES as readonly PlanCode[]).filter( + code => code !== (normalizedCurrentPlan as PlanCode) + ); + + const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>(""); const [loading, setLoading] = useState(false); const submit = async () => { @@ -78,13 +81,13 @@ export function ChangePlanModal({ diff --git a/apps/portal/src/features/sim-management/components/DataUsageChart.tsx b/apps/portal/src/features/sim-management/components/DataUsageChart.tsx index c0a8fa56..52d61da2 100644 --- a/apps/portal/src/features/sim-management/components/DataUsageChart.tsx +++ b/apps/portal/src/features/sim-management/components/DataUsageChart.tsx @@ -2,7 +2,18 @@ import React from "react"; import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; -import type { SimUsage } from "@customer-portal/domain/sim"; + +export interface SimUsage { + account: string; + todayUsageKb: number; + todayUsageMb: number; + recentDaysUsage: Array<{ + date: string; + usageKb: number; + usageMb: number; + }>; + isBlacklisted: boolean; +} interface DataUsageChartProps { usage: SimUsage; @@ -210,19 +221,6 @@ export function DataUsageChart({ )} {/* Warnings */} - {usage.isBlacklisted && ( -
-
- -
-

Service Restricted

-

- This SIM is currently blacklisted. Please contact support for assistance. -

-
-
-
- )} {usagePercentage >= 90 && (
diff --git a/apps/portal/src/features/sim-management/components/ReissueSimModal.tsx b/apps/portal/src/features/sim-management/components/ReissueSimModal.tsx new file mode 100644 index 00000000..653fd01b --- /dev/null +++ b/apps/portal/src/features/sim-management/components/ReissueSimModal.tsx @@ -0,0 +1,221 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import { ArrowPathIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { simActionsService } from "@/features/subscriptions/services/sim-actions.service"; + +type SimKind = "physical" | "esim"; + +interface ReissueSimModalProps { + subscriptionId: number; + currentSimType: SimKind; + onClose: () => void; + onSuccess: () => void; + onError: (message: string) => void; +} + +const IMPORTANT_POINTS: string[] = [ + "The reissue request cannot be reversed.", + "Service to the existing SIM will be terminated with immediate effect.", + "A fee of 1,500 yen + tax will be incurred.", + "For physical SIM: allow approximately 3-5 business days for shipping.", + "For eSIM: activation typically completes within 30-60 minutes after processing.", +]; + +const EID_HELP = "Enter the 32-digit EID (numbers only). Leave blank to reuse Freebit's generated EID."; + +export function ReissueSimModal({ + subscriptionId, + currentSimType, + onClose, + onSuccess, + onError, +}: ReissueSimModalProps) { + const [selectedSimType, setSelectedSimType] = useState(currentSimType); + const [newEid, setNewEid] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [validationError, setValidationError] = useState(null); + + const isEsimSelected = selectedSimType === "esim"; + const isPhysicalSelected = selectedSimType === "physical"; + + const disableSubmit = useMemo(() => { + if (isPhysicalSelected) { + return false; // Allow click to show guidance message + } + if (!isEsimSelected) { + return true; + } + if (!newEid) { + return false; // Optional – backend supports auto EID + } + return !/^\d{32}$/.test(newEid.trim()); + }, [isPhysicalSelected, isEsimSelected, newEid]); + + const handleSubmit = async () => { + if (isPhysicalSelected) { + setValidationError( + "Physical SIM reissue cannot be requested online yet. Please contact support for assistance." + ); + return; + } + + if (isEsimSelected && newEid && !/^\d{32}$/.test(newEid.trim())) { + setValidationError("EID must be 32 digits."); + return; + } + + setValidationError(null); + setSubmitting(true); + try { + await simActionsService.reissueEsim(String(subscriptionId), { + newEid: newEid.trim() || undefined, + }); + onSuccess(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Failed to submit reissue request"; + onError(message); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ + ); +} diff --git a/apps/portal/src/features/sim-management/components/SimActions.tsx b/apps/portal/src/features/sim-management/components/SimActions.tsx index a3b5360e..ec139f7d 100644 --- a/apps/portal/src/features/sim-management/components/SimActions.tsx +++ b/apps/portal/src/features/sim-management/components/SimActions.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { PlusIcon, @@ -11,18 +11,13 @@ import { } from "@heroicons/react/24/outline"; import { TopUpModal } from "./TopUpModal"; import { ChangePlanModal } from "./ChangePlanModal"; +import { ReissueSimModal } from "./ReissueSimModal"; import { apiClient } from "@/lib/api"; -import { - canTopUpSim, - canReissueEsim, - canCancelSim, - type SimStatus, -} from "@customer-portal/domain/sim"; interface SimActionsProps { subscriptionId: number; simType: "physical" | "esim"; - status: SimStatus; + status: string; onTopUpSuccess?: () => void; onPlanChangeSuccess?: () => void; onCancelSuccess?: () => void; @@ -45,7 +40,7 @@ export function SimActions({ const router = useRouter(); const [showTopUpModal, setShowTopUpModal] = useState(false); const [showCancelConfirm, setShowCancelConfirm] = useState(false); - const [showReissueConfirm, setShowReissueConfirm] = useState(false); + const [showReissueModal, setShowReissueModal] = useState(false); const [loading, setLoading] = useState(null); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); @@ -54,29 +49,17 @@ export function SimActions({ "topup" | "reissue" | "cancel" | "changePlan" | null >(null); - const isActiveStatus = canTopUpSim(status); - const canTopUp = isActiveStatus; - const canReissue = simType === "esim" && canReissueEsim(status); - const canCancel = canCancelSim(status); + const isActive = status === "active"; + const canTopUp = isActive; + const canReissue = isActive; + const canCancel = isActive; - const handleReissueEsim = async () => { - setLoading("reissue"); - setError(null); - - try { - await apiClient.POST("/api/subscriptions/{id}/sim/reissue-esim", { - params: { path: { id: subscriptionId } }, - }); - - setSuccess("eSIM profile reissued successfully"); - setShowReissueConfirm(false); - onReissueSuccess?.(); - } catch (error: unknown) { - setError(error instanceof Error ? error.message : "Failed to reissue eSIM profile"); - } finally { - setLoading(null); + const reissueDisabledReason = useMemo(() => { + if (!isActive) { + return "SIM must be active to request a reissue."; } - }; + return null; + }, [isActive]); const handleCancelSim = async () => { setLoading("cancel"); @@ -85,6 +68,7 @@ export function SimActions({ try { await apiClient.POST("/api/subscriptions/{id}/sim/cancel", { params: { path: { id: subscriptionId } }, + body: {}, }); setSuccess("SIM service cancelled successfully"); @@ -112,32 +96,17 @@ export function SimActions({ return (
{/* Header */} -
-
-
- - - -
-
-

SIM Management Actions

-

Manage your SIM service

-
+ {!embedded && ( +
+

+ SIM Management Actions +

+

Manage your SIM service

-
+ )} {/* Content */}
@@ -160,7 +129,7 @@ export function SimActions({
)} - {!isActiveStatus && ( + {!isActive && (
@@ -172,7 +141,7 @@ export function SimActions({ )} {/* Action Buttons */} -
+
{/* Top Up Data - Primary Action */} - - {/* Reissue eSIM (only for eSIMs) */} - {simType === "esim" && ( - - )} - - {/* Cancel SIM - Destructive Action */} - @@ -261,30 +180,81 @@ export function SimActions({ setShowChangePlanModal(true); } }} - disabled={loading !== null} - className={`group relative flex items-center justify-center px-6 py-4 border-2 border-dashed rounded-xl text-sm font-semibold transition-all duration-200 ${ - loading === null - ? "text-purple-700 bg-purple-50 border-purple-300 hover:bg-purple-100 hover:border-purple-400 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500" - : "text-gray-400 bg-gray-50 border-gray-300 cursor-not-allowed" + disabled={!isActive || loading !== null} + className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${ + isActive && loading === null + ? "text-slate-700 bg-slate-100 hover:bg-slate-200 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 active:scale-[0.98]" + : "text-gray-400 bg-gray-100 cursor-not-allowed" }`} >
-
- - - + +
+
+ {loading === "change-plan" ? "Processing..." : "Change Plan"} +
+
Switch to a different plan
+
+
+ + + {/* Reissue SIM */} + + + {/* Cancel SIM - Destructive Action */} +
@@ -305,8 +275,7 @@ export function SimActions({
- Reissue eSIM: Generate a new eSIM profile for download. Use this - if your previous download failed or you need to install on a new device. + Reissue SIM: Submit a replacement request for either a physical SIM or an eSIM. eSIM users can optionally supply a new EID to pair with the replacement profile.
)} @@ -382,54 +351,24 @@ export function SimActions({ /> )} - {/* Reissue eSIM Confirmation */} - {showReissueConfirm && ( -
-
-
-
-
-
-
- -
-
-

- Reissue eSIM Profile -

-
-

- This will generate a new eSIM profile for download. Your current eSIM will - remain active until you activate the new profile. -

-
-
-
-
-
- - -
-
-
-
+ {/* Reissue SIM Modal */} + {showReissueModal && ( + { + setShowReissueModal(false); + setActiveInfo(null); + }} + onSuccess={() => { + setShowReissueModal(false); + setSuccess("SIM reissue request submitted successfully"); + onReissueSuccess?.(); + }} + onError={message => { + setError(message); + }} + /> )} {/* Cancel Confirmation */} diff --git a/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx b/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx index 2cea8308..f2c93401 100644 --- a/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx +++ b/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx @@ -1,211 +1,430 @@ "use client"; import React from "react"; -import { formatSimPlanShort } from "@customer-portal/domain/sim"; import { DevicePhoneMobileIcon, + WifiIcon, + SignalIcon, + ClockIcon, CheckCircleIcon, ExclamationTriangleIcon, XCircleIcon, - ClockIcon, } from "@heroicons/react/24/outline"; -import type { SimDetails, SimStatus } from "@customer-portal/domain/sim"; + +// Inline formatPlanShort function +function formatPlanShort(planCode?: string): string { + if (!planCode) return "—"; + const m = planCode.match(/(?:^|[_-])(\d+(?:\.\d+)?)\s*G(?:B)?\b/i); + if (m && m[1]) { + return `${m[1]}G`; + } + // Try extracting trailing number+G anywhere in the string + const m2 = planCode.match(/(\d+(?:\.\d+)?)\s*G(?:B)?\b/i); + if (m2 && m2[1]) { + return `${m2[1]}G`; + } + return planCode; +} + +export interface SimDetails { + account: string; + msisdn: string; + iccid?: string; + imsi?: string; + eid?: string; + planCode: string; + status: "active" | "suspended" | "cancelled" | "pending"; + simType: "physical" | "esim"; + size: "standard" | "nano" | "micro" | "esim"; + hasVoice: boolean; + hasSms: boolean; + remainingQuotaKb: number; + remainingQuotaMb: number; + startDate?: string; + ipv4?: string; + ipv6?: string; + voiceMailEnabled?: boolean; + callWaitingEnabled?: boolean; + internationalRoamingEnabled?: boolean; + networkType?: string; + pendingOperations?: Array<{ + operation: string; + scheduledDate: string; + }>; +} interface SimDetailsCardProps { simDetails: SimDetails; isLoading?: boolean; error?: string | null; - embedded?: boolean; - showFeaturesSummary?: boolean; + embedded?: boolean; // when true, render content without card container + showFeaturesSummary?: boolean; // show the right-side Service Features summary } -const STATUS_ICON_MAP: Record = { - active: , - suspended: , - cancelled: , - pending: , -}; - -const STATUS_BADGE_CLASS_MAP: Record = { - active: "bg-green-100 text-green-800", - suspended: "bg-yellow-100 text-yellow-800", - cancelled: "bg-red-100 text-red-800", - pending: "bg-blue-100 text-blue-800", -}; - -const formatDate = (value?: string | null) => { - if (!value) return "-"; - const date = new Date(value); - return Number.isNaN(date.getTime()) - ? value - : date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }); -}; - -const formatQuota = (remainingMb: number) => { - if (remainingMb >= 1000) { - return `${(remainingMb / 1000).toFixed(1)} GB`; - } - return `${remainingMb.toFixed(0)} MB`; -}; - -const FeatureToggleRow = ({ label, enabled }: { label: string; enabled: boolean }) => ( -
- {label} - - {enabled ? "Enabled" : "Disabled"} - -
-); - -const LoadingCard = ({ embedded }: { embedded: boolean }) => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-); - -const ErrorCard = ({ embedded, message }: { embedded: boolean; message: string }) => ( -
-
{message}
-
-); - export function SimDetailsCard({ simDetails, - isLoading = false, - error = null, + isLoading, + error, embedded = false, showFeaturesSummary = true, }: SimDetailsCardProps) { + const formatPlan = (code?: string) => { + const formatted = formatPlanShort(code); + // Remove "PASI" prefix if present + return formatted?.replace(/^PASI\s*/, "") || formatted; + }; + const getStatusIcon = (status: string) => { + switch (status) { + case "active": + return ; + case "suspended": + return ; + case "cancelled": + return ; + case "pending": + return ; + default: + return ; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case "active": + return "bg-green-100 text-green-800"; + case "suspended": + return "bg-yellow-100 text-yellow-800"; + case "cancelled": + return "bg-red-100 text-red-800"; + case "pending": + return "bg-blue-100 text-blue-800"; + default: + return "bg-gray-100 text-gray-800"; + } + }; + + const formatDate = (dateString: string) => { + try { + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + } catch { + return dateString; + } + }; + + const formatQuota = (quotaMb: number) => { + if (quotaMb >= 1000) { + return `${(quotaMb / 1000).toFixed(1)} GB`; + } + return `${quotaMb.toFixed(0)} MB`; + }; + if (isLoading) { - return ; + const Skeleton = ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + return Skeleton; } if (error) { - return ; + return ( +
+
+
+ +
+

Error Loading SIM Details

+

{error}

+
+
+ ); } - const planName = simDetails.planName || formatSimPlanShort(simDetails.planCode) || "SIM Plan"; - const statusIcon = STATUS_ICON_MAP[simDetails.status] ?? ( - - ); - const statusClass = STATUS_BADGE_CLASS_MAP[simDetails.status] ?? "bg-gray-100 text-gray-800"; - const containerClasses = embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100"; + // Modern eSIM details view with usage visualization + if (simDetails.simType === "esim") { + const remainingGB = simDetails.remainingQuotaMb / 1000; + const totalGB = 1048.6; // Mock total - should come from API + const usedGB = totalGB - remainingGB; + const usagePercentage = (usedGB / totalGB) * 100; - return ( -
-
-
-
- -
-
-

{planName}

-

Account #{simDetails.account}

+ // Usage Sparkline Component + const UsageSparkline = ({ data }: { data: Array<{ date: string; usedMB: number }> }) => { + const maxValue = Math.max(...data.map(d => d.usedMB), 1); + const width = 80; + const height = 16; + + const points = data.map((d, i) => { + const x = (i / (data.length - 1)) * width; + const y = height - (d.usedMB / maxValue) * height; + return `${x},${y}`; + }).join(' '); + + return ( + + + + ); + }; + + // Usage Donut Component + const UsageDonut = ({ size = 120 }: { size?: number }) => { + const radius = (size - 16) / 2; + const circumference = 2 * Math.PI * radius; + const strokeDashoffset = circumference - (usagePercentage / 100) * circumference; + + return ( +
+ + + + +
+
{remainingGB.toFixed(1)}
+
GB remaining
+
{usagePercentage.toFixed(1)}% used
+
+
+ ); + }; + + return ( +
+ {/* Compact Header Bar */} +
+
+
+ + {simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)} + + + {formatPlan(simDetails.planCode)} + +
+
+
{simDetails.msisdn}
+
+ +
+ {/* Usage Visualization */} +
+ +
+ +
+

Recent Usage History

+
+ {[ + { date: "Sep 29", usage: "0 MB" }, + { date: "Sep 28", usage: "0 MB" }, + { date: "Sep 27", usage: "0 MB" }, + ].map((entry, index) => ( +
+ {entry.date} + {entry.usage} +
+ ))} +
+
+ +
+
+ ); + } + + // Default view for physical SIM cards + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+

Physical SIM Details

+

+ {formatPlan(simDetails.planCode)} • {`${simDetails.size} SIM`} +

+
+
+
+ {getStatusIcon(simDetails.status)} + + {simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)} +
- - {statusIcon} - {simDetails.status} -
-
-
-
-

+ {/* Content */} +
+
+ {/* SIM Information */} +
+

SIM Information

-
-
-
Phone Number
-
{simDetails.msisdn}
+
+
+ +

{simDetails.msisdn}

-
-
SIM Type
-
{simDetails.simType}
-
-
-
ICCID
-
{simDetails.iccid}
-
- {simDetails.eid && ( -
-
EID
-
{simDetails.eid}
+ + {simDetails.simType === "physical" && ( +
+ +

{simDetails.iccid}

)} -
-
Network Type
-
{simDetails.networkType}
-
-
-

-
-

- Data Remaining -

-

- {formatQuota(simDetails.remainingQuotaMb)} -

-

Remaining allowance in current cycle

-
+ {simDetails.eid && ( +
+ +

{simDetails.eid}

+
+ )} -
-

- Activation Timeline -

-
-
-
Activated
-
{formatDate(simDetails.activatedAt)}
+ {simDetails.imsi && ( +
+ +

{simDetails.imsi}

+
+ )} + + {simDetails.startDate && ( +
+ +

{formatDate(simDetails.startDate)}

+
+ )} +
+
+ + {/* Service Features */} + {showFeaturesSummary && ( +
+

+ Service Features +

+
+
+ +

+ {formatQuota(simDetails.remainingQuotaMb)} +

+
+ +
+
+ + + Voice {simDetails.hasVoice ? "Enabled" : "Disabled"} + +
+
+ + + SMS {simDetails.hasSms ? "Enabled" : "Disabled"} + +
+
+ + {(simDetails.ipv4 || simDetails.ipv6) && ( +
+ +
+ {simDetails.ipv4 && ( +

IPv4: {simDetails.ipv4}

+ )} + {simDetails.ipv6 && ( +

IPv6: {simDetails.ipv6}

+ )} +
+
+ )}
-
-
Expires
-
{formatDate(simDetails.expiresAt)}
-
- - +
+ )}
- {showFeaturesSummary && ( -
-

- Service Features + {/* Pending Operations */} + {simDetails.pendingOperations && simDetails.pendingOperations.length > 0 && ( +
+

+ Pending Operations

- - - -

+
+ {simDetails.pendingOperations.map((operation, index) => ( +
+ + + {operation.operation} scheduled for {formatDate(operation.scheduledDate)} + +
+ ))} +
+
)}
); } - -export type { SimDetails }; diff --git a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx index 1b9f44cd..f44d61b3 100644 --- a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx +++ b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx @@ -1,11 +1,7 @@ "use client"; -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { apiClient } from "@/lib/api"; -import { - buildSimFeaturesUpdatePayload, - type SimFeatureToggleSnapshot, -} from "@customer-portal/domain/sim"; interface SimFeatureTogglesProps { subscriptionId: number; @@ -27,42 +23,37 @@ export function SimFeatureToggles({ embedded = false, }: SimFeatureTogglesProps) { // Initial values - const initial = useMemo( + const initial = useMemo( () => ({ - voiceMailEnabled: !!voiceMailEnabled, - callWaitingEnabled: !!callWaitingEnabled, - internationalRoamingEnabled: !!internationalRoamingEnabled, - networkType: networkType === "5G" ? "5G" : "4G", + vm: !!voiceMailEnabled, + cw: !!callWaitingEnabled, + ir: !!internationalRoamingEnabled, + nt: networkType === "5G" ? "5G" : "4G", }), [voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType] ); // Working values - const [vm, setVm] = useState(initial.voiceMailEnabled); - const [cw, setCw] = useState(initial.callWaitingEnabled); - const [ir, setIr] = useState(initial.internationalRoamingEnabled); - const [nt, setNt] = useState<"4G" | "5G">(initial.networkType); + const [vm, setVm] = useState(initial.vm); + const [cw, setCw] = useState(initial.cw); + const [ir, setIr] = useState(initial.ir); + const [nt, setNt] = useState<"4G" | "5G">(initial.nt as "4G" | "5G"); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const successTimerRef = useRef(null); useEffect(() => { - setVm(initial.voiceMailEnabled); - setCw(initial.callWaitingEnabled); - setIr(initial.internationalRoamingEnabled); - setNt(initial.networkType); - }, [initial]); + setVm(initial.vm); + setCw(initial.cw); + setIr(initial.ir); + setNt(initial.nt as "4G" | "5G"); + }, [initial.vm, initial.cw, initial.ir, initial.nt]); const reset = () => { - if (successTimerRef.current) { - clearTimeout(successTimerRef.current); - successTimerRef.current = null; - } - setVm(initial.voiceMailEnabled); - setCw(initial.callWaitingEnabled); - setIr(initial.internationalRoamingEnabled); - setNt(initial.networkType); + setVm(initial.vm); + setCw(initial.cw); + setIr(initial.ir); + setNt(initial.nt as "4G" | "5G"); setError(null); setSuccess(null); }; @@ -72,21 +63,22 @@ export function SimFeatureToggles({ setError(null); setSuccess(null); try { - const featurePayload = buildSimFeaturesUpdatePayload(initial, { - voiceMailEnabled: vm, - callWaitingEnabled: cw, - internationalRoamingEnabled: ir, - networkType: nt, - }); + const featurePayload: { + voiceMailEnabled?: boolean; + callWaitingEnabled?: boolean; + internationalRoamingEnabled?: boolean; + networkType?: "4G" | "5G"; + } = {}; + if (vm !== initial.vm) featurePayload.voiceMailEnabled = vm; + if (cw !== initial.cw) featurePayload.callWaitingEnabled = cw; + if (ir !== initial.ir) featurePayload.internationalRoamingEnabled = ir; + if (nt !== initial.nt) featurePayload.networkType = nt; - if (featurePayload) { + if (Object.keys(featurePayload).length > 0) { await apiClient.POST("/api/subscriptions/{id}/sim/features", { params: { path: { id: subscriptionId } }, body: featurePayload, }); - } else { - setSuccess("No changes detected"); - return; } setSuccess("Changes submitted successfully"); @@ -95,224 +87,132 @@ export function SimFeatureToggles({ setError(e instanceof Error ? e.message : "Failed to submit changes"); } finally { setLoading(false); - if (successTimerRef.current) { - clearTimeout(successTimerRef.current); - } - successTimerRef.current = window.setTimeout(() => { - setSuccess(null); - successTimerRef.current = null; - }, 3000); + setTimeout(() => setSuccess(null), 3000); } }; - useEffect(() => { - return () => { - if (successTimerRef.current) { - clearTimeout(successTimerRef.current); - successTimerRef.current = null; - } - }; - }, []); - return (
{/* Service Options */} -
-
+
+
{/* Voice Mail */} -
+
-
-
- - - -
-
-
Voice Mail
-
¥300/month
-
-
-
-
-
- Current: - - {initial.voiceMailEnabled ? "Enabled" : "Disabled"} - -
-
- +
Voice Mail
+
¥300/month
+
{/* Call Waiting */} -
+
-
-
- - - -
-
-
Call Waiting
-
¥300/month
-
-
-
-
-
- Current: - - {initial.callWaitingEnabled ? "Enabled" : "Disabled"} - -
-
- +
Call Waiting
+
¥300/month
+
{/* International Roaming */} -
+
-
-
- - - -
-
-
International Roaming
-
Global connectivity
-
-
-
-
-
- Current: - - {initial.internationalRoamingEnabled ? "Enabled" : "Disabled"} - -
-
- +
International Roaming
+
Global connectivity
+
- {/* Network Type */} -
-
-
-
- - - -
-
-
Network Type
-
4G/5G connectivity
-
+
+
+
Network Type
+
Choose your preferred connectivity
+
+ Voice, network, and plan changes must be requested at least 30 minutes apart. If you just changed another option, you may need to wait before submitting.
-
-
- Current: - {initial.networkType} +
+
+ setNt("4G")} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" + /> + +
+
+ setNt("5G")} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" + /> +
-
-
+

5G connectivity for enhanced speeds

{/* Notes and Actions */}
-
-
- +
+

+ -
-

- Important Notes: -

-
    -
  • Changes will take effect instantaneously (approx. 30min)
  • -
  • May require smartphone/device restart after changes are applied
  • -
  • 5G requires a compatible smartphone/device. Will not function on 4G devices
  • -
  • - Changes to Voice Mail / Call Waiting must be requested before the 25th of the - month -
  • -
-
-

+ Important Notes + +
    +
  • + + Changes will take effect instantaneously (approx. 30min) +
  • +
  • + + May require smartphone device restart after changes are applied +
  • +
  • + + Voice, network, and plan changes must be requested at least 30 minutes apart. +
  • +
  • + + Changes to Voice Mail / Call Waiting must be requested before the 25th of the month +
  • +
{success && ( diff --git a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx index d2413320..481e9fc8 100644 --- a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx @@ -1,84 +1,62 @@ "use client"; -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { DevicePhoneMobileIcon, ExclamationTriangleIcon, ArrowPathIcon, + DocumentTextIcon, } from "@heroicons/react/24/outline"; -import { SimDetailsCard } from "./SimDetailsCard"; -import { DataUsageChart } from "./DataUsageChart"; -import { SimActions } from "./SimActions"; +import { type SimDetails } from "./SimDetailsCard"; import { apiClient } from "@/lib/api"; -import { SimFeatureToggles } from "./SimFeatureToggles"; -import { simInfoSchema, type SimInfo } from "@customer-portal/domain/sim"; +import Link from "next/link"; interface SimManagementSectionProps { subscriptionId: number; } +interface SimInfo { + details: SimDetails; + usage?: { + todayUsageMb: number; + recentDaysUsage: Array<{ date: string; usageMb: number }>; + }; +} + export function SimManagementSection({ subscriptionId }: SimManagementSectionProps) { const [simInfo, setSimInfo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const abortControllerRef = useRef(null); - const isMountedRef = useRef(true); - - useEffect(() => { - return () => { - isMountedRef.current = false; - abortControllerRef.current?.abort(); - }; - }, []); + const [activeTab, setActiveTab] = useState<"sim" | "invoices">("sim"); const fetchSimInfo = useCallback(async () => { - abortControllerRef.current?.abort(); - const controller = new AbortController(); - abortControllerRef.current = controller; - - if (isMountedRef.current) { - setLoading(true); - setError(null); - } - try { - const response = await apiClient.GET("/api/subscriptions/{id}/sim", { + setError(null); + + const response = await apiClient.GET("/api/subscriptions/{id}/sim", { params: { path: { id: subscriptionId } }, - signal: controller.signal, }); - if (!response.data) { + const payload = response.data as { details: SimDetails; usage: any } | undefined; + + if (!payload) { throw new Error("Failed to load SIM information"); } - const payload = simInfoSchema.parse(response.data); - - if (controller.signal.aborted || !isMountedRef.current) { - return; - } - setSimInfo(payload); } catch (err: unknown) { - if (controller.signal.aborted || !isMountedRef.current) { - return; - } - const hasStatus = (value: unknown): value is { status: number } => - typeof value === "object" && - value !== null && - "status" in value && - typeof (value as { status: unknown }).status === "number"; - + const hasStatus = (v: unknown): v is { status: number } => + typeof v === "object" && + v !== null && + "status" in v && + typeof (v as { status: unknown }).status === "number"; if (hasStatus(err) && err.status === 400) { - // Not a SIM subscription - this component shouldn't be shown setError("This subscription is not a SIM service"); - return; + } else { + setError(err instanceof Error ? err.message : "Failed to load SIM information"); } - - setError(err instanceof Error ? err.message : "Failed to load SIM information"); } finally { - if (!controller.signal.aborted && isMountedRef.current) { - setLoading(false); - } + setLoading(false); } }, [subscriptionId]); @@ -87,35 +65,41 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro }, [fetchSimInfo]); const handleRefresh = () => { + setLoading(true); void fetchSimInfo(); }; const handleActionSuccess = () => { - // Refresh SIM info after any successful action void fetchSimInfo(); }; if (loading) { return ( -
-
-
-
- -
-
-

SIM Management

-

Loading your SIM service details...

+
+ {/* Header */} +
+
+

Service Management

+
+
+ SIM Management +
+
+ Invoices +
-
-
-
-
-
-
-
+
+ + {/* Loading Animation */} +
+
+
+
+
+
+
@@ -124,27 +108,19 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro if (error) { return ( -
-
-
- -
-
-

SIM Management

-

Unable to load SIM information

-
+
+
+

Service Management

-
+
-

- Unable to Load SIM Information -

+

Unable to Load SIM Information

{error}

+ + Invoices + +
+
+
+ + {/* Content */} +
+ {/* 2. Billing Information Section */} +
+
+
+

Monthly Cost

+

¥3,100

+
+
+

Next Billing

+

Jul 1 2025

+
+
+

Registered

+

Aug 2 2023

- {/* Sidebar - Compact Info (Right Side) */} -
- {/* Details + Usage combined card for mobile-first */} -
- - -
- - {/* Important Information Card */} -
-
-
- - - -
-

Important Information

+ {/* 3. Invoice & Data Usage Row */} +
+ {/* Left Column - Invoice Card */} +
+
+

Latest Invoice

+

3400 ¥

-
    -
  • - - Data usage is updated in real-time and may take a few minutes to reflect recent - activity -
  • -
  • - - Top-up data will be available immediately after successful processing -
  • -
  • - - SIM cancellation is permanent and cannot be undone -
  • - {simInfo.details.simType === "esim" && ( -
  • - - eSIM profile reissue will provide a new QR code for activation -
  • - )} -
+
- {/* (On desktop, details+usage are above; on mobile they appear first since this section is above actions) */} + {/* Right Column - Data Usage Circle */} +
+

Remaining data

+
+ + + + +
+

{remainingGB.toFixed(1)} GB

+

–{usedMB.toFixed(2)} GB

+
+
+
+
+ + {/* 4. Top Up Button */} + + + {/* 5. SIM Management Actions Section */} +
+

SIM Management Actions

+
+ + + + +
+
+ + {/* 6. Voice Status Section */} +
+

Voice Status

+
+ + + + +
+
+ + {/* 7. Important Notes Section */} +
+

Important Notes

+
    +
  • + + Changes to SIM settings typically take effect instantaneously (approx. 30min) +
  • +
  • + + May require smartphone device restart after changes are applied +
  • +
  • + + Voice/Network/Plan change requests must be requested at least 30 minutes apart +
  • +
  • + + Changes to Voice Mail / Call Waiting must be requested before the 25th of the month +
  • +
diff --git a/apps/portal/src/features/sim-management/components/TopUpModal.tsx b/apps/portal/src/features/sim-management/components/TopUpModal.tsx index d8e87141..658e0b54 100644 --- a/apps/portal/src/features/sim-management/components/TopUpModal.tsx +++ b/apps/portal/src/features/sim-management/components/TopUpModal.tsx @@ -1,13 +1,8 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { apiClient } from "@/lib/api"; -import { useSimTopUpPricing } from "../hooks/useSimTopUpPricing"; -import { - simTopUpRequestSchema, - type SimTopUpPricingPreviewResponse, -} from "@customer-portal/domain/sim"; interface TopUpModalProps { subscriptionId: number; @@ -19,22 +14,6 @@ interface TopUpModalProps { export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) { const [gbAmount, setGbAmount] = useState("1"); const [loading, setLoading] = useState(false); - const { pricing, loading: pricingLoading, calculatePreview } = useSimTopUpPricing(); - const [preview, setPreview] = useState(null); - - // Update preview when gbAmount changes - useEffect(() => { - const updatePreview = async () => { - const mb = parseInt(gbAmount, 10) * 1000; - if (!isNaN(mb) && mb > 0) { - const result = await calculatePreview(mb); - setPreview(result); - } else { - setPreview(null); - } - }; - void updatePreview(); - }, [gbAmount, calculatePreview]); const getCurrentAmountMb = () => { const gb = parseInt(gbAmount, 10); @@ -42,39 +21,35 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU }; const isValidAmount = () => { - if (!pricing) return false; - const mb = getCurrentAmountMb(); - return mb >= pricing.minQuotaMb && mb <= pricing.maxQuotaMb; + const gb = Number(gbAmount); + return Number.isInteger(gb) && gb >= 1 && gb <= 50; // 1-50 GB, whole numbers only (Freebit API limit) }; - const displayCost = preview?.totalPriceJpy ?? 0; - const pricePerGb = pricing?.pricePerGbJpy ?? 500; + const calculateCost = () => { + const gb = parseInt(gbAmount, 10); + return isNaN(gb) ? 0 : gb * 500; // 1GB = 500 JPY + }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!isValidAmount()) { - onError( - `Please enter a valid amount between ${pricing ? pricing.minQuotaMb / 1000 : 1} GB and ${pricing ? pricing.maxQuotaMb / 1000 : 50} GB` - ); + onError("Please enter a whole number between 1 GB and 100 GB"); return; } setLoading(true); try { - const validationResult = simTopUpRequestSchema.safeParse({ + const requestBody = { quotaMb: getCurrentAmountMb(), - }); - - if (!validationResult.success) { - onError(validationResult.error.issues[0]?.message ?? "Invalid top-up amount"); - return; - } + amount: calculateCost(), + currency: "JPY", + }; await apiClient.POST("/api/subscriptions/{id}/sim/top-up", { params: { path: { id: subscriptionId } }, - body: validationResult.data, + body: requestBody, }); onSuccess(); @@ -137,9 +112,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU

- Enter the amount of data you want to add ( - {pricing ? `${pricing.minQuotaMb / 1000} - ${pricing.maxQuotaMb / 1000}` : "1 - 50"}{" "} - GB, whole numbers) + Enter the amount of data you want to add (1 - 50 GB, whole numbers)

@@ -154,21 +127,20 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
- ¥{displayCost.toLocaleString()} + ¥{calculateCost().toLocaleString()}
-
(1GB = ¥{pricePerGb})
+
(1GB = ¥500)
{/* Validation Warning */} - {!isValidAmount() && gbAmount && pricing && ( + {!isValidAmount() && gbAmount && (

- Amount must be between {pricing.minQuotaMb / 1000} GB and{" "} - {pricing.maxQuotaMb / 1000} GB + Amount must be a whole number between 1 GB and 50 GB

@@ -186,14 +158,10 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
diff --git a/apps/portal/src/features/sim-management/utils/plan.ts b/apps/portal/src/features/sim-management/utils/plan.ts new file mode 100644 index 00000000..f3978053 --- /dev/null +++ b/apps/portal/src/features/sim-management/utils/plan.ts @@ -0,0 +1,62 @@ +// Generic plan code formatter for SIM plans +// Examples: +// - PASI_10G -> 10G +// - PASI_25G -> 25G +// - ANY_PREFIX_50GB -> 50G +// - Fallback: return the original code when unknown + +export function formatPlanShort(planCode?: string): string { + if (!planCode) return "—"; + const m = planCode.match(/(?:^|[_-])(\d+(?:\.\d+)?)\s*G(?:B)?\b/i); + if (m && m[1]) { + return `${m[1]}G`; + } + // Try extracting trailing number+G anywhere in the string + const m2 = planCode.match(/(\d+(?:\.\d+)?)\s*G(?:B)?\b/i); + if (m2 && m2[1]) { + return `${m2[1]}G`; + } + return planCode; +} + +// Mapping between Freebit plan codes and Salesforce product SKUs used by the portal +export const SIM_PLAN_SKU_BY_CODE: Record = { + PASI_5G: "SIM-DATA-VOICE-5GB", + PASI_10G: "SIM-DATA-VOICE-10GB", + PASI_25G: "SIM-DATA-VOICE-25GB", + PASI_50G: "SIM-DATA-VOICE-50GB", + PASI_5G_DATA: "SIM-DATA-ONLY-5GB", + PASI_10G_DATA: "SIM-DATA-ONLY-10GB", + PASI_25G_DATA: "SIM-DATA-ONLY-25GB", + PASI_50G_DATA: "SIM-DATA-ONLY-50GB", +}; + +export function getSimPlanSku(planCode?: string): string | undefined { + if (!planCode) return undefined; + return SIM_PLAN_SKU_BY_CODE[planCode]; +} + +/** + * Map Freebit plan codes to simplified format for API requests + * Converts PASI_5G -> 5GB, PASI_25G -> 25GB, etc. + */ +export function mapToSimplifiedFormat(planCode?: string): string { + if (!planCode) return ""; + + // Handle Freebit format (PASI_5G, PASI_25G, etc.) + if (planCode.startsWith("PASI_")) { + const match = planCode.match(/PASI_(\d+)G/); + if (match) { + return `${match[1]}GB`; + } + } + + // Handle other formats that might end with G or GB + const match = planCode.match(/(\d+)\s*G(?:B)?\b/i); + if (match) { + return `${match[1]}GB`; + } + + // Return as-is if no pattern matches + return planCode; +} diff --git a/scripts/bundle-analyze.sh b/scripts/bundle-analyze.sh old mode 100644 new mode 100755 diff --git a/scripts/dev/manage.sh b/scripts/dev/manage.sh old mode 100644 new mode 100755 diff --git a/scripts/migrate-imports.sh b/scripts/migrate-imports.sh old mode 100644 new mode 100755 diff --git a/scripts/plesk-deploy.sh b/scripts/plesk-deploy.sh old mode 100644 new mode 100755 diff --git a/scripts/plesk/build-images.sh b/scripts/plesk/build-images.sh old mode 100644 new mode 100755 diff --git a/scripts/prod/manage.sh b/scripts/prod/manage.sh old mode 100644 new mode 100755 diff --git a/scripts/set-log-level.sh b/scripts/set-log-level.sh old mode 100644 new mode 100755 diff --git a/scripts/validate-deps.sh b/scripts/validate-deps.sh old mode 100644 new mode 100755