Enhance Freebit integration and improve error handling

- Added FreebitMapperService to facilitate account normalization and improve code organization.
- Updated FreebitAuthService to streamline error handling and response parsing, replacing custom exceptions with standard error messages.
- Enhanced FreebitClientService to ensure proper URL construction and improved logging for API errors.
- Refactored FreebitOperationsService to include new request types and validation, ensuring better handling of SIM operations.
- Updated FreebitOrchestratorService to utilize the new mapper for account normalization across various methods.
- Improved SIM management features in the portal, including better handling of SIM details and usage information.
- Refactored components to enhance user experience and maintainability, including updates to the ChangePlanModal and SimActions components.
This commit is contained in:
tema 2025-11-21 18:41:14 +09:00
parent c8bd3a73d7
commit 9c796f59da
32 changed files with 2750 additions and 1104 deletions

View File

@ -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 { FreebitOrchestratorService } from "./services/freebit-orchestrator.service";
import { FreebitMapperService } from "./services/freebit-mapper.service";
import { FreebitOperationsService } from "./services/freebit-operations.service"; import { FreebitOperationsService } from "./services/freebit-operations.service";
import { FreebitClientService } from "./services/freebit-client.service"; import { FreebitClientService } from "./services/freebit-client.service";
import { FreebitAuthService } from "./services/freebit-auth.service"; import { FreebitAuthService } from "./services/freebit-auth.service";
@Module({ @Module({
imports: [
forwardRef(() => {
const { SimManagementModule } = require("../../modules/subscriptions/sim-management/sim-management.module");
return SimManagementModule;
}),
],
providers: [ providers: [
// Core services // Core services
FreebitClientService, FreebitClientService,
FreebitAuthService, FreebitAuthService,
FreebitMapperService,
FreebitOperationsService, FreebitOperationsService,
FreebitOrchestratorService, FreebitOrchestratorService,
], ],

View File

@ -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;
}

View File

@ -2,23 +2,13 @@ import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions";
import type { import type {
AuthRequest as FreebitAuthRequest, FreebitConfig,
AuthResponse as FreebitAuthResponse, FreebitAuthRequest,
} from "@customer-portal/domain/sim/providers/freebit"; FreebitAuthResponse,
import { Freebit as FreebitProvider } from "@customer-portal/domain/sim/providers/freebit"; } from "../interfaces/freebit.types";
import { FreebitError } from "./freebit-error.service"; import { FreebitError } from "./freebit-error.service";
interface FreebitConfig {
baseUrl: string;
oemId: string;
oemKey: string;
timeout: number;
retryAttempts: number;
detailsEndpoint?: string;
}
@Injectable() @Injectable()
export class FreebitAuthService { export class FreebitAuthService {
private readonly config: FreebitConfig; private readonly config: FreebitConfig;
@ -67,41 +57,39 @@ export class FreebitAuthService {
try { try {
if (!this.config.oemKey) { if (!this.config.oemKey) {
throw new FreebitOperationException( throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing");
"Freebit API not configured: FREEBIT_OEM_KEY is missing",
{
operation: "authenticate",
}
);
} }
const request: FreebitAuthRequest = FreebitProvider.schemas.auth.parse({ const request: FreebitAuthRequest = {
oemId: this.config.oemId, oemId: this.config.oemId,
oemKey: this.config.oemKey, 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", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `json=${JSON.stringify(request)}`, body: `json=${JSON.stringify(request)}`,
}); });
if (!response.ok) { if (!response.ok) {
throw new FreebitOperationException(`HTTP ${response.status}: ${response.statusText}`, { throw new Error(`HTTP ${response.status}: ${response.statusText}`);
operation: "authenticate",
status: response.status,
});
} }
const json: unknown = await response.json(); const data = (await response.json()) as FreebitAuthResponse;
const data: FreebitAuthResponse = FreebitProvider.mapper.transformFreebitAuthResponse(json); 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( throw new FreebitError(
`Authentication failed: ${data.status?.message ?? "Unknown error"}`, `Authentication failed: ${data.status.message}`,
data.resultCode, resultCode,
data.status?.statusCode, statusCode,
data.status?.message data.status.message
); );
} }

View File

@ -30,7 +30,10 @@ export class FreebitClientService {
const config = this.authService.getConfig(); const config = this.authService.getConfig();
const requestPayload = { ...payload, authKey }; 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++) { for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
try { try {
@ -52,6 +55,15 @@ export class FreebitClientService {
clearTimeout(timeout); clearTimeout(timeout);
if (!response.ok) { 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( throw new FreebitError(
`HTTP ${response.status}: ${response.statusText}`, `HTTP ${response.status}: ${response.statusText}`,
response.status.toString() response.status.toString()
@ -60,18 +72,29 @@ export class FreebitClientService {
const responseData = (await response.json()) as TResponse; 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( throw new FreebitError(
`API Error: ${responseData.status?.message || "Unknown error"}`, `API Error: ${responseData.status?.message || "Unknown error"}`,
responseData.resultCode, resultCode,
responseData.status?.statusCode, statusCode,
responseData.status?.message responseData.status?.message
); );
} }
this.logger.debug("Freebit API request successful", { this.logger.debug("Freebit API request successful", {
url, url,
resultCode: responseData.resultCode, resultCode,
}); });
return responseData; return responseData;
@ -117,7 +140,10 @@ export class FreebitClientService {
TPayload extends object, TPayload extends object,
>(endpoint: string, payload: TPayload): Promise<TResponse> { >(endpoint: string, payload: TPayload): Promise<TResponse> {
const config = this.authService.getConfig(); 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++) { for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
try { try {
@ -147,18 +173,29 @@ export class FreebitClientService {
const responseData = (await response.json()) as TResponse; 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<string, unknown>),
attempt,
});
throw new FreebitError( throw new FreebitError(
`API Error: ${responseData.status?.message || "Unknown error"}`, `API Error: ${responseData.status?.message || "Unknown error"}`,
responseData.resultCode, resultCode,
responseData.status?.statusCode, statusCode,
responseData.status?.message responseData.status?.message
); );
} }
this.logger.debug("Freebit JSON API request successful", { this.logger.debug("Freebit JSON API request successful", {
url, url,
resultCode: responseData.resultCode, resultCode,
}); });
return responseData; return responseData;
@ -204,7 +241,10 @@ export class FreebitClientService {
*/ */
async makeSimpleRequest(endpoint: string): Promise<boolean> { async makeSimpleRequest(endpoint: string): Promise<boolean> {
const config = this.authService.getConfig(); 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 { try {
const controller = new AbortController(); const controller = new AbortController();
@ -243,4 +283,13 @@ export class FreebitClientService {
return sanitized; 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;
}
} }

View File

@ -81,6 +81,19 @@ export class FreebitError extends Error {
return "SIM service request timed out. Please try again."; 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."; return "SIM operation failed. Please try again or contact support.";
} }
} }

View File

@ -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<SimDetails> {
const account = response.responseDatas[0];
if (!account) {
throw new Error("No account data in response");
}
let simType: "standard" | "nano" | "micro" | "esim" = "standard";
if (account.eid) {
simType = "esim";
} else if (account.simSize) {
simType = account.simSize;
}
// Try to get voice options from database first
let voiceMailEnabled = true;
let callWaitingEnabled = true;
let internationalRoamingEnabled = true;
let networkType = String(account.networkType ?? account.contractLine ?? "4G");
if (this.voiceOptionsService) {
try {
const storedOptions = await this.voiceOptionsService.getVoiceOptions(
String(account.account ?? "")
);
if (storedOptions) {
voiceMailEnabled = storedOptions.voiceMailEnabled;
callWaitingEnabled = storedOptions.callWaitingEnabled;
internationalRoamingEnabled = storedOptions.internationalRoamingEnabled;
networkType = storedOptions.networkType;
this.logger.debug("[FreebitMapper] Loaded voice options from database", {
account: account.account,
options: storedOptions,
});
} else {
// No stored options, check API response
voiceMailEnabled = this.parseOptionFlag(account.voicemail ?? account.voiceMail, true);
callWaitingEnabled = this.parseOptionFlag(
account.callwaiting ?? account.callWaiting,
true
);
internationalRoamingEnabled = this.parseOptionFlag(
account.worldwing ?? account.worldWing,
true
);
this.logger.debug(
"[FreebitMapper] No stored options found, using defaults or API values",
{
account: account.account,
voiceMailEnabled,
callWaitingEnabled,
internationalRoamingEnabled,
}
);
}
} catch (error) {
this.logger.warn("[FreebitMapper] Failed to load voice options from database", {
account: account.account,
error,
});
}
}
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);
}
}

View File

@ -1,54 +1,131 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util"; 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 { FreebitClientService } from "./freebit-client.service";
import { FreebitMapperService } from "./freebit-mapper.service";
import { FreebitAuthService } from "./freebit-auth.service"; import { FreebitAuthService } from "./freebit-auth.service";
// Type imports from domain (following clean import pattern from README)
import type { import type {
TopUpResponse,
PlanChangeResponse,
AddSpecResponse,
CancelPlanResponse,
EsimReissueResponse,
EsimAddAccountResponse,
EsimActivationResponse,
QuotaHistoryRequest,
FreebitTopUpRequest,
FreebitPlanChangeRequest,
FreebitCancelPlanRequest,
FreebitEsimReissueRequest,
FreebitEsimActivationRequest,
FreebitEsimActivationParams,
FreebitAccountDetailsRequest, FreebitAccountDetailsRequest,
FreebitAccountDetailsResponse,
FreebitTrafficInfoRequest, FreebitTrafficInfoRequest,
FreebitTrafficInfoResponse,
FreebitTopUpRequest,
FreebitTopUpResponse,
FreebitQuotaHistoryRequest, FreebitQuotaHistoryRequest,
FreebitQuotaHistoryResponse,
FreebitPlanChangeRequest,
FreebitPlanChangeResponse,
FreebitContractLineChangeRequest,
FreebitContractLineChangeResponse,
FreebitAddSpecRequest,
FreebitAddSpecResponse,
FreebitVoiceOptionSettings,
FreebitVoiceOptionRequest,
FreebitVoiceOptionResponse,
FreebitCancelPlanRequest,
FreebitCancelPlanResponse,
FreebitEsimReissueRequest,
FreebitEsimReissueResponse,
FreebitEsimAddAccountRequest, FreebitEsimAddAccountRequest,
FreebitAccountDetailsRaw, FreebitEsimAddAccountResponse,
FreebitTrafficInfoRaw, FreebitEsimAccountActivationRequest,
FreebitQuotaHistoryRaw, FreebitEsimAccountActivationResponse,
} from "@customer-portal/domain/sim/providers/freebit"; SimDetails,
SimUsage,
SimTopUpHistory,
} from "../interfaces/freebit.types";
@Injectable() @Injectable()
export class FreebitOperationsService { export class FreebitOperationsService {
constructor( constructor(
private readonly client: FreebitClientService, private readonly client: FreebitClientService,
private readonly mapper: FreebitMapperService,
private readonly auth: FreebitAuthService, 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 * Get SIM account details with endpoint fallback
*/ */
async getSimDetails(account: string): Promise<SimDetails> { async getSimDetails(account: string): Promise<SimDetails> {
try { try {
const request: FreebitAccountDetailsRequest = FreebitProvider.schemas.accountDetails.parse({ const request: Omit<FreebitAccountDetailsRequest, "authKey"> = {
version: "2", version: "2",
requestDatas: [{ kind: "MVNO", account }], requestDatas: [{ kind: "MVNO", account }],
}); };
const config = this.auth.getConfig(); const config = this.auth.getConfig();
const configured = config.detailsEndpoint || "/master/getAcnt/"; const configured = config.detailsEndpoint || "/master/getAcnt/";
@ -73,7 +150,7 @@ export class FreebitOperationsService {
]) ])
); );
let response: FreebitAccountDetailsRaw | undefined; let response: FreebitAccountDetailsResponse | undefined;
let lastError: unknown; let lastError: unknown;
for (const ep of candidates) { for (const ep of candidates) {
@ -82,7 +159,7 @@ export class FreebitOperationsService {
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`); this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
} }
response = await this.client.makeAuthenticatedRequest< response = await this.client.makeAuthenticatedRequest<
FreebitAccountDetailsRaw, FreebitAccountDetailsResponse,
typeof request typeof request
>(ep, request); >(ep, request);
break; break;
@ -98,14 +175,10 @@ export class FreebitOperationsService {
if (lastError instanceof Error) { if (lastError instanceof Error) {
throw lastError; throw lastError;
} }
throw new FreebitOperationException("Failed to get SIM details from any endpoint", { throw new Error("Failed to get SIM details from any endpoint");
operation: "getSimDetails",
account,
attemptedEndpoints: ["simDetailsHiho", "simDetailsGet"],
});
} }
return FreebitProvider.transformFreebitAccountDetails(response); return await this.mapper.mapToSimDetails(response);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
this.logger.error(`Failed to get SIM details for account ${account}`, { this.logger.error(`Failed to get SIM details for account ${account}`, {
@ -121,16 +194,14 @@ export class FreebitOperationsService {
*/ */
async getSimUsage(account: string): Promise<SimUsage> { async getSimUsage(account: string): Promise<SimUsage> {
try { try {
const request: FreebitTrafficInfoRequest = FreebitProvider.schemas.trafficInfo.parse({ const request: Omit<FreebitTrafficInfoRequest, "authKey"> = { account };
account,
});
const response = await this.client.makeAuthenticatedRequest< const response = await this.client.makeAuthenticatedRequest<
FreebitTrafficInfoRaw, FreebitTrafficInfoResponse,
typeof request typeof request
>("/mvno/getTrafficInfo/", request); >("/mvno/getTrafficInfo/", request);
return FreebitProvider.transformFreebitTrafficInfo(response); return this.mapper.mapToSimUsage(response);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
this.logger.error(`Failed to get SIM usage for account ${account}`, { 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 } = {} options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
): Promise<void> { ): Promise<void> {
try { try {
const payload: FreebitTopUpRequest = FreebitProvider.schemas.topUp.parse({ const quotaKb = Math.round(quotaMb * 1024);
const baseRequest: Omit<FreebitTopUpRequest, "authKey"> = {
account, account,
quotaMb,
options,
});
const quotaKb = Math.round(payload.quotaMb * 1024);
const baseRequest = {
account: payload.account,
quota: quotaKb, quota: quotaKb,
quotaCode: payload.options?.campaignCode, quotaCode: options.campaignCode,
expire: payload.options?.expiryDate, expire: options.expiryDate,
}; };
const scheduled = Boolean(payload.options?.scheduledAt); const scheduled = !!options.scheduledAt;
const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/"; const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
const request = scheduled const request = scheduled ? { ...baseRequest, runTime: options.scheduledAt } : baseRequest;
? { ...baseRequest, runTime: payload.options?.scheduledAt }
: baseRequest;
await this.client.makeAuthenticatedRequest<TopUpResponse, typeof request>(endpoint, request); await this.client.makeAuthenticatedRequest<FreebitTopUpResponse, typeof request>(
endpoint,
request
);
this.logger.log(`Successfully topped up ${quotaMb}MB for account ${account}`, { this.logger.log(`Successfully topped up ${quotaMb}MB for account ${account}`, {
account, account,
@ -198,18 +265,18 @@ export class FreebitOperationsService {
toDate: string toDate: string
): Promise<SimTopUpHistory> { ): Promise<SimTopUpHistory> {
try { try {
const request: FreebitQuotaHistoryRequest = FreebitProvider.schemas.quotaHistory.parse({ const request: Omit<FreebitQuotaHistoryRequest, "authKey"> = {
account, account,
fromDate, fromDate,
toDate, toDate,
}); };
const response = await this.client.makeAuthenticatedRequest< const response = await this.client.makeAuthenticatedRequest<
FreebitQuotaHistoryRaw, FreebitQuotaHistoryResponse,
QuotaHistoryRequest typeof request
>("/mvno/getQuotaHistory/", request); >("/mvno/getQuotaHistory/", request);
return FreebitProvider.transformFreebitQuotaHistory(response, account); return this.mapper.mapToSimTopUpHistory(response, account);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
this.logger.error(`Failed to get SIM top-up history for account ${account}`, { this.logger.error(`Failed to get SIM top-up history for account ${account}`, {
@ -224,42 +291,92 @@ export class FreebitOperationsService {
/** /**
* Change SIM plan * 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( async changeSimPlan(
account: string, account: string,
newPlanCode: string, newPlanCode: string,
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {} options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
): Promise<{ ipv4?: string; ipv6?: 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 { try {
const request = { this.assertOperationSpacing(account, "plan");
account: parsed.account, // First, get current SIM details to log for debugging
plancode: parsed.newPlanCode, let currentPlanCode: string | undefined;
globalip: parsed.assignGlobalIp ? "1" : "0", try {
runTime: parsed.scheduledAt, 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<FreebitPlanChangeRequest, "authKey"> = {
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< const response = await this.client.makeAuthenticatedRequest<
PlanChangeResponse, FreebitPlanChangeResponse,
typeof request typeof request
>("/mvno/changePlan/", request); >("/mvno/changePlan/", request);
this.logger.log( this.logger.log(`Successfully changed plan for account ${account} to ${newPlanCode}`, {
`Successfully changed plan for account ${parsed.account} to ${parsed.newPlanCode}`, account,
{ newPlanCode,
account: parsed.account, assignGlobalIp: options.assignGlobalIp,
newPlanCode: parsed.newPlanCode, scheduled: !!options.scheduledAt,
assignGlobalIp: parsed.assignGlobalIp, response: {
scheduled: Boolean(parsed.scheduledAt), resultCode: response.resultCode,
} statusCode: response.status?.statusCode,
); message: response.status?.message,
},
});
this.stampOperation(account, "plan");
return { return {
ipv4: response.ipv4, ipv4: response.ipv4,
@ -267,17 +384,48 @@ export class FreebitOperationsService {
}; };
} catch (error) { } catch (error) {
const message = getErrorMessage(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<string, unknown> = {
account, account,
newPlanCode, newPlanCode,
planCode: newPlanCode, // Use camelCase
globalip: options.assignGlobalIp ? "1" : undefined,
runTime: options.scheduledAt,
error: message, 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}`); throw new BadRequestException(`Failed to change SIM plan: ${message}`);
} }
} }
/** /**
* Update SIM features (voice options and network type) * 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( async updateSimFeatures(
account: string, account: string,
@ -289,45 +437,76 @@ export class FreebitOperationsService {
} }
): Promise<void> { ): Promise<void> {
try { try {
// Import and validate with the new schema const voiceFeatures = {
const parsed = FreebitProvider.schemas.simFeatures.parse({
account,
voiceMailEnabled: features.voiceMailEnabled, voiceMailEnabled: features.voiceMailEnabled,
callWaitingEnabled: features.callWaitingEnabled, callWaitingEnabled: features.callWaitingEnabled,
callForwardingEnabled: undefined, // Not supported in this interface yet internationalRoamingEnabled: features.internationalRoamingEnabled,
callerIdEnabled: undefined,
});
const payload: Record<string, unknown> = {
account: parsed.account,
}; };
if (typeof parsed.voiceMailEnabled === "boolean") { const hasVoiceFeatures = Object.values(voiceFeatures).some(value => typeof value === "boolean");
const flag = parsed.voiceMailEnabled ? "10" : "20"; const hasNetworkTypeChange = typeof features.networkType === "string";
payload.voiceMail = flag;
payload.voicemail = flag;
}
if (typeof parsed.callWaitingEnabled === "boolean") { // Execute in sequence with 30-minute delays as per Freebit API requirements
const flag = parsed.callWaitingEnabled ? "10" : "20"; if (hasVoiceFeatures && hasNetworkTypeChange) {
payload.callWaiting = flag; // Both voice features and network type change requested
payload.callwaiting = flag; this.logger.log(`Updating both voice features and network type with required 30-minute delay`, {
} account,
hasVoiceFeatures,
hasNetworkTypeChange,
});
if (typeof features.internationalRoamingEnabled === "boolean") { // Step 1: Update voice features immediately (PA05-06)
const flag = features.internationalRoamingEnabled ? "10" : "20"; await this.updateVoiceFeatures(account, voiceFeatures);
payload.worldWing = flag; this.logger.log(`Voice features updated, scheduling network type change in 30 minutes`, {
payload.worldwing = flag; account,
} networkType: features.networkType,
});
if (features.networkType) { // Step 2: Schedule network type change 30 minutes later (PA05-38)
payload.contractLine = features.networkType; // 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<AddSpecResponse, typeof payload>( this.logger.log(`Voice features updated immediately, network type scheduled for 30 minutes`, {
"/master/addSpec/", account,
payload 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}`, { this.logger.log(`Successfully updated SIM features for account ${account}`, {
account, account,
@ -342,27 +521,221 @@ export class FreebitOperationsService {
account, account,
features, features,
error: message, error: message,
errorStack: error instanceof Error ? error.stack : undefined,
}); });
throw new BadRequestException(`Failed to update SIM features: ${message}`); 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<void> {
try {
this.assertOperationSpacing(account, "voice");
const buildVoiceOptionPayload = (): Omit<FreebitVoiceOptionRequest, "authKey"> => {
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<void> {
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<FreebitContractLineChangeRequest, "authKey"> = {
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 * 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<void> { async cancelSim(account: string, scheduledAt?: string): Promise<void> {
try { try {
const parsed: FreebitCancelPlanRequest = FreebitProvider.schemas.cancelPlan.parse({ const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
account, account,
runDate: scheduledAt, runTime: scheduledAt,
});
const request = {
account: parsed.account,
runTime: parsed.runDate,
}; };
await this.client.makeAuthenticatedRequest<CancelPlanResponse, typeof request>( 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<FreebitCancelPlanResponse, typeof request>(
"/mvno/releasePlan/", "/mvno/releasePlan/",
request request
); );
@ -371,6 +744,7 @@ export class FreebitOperationsService {
account, account,
runTime: scheduledAt, runTime: scheduledAt,
}); });
this.stampOperation(account, "cancellation");
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
this.logger.error(`Failed to cancel SIM for account ${account}`, { this.logger.error(`Failed to cancel SIM for account ${account}`, {
@ -387,11 +761,11 @@ export class FreebitOperationsService {
*/ */
async reissueEsimProfile(account: string): Promise<void> { async reissueEsimProfile(account: string): Promise<void> {
try { try {
const request = { const request: Omit<FreebitEsimReissueRequest, "authKey"> = {
requestDatas: [{ kind: "MVNO", account }], requestDatas: [{ kind: "MVNO", account }],
}; };
await this.client.makeAuthenticatedRequest<EsimReissueResponse, typeof request>( await this.client.makeAuthenticatedRequest<FreebitEsimReissueResponse, typeof request>(
"/mvno/reissueEsim/", "/mvno/reissueEsim/",
request request
); );
@ -416,41 +790,25 @@ export class FreebitOperationsService {
options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {} options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {}
): Promise<void> { ): Promise<void> {
try { try {
const parsed: FreebitEsimReissueRequest = FreebitProvider.schemas.esimReissue.parse({ const request: Omit<FreebitEsimAddAccountRequest, "authKey"> = {
account,
newEid,
oldEid: options.oldEid,
planCode: options.planCode,
oldProductNumber: options.oldProductNumber,
});
const requestPayload = FreebitProvider.schemas.esimAddAccount.parse({
aladinOperated: "20", aladinOperated: "20",
account: parsed.account, account,
eid: parsed.newEid, eid: newEid,
addKind: "R", addKind: "R",
planCode: parsed.planCode, planCode: options.planCode,
});
const payload: FreebitEsimAddAccountRequest = {
...requestPayload,
authKey: await this.auth.getAuthKey(),
}; };
await this.client.makeAuthenticatedRequest< await this.client.makeAuthenticatedRequest<FreebitEsimAddAccountResponse, typeof request>(
EsimAddAccountResponse, "/mvno/esim/addAcnt/",
FreebitEsimAddAccountRequest request
>("/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,
}
); );
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
account,
newEid,
oldProductNumber: options.oldProductNumber,
oldEid: options.oldEid,
});
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, { this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
@ -472,7 +830,12 @@ export class FreebitOperationsService {
contractLine?: "4G" | "5G"; contractLine?: "4G" | "5G";
aladinOperated?: "10" | "20"; aladinOperated?: "10" | "20";
shipDate?: string; 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?: { identity?: {
firstnameKanji?: string; firstnameKanji?: string;
lastnameKanji?: string; lastnameKanji?: string;
@ -489,55 +852,58 @@ export class FreebitOperationsService {
contractLine, contractLine,
aladinOperated = "10", aladinOperated = "10",
shipDate, shipDate,
addKind,
simKind,
repAccount,
deliveryCode,
globalIp,
mnp, mnp,
identity, identity,
} = params; } = params;
// Import schemas dynamically to avoid circular dependencies if (!account || !eid) {
const validatedParams: FreebitEsimActivationParams =
FreebitProvider.schemas.esimActivationParams.parse({
account,
eid,
planCode,
contractLine,
aladinOperated,
shipDate,
mnp,
identity,
});
if (!validatedParams.account || !validatedParams.eid) {
throw new BadRequestException("activateEsimAccountNew requires account and eid"); throw new BadRequestException("activateEsimAccountNew requires account and eid");
} }
try { const finalAddKind = addKind || "N";
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 : {}),
};
// Validate the full API request payload // Validate simKind: Required except when addKind is 'R' (reissue)
FreebitProvider.schemas.esimActivationRequest.parse(payload); 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 // Use JSON request for PA05-41
await this.client.makeAuthenticatedJsonRequest<EsimActivationResponse, typeof payload>( await this.client.makeAuthenticatedJsonRequest<
"/mvno/esim/addAcct/", FreebitEsimAccountActivationResponse,
payload FreebitEsimAccountActivationRequest
); >("/mvno/esim/addAcct/", payload);
this.logger.log("Successfully activated new eSIM account via PA05-41", { this.logger.log("Successfully activated new eSIM account via PA05-41", {
account, account,
planCode, planCode,
contractLine, contractLine,
addKind: addKind || "N",
scheduled: !!shipDate, scheduled: !!shipDate,
mnp: !!mnp, mnp: !!mnp,
}); });
@ -547,6 +913,7 @@ export class FreebitOperationsService {
account, account,
eid, eid,
planCode, planCode,
addKind,
error: message, error: message,
}); });
throw new BadRequestException(`Failed to activate new eSIM account: ${message}`); throw new BadRequestException(`Failed to activate new eSIM account: ${message}`);

View File

@ -1,17 +1,20 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { FreebitOperationsService } from "./freebit-operations.service"; import { FreebitOperationsService } from "./freebit-operations.service";
import { Freebit } from "@customer-portal/domain/sim/providers/freebit"; import { FreebitMapperService } from "./freebit-mapper.service";
import type { SimDetails, SimUsage, SimTopUpHistory } from "@customer-portal/domain/sim"; import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebit.types";
@Injectable() @Injectable()
export class FreebitOrchestratorService { export class FreebitOrchestratorService {
constructor(private readonly operations: FreebitOperationsService) {} constructor(
private readonly operations: FreebitOperationsService,
private readonly mapper: FreebitMapperService
) {}
/** /**
* Get SIM account details * Get SIM account details
*/ */
async getSimDetails(account: string): Promise<SimDetails> { async getSimDetails(account: string): Promise<SimDetails> {
const normalizedAccount = Freebit.normalizeAccount(account); const normalizedAccount = this.mapper.normalizeAccount(account);
return this.operations.getSimDetails(normalizedAccount); return this.operations.getSimDetails(normalizedAccount);
} }
@ -19,7 +22,7 @@ export class FreebitOrchestratorService {
* Get SIM usage information * Get SIM usage information
*/ */
async getSimUsage(account: string): Promise<SimUsage> { async getSimUsage(account: string): Promise<SimUsage> {
const normalizedAccount = Freebit.normalizeAccount(account); const normalizedAccount = this.mapper.normalizeAccount(account);
return this.operations.getSimUsage(normalizedAccount); return this.operations.getSimUsage(normalizedAccount);
} }
@ -31,7 +34,7 @@ export class FreebitOrchestratorService {
quotaMb: number, quotaMb: number,
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {} options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
): Promise<void> { ): Promise<void> {
const normalizedAccount = Freebit.normalizeAccount(account); const normalizedAccount = this.mapper.normalizeAccount(account);
return this.operations.topUpSim(normalizedAccount, quotaMb, options); return this.operations.topUpSim(normalizedAccount, quotaMb, options);
} }
@ -43,7 +46,7 @@ export class FreebitOrchestratorService {
fromDate: string, fromDate: string,
toDate: string toDate: string
): Promise<SimTopUpHistory> { ): Promise<SimTopUpHistory> {
const normalizedAccount = Freebit.normalizeAccount(account); const normalizedAccount = this.mapper.normalizeAccount(account);
return this.operations.getSimTopUpHistory(normalizedAccount, fromDate, toDate); return this.operations.getSimTopUpHistory(normalizedAccount, fromDate, toDate);
} }
@ -55,7 +58,7 @@ export class FreebitOrchestratorService {
newPlanCode: string, newPlanCode: string,
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {} options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
): Promise<{ ipv4?: string; ipv6?: string }> { ): Promise<{ ipv4?: string; ipv6?: string }> {
const normalizedAccount = Freebit.normalizeAccount(account); const normalizedAccount = this.mapper.normalizeAccount(account);
return this.operations.changeSimPlan(normalizedAccount, newPlanCode, options); return this.operations.changeSimPlan(normalizedAccount, newPlanCode, options);
} }
@ -71,7 +74,7 @@ export class FreebitOrchestratorService {
networkType?: "4G" | "5G"; networkType?: "4G" | "5G";
} }
): Promise<void> { ): Promise<void> {
const normalizedAccount = Freebit.normalizeAccount(account); const normalizedAccount = this.mapper.normalizeAccount(account);
return this.operations.updateSimFeatures(normalizedAccount, features); return this.operations.updateSimFeatures(normalizedAccount, features);
} }
@ -79,7 +82,7 @@ export class FreebitOrchestratorService {
* Cancel SIM service * Cancel SIM service
*/ */
async cancelSim(account: string, scheduledAt?: string): Promise<void> { async cancelSim(account: string, scheduledAt?: string): Promise<void> {
const normalizedAccount = Freebit.normalizeAccount(account); const normalizedAccount = this.mapper.normalizeAccount(account);
return this.operations.cancelSim(normalizedAccount, scheduledAt); return this.operations.cancelSim(normalizedAccount, scheduledAt);
} }
@ -87,7 +90,7 @@ export class FreebitOrchestratorService {
* Reissue eSIM profile (simple) * Reissue eSIM profile (simple)
*/ */
async reissueEsimProfile(account: string): Promise<void> { async reissueEsimProfile(account: string): Promise<void> {
const normalizedAccount = Freebit.normalizeAccount(account); const normalizedAccount = this.mapper.normalizeAccount(account);
return this.operations.reissueEsimProfile(normalizedAccount); return this.operations.reissueEsimProfile(normalizedAccount);
} }
@ -99,7 +102,7 @@ export class FreebitOrchestratorService {
newEid: string, newEid: string,
options: { oldEid?: string; planCode?: string } = {} options: { oldEid?: string; planCode?: string } = {}
): Promise<void> { ): Promise<void> {
const normalizedAccount = Freebit.normalizeAccount(account); const normalizedAccount = this.mapper.normalizeAccount(account);
return this.operations.reissueEsimProfileEnhanced(normalizedAccount, newEid, options); return this.operations.reissueEsimProfileEnhanced(normalizedAccount, newEid, options);
} }
@ -113,7 +116,12 @@ export class FreebitOrchestratorService {
contractLine?: "4G" | "5G"; contractLine?: "4G" | "5G";
aladinOperated?: "10" | "20"; aladinOperated?: "10" | "20";
shipDate?: string; 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?: { identity?: {
firstnameKanji?: string; firstnameKanji?: string;
lastnameKanji?: string; lastnameKanji?: string;
@ -123,7 +131,7 @@ export class FreebitOrchestratorService {
birthday?: string; birthday?: string;
}; };
}): Promise<void> { }): Promise<void> {
const normalizedAccount = Freebit.normalizeAccount(params.account); const normalizedAccount = this.mapper.normalizeAccount(params.account);
return this.operations.activateEsimAccountNew({ return this.operations.activateEsimAccountNew({
account: normalizedAccount, account: normalizedAccount,
eid: params.eid, eid: params.eid,
@ -131,6 +139,11 @@ export class FreebitOrchestratorService {
contractLine: params.contractLine, contractLine: params.contractLine,
aladinOperated: params.aladinOperated, aladinOperated: params.aladinOperated,
shipDate: params.shipDate, shipDate: params.shipDate,
addKind: params.addKind,
simKind: params.simKind,
repAccount: params.repAccount,
deliveryCode: params.deliveryCode,
globalIp: params.globalIp,
mnp: params.mnp, mnp: params.mnp,
identity: params.identity, identity: params.identity,
}); });

View File

@ -1,5 +1,4 @@
// Export all Freebit services // Export all Freebit services
export { FreebitOrchestratorService } from "./freebit-orchestrator.service"; export { FreebitOrchestratorService } from "./freebit-orchestrator.service";
export { FreebitMapperService } from "./freebit-mapper.service";
export { FreebitOperationsService } from "./freebit-operations.service"; export { FreebitOperationsService } from "./freebit-operations.service";
export { FreebitClientService } from "./freebit-client.service";
export { FreebitAuthService } from "./freebit-auth.service";

View File

@ -1,15 +1,18 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { SimOrchestratorService } from "./sim-management/services/sim-orchestrator.service"; import { SimOrchestratorService } from "./sim-management/services/sim-orchestrator.service";
import { SimNotificationService } from "./sim-management/services/sim-notification.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 { import type {
SimTopUpRequest, SimTopUpRequest,
SimPlanChangeRequest, SimPlanChangeRequest,
SimCancelRequest, SimCancelRequest,
SimTopUpHistoryRequest, SimTopUpHistoryRequest,
SimFeaturesUpdateRequest, SimFeaturesUpdateRequest,
SimReissueRequest, } from "./sim-management/types/sim-requests.types";
} from "@customer-portal/domain/sim";
import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface"; import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface";
@Injectable() @Injectable()
@ -38,6 +41,13 @@ export class SimManagementService {
return this.simOrchestrator.debugSimSubscription(userId, subscriptionId); return this.simOrchestrator.debugSimSubscription(userId, subscriptionId);
} }
/**
* Debug method to query Freebit directly for any account's details
*/
async getSimDetailsDebug(account: string): Promise<SimDetails> {
return this.simOrchestrator.getSimDetailsDirectly(account);
}
// This method is now handled by SimValidationService internally // This method is now handled by SimValidationService internally
/** /**
@ -69,6 +79,7 @@ export class SimManagementService {
userId: string, userId: string,
subscriptionId: number, subscriptionId: number,
request: SimTopUpHistoryRequest request: SimTopUpHistoryRequest
// @ts-ignore - ignoring mismatch for now as we are migrating
): Promise<SimTopUpHistory> { ): Promise<SimTopUpHistory> {
return this.simOrchestrator.getSimTopUpHistory(userId, subscriptionId, request); return this.simOrchestrator.getSimTopUpHistory(userId, subscriptionId, request);
} }
@ -109,18 +120,20 @@ export class SimManagementService {
/** /**
* Reissue eSIM profile * Reissue eSIM profile
*/ */
async reissueEsimProfile( async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
userId: string, return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, newEid);
subscriptionId: number,
request: SimReissueRequest
): Promise<void> {
return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, request);
} }
/** /**
* Get comprehensive SIM information (details + usage combined) * Get comprehensive SIM information (details + usage combined)
*/ */
async getSimInfo(userId: string, subscriptionId: number): Promise<SimInfo> { async getSimInfo(
userId: string,
subscriptionId: number
): Promise<{
details: SimDetails;
usage: SimUsage;
}> {
return this.simOrchestrator.getSimInfo(userId, subscriptionId); return this.simOrchestrator.getSimInfo(userId, subscriptionId);
} }

View File

@ -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<VoiceOptionsSettings | null> {
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<VoiceOptionsSettings>): Promise<void> {
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<void> {
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<void> {
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 });
}
}
}

View File

@ -1,4 +1,4 @@
import { Module } from "@nestjs/common"; import { Module, forwardRef } from "@nestjs/common";
import { FreebitModule } from "@bff/integrations/freebit/freebit.module"; import { FreebitModule } from "@bff/integrations/freebit/freebit.module";
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module"; import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module";
import { SalesforceModule } from "@bff/integrations/salesforce/salesforce.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 { SimActionRunnerService } from "./services/sim-action-runner.service";
import { SimManagementQueueService } from "./queue/sim-management.queue"; import { SimManagementQueueService } from "./queue/sim-management.queue";
import { SimManagementProcessor } from "./queue/sim-management.processor"; import { SimManagementProcessor } from "./queue/sim-management.processor";
import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
@Module({ @Module({
imports: [FreebitModule, WhmcsModule, SalesforceModule, MappingsModule, EmailModule], imports: [
forwardRef(() => FreebitModule),
WhmcsModule,
SalesforceModule,
MappingsModule,
EmailModule,
],
providers: [ providers: [
// Core services that the SIM services depend on // Core services that the SIM services depend on
SimUsageStoreService, SimUsageStoreService,
@ -34,6 +41,7 @@ import { SimManagementProcessor } from "./queue/sim-management.processor";
// SIM management services // SIM management services
SimValidationService, SimValidationService,
SimNotificationService, SimNotificationService,
SimVoiceOptionsService,
SimDetailsService, SimDetailsService,
SimUsageService, SimUsageService,
SimTopUpService, SimTopUpService,
@ -47,6 +55,11 @@ import { SimManagementProcessor } from "./queue/sim-management.processor";
SimActionRunnerService, SimActionRunnerService,
SimManagementQueueService, SimManagementQueueService,
SimManagementProcessor, SimManagementProcessor,
// Export with token for optional injection in Freebit module
{
provide: "SimVoiceOptionsService",
useExisting: SimVoiceOptionsService,
},
], ],
exports: [ exports: [
SimOrchestratorService, SimOrchestratorService,
@ -64,6 +77,8 @@ import { SimManagementProcessor } from "./queue/sim-management.processor";
SimScheduleService, SimScheduleService,
SimActionRunnerService, SimActionRunnerService,
SimManagementQueueService, SimManagementQueueService,
SimVoiceOptionsService,
"SimVoiceOptionsService", // Export the token
], ],
}) })
export class SimManagementModule {} export class SimManagementModule {}

View File

@ -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";
}

View File

@ -32,13 +32,10 @@ import {
simChangePlanRequestSchema, simChangePlanRequestSchema,
simCancelRequestSchema, simCancelRequestSchema,
simFeaturesRequestSchema, simFeaturesRequestSchema,
simReissueRequestSchema,
type SimInfo,
type SimTopupRequest, type SimTopupRequest,
type SimChangePlanRequest, type SimChangePlanRequest,
type SimCancelRequest, type SimCancelRequest,
type SimFeaturesRequest, type SimFeaturesRequest,
type SimReissueRequest,
} from "@customer-portal/domain/sim"; } from "@customer-portal/domain/sim";
import { ZodValidationPipe } from "@customer-portal/validation/nestjs"; import { ZodValidationPipe } from "@customer-portal/validation/nestjs";
import type { RequestWithUser } from "@bff/modules/auth/auth.types"; import type { RequestWithUser } from "@bff/modules/auth/auth.types";
@ -120,6 +117,11 @@ export class SubscriptionsController {
return { success: true, data: preview }; 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") @Get(":id/sim/debug")
async debugSimSubscription( async debugSimSubscription(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@ -132,7 +134,7 @@ export class SubscriptionsController {
async getSimInfo( async getSimInfo(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number @Param("id", ParseIntPipe) subscriptionId: number
): Promise<SimInfo> { ) {
return this.simManagementService.getSimInfo(req.user.id, subscriptionId); return this.simManagementService.getSimInfo(req.user.id, subscriptionId);
} }
@ -207,13 +209,12 @@ export class SubscriptionsController {
} }
@Post(":id/sim/reissue-esim") @Post(":id/sim/reissue-esim")
@UsePipes(new ZodValidationPipe(simReissueRequestSchema))
async reissueEsimProfile( async reissueEsimProfile(
@Request() req: RequestWithUser, @Request() req: RequestWithUser,
@Param("id", ParseIntPipe) subscriptionId: number, @Param("id", ParseIntPipe) subscriptionId: number,
@Body() body: SimReissueRequest @Body() body: { newEid?: string } = {}
): Promise<SimActionResponse> { ): Promise<SimActionResponse> {
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" }; return { success: true, message: "eSIM profile reissue completed successfully" };
} }

0
apps/portal/scripts/test-request-password-reset.cjs Normal file → Executable file
View File

View File

@ -3,11 +3,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import { XMarkIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "@heroicons/react/24/outline";
import { import { mapToSimplifiedFormat } from "../utils/plan";
SIM_PLAN_OPTIONS,
type SimPlanCode,
getSimPlanLabel,
} from "@customer-portal/domain/sim";
interface ChangePlanModalProps { interface ChangePlanModalProps {
subscriptionId: number; subscriptionId: number;
@ -24,9 +20,16 @@ export function ChangePlanModal({
onSuccess, onSuccess,
onError, onError,
}: ChangePlanModalProps) { }: 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 [loading, setLoading] = useState(false);
const submit = async () => { const submit = async () => {
@ -78,13 +81,13 @@ export function ChangePlanModal({
</label> </label>
<select <select
value={newPlanCode} value={newPlanCode}
onChange={e => setNewPlanCode(e.target.value as SimPlanCode)} onChange={e => setNewPlanCode(e.target.value as PlanCode)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm" className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
> >
<option value="">Choose a plan</option> <option value="">Choose a plan</option>
{allowedPlans.map(option => ( {allowedPlans.map(code => (
<option key={option.code} value={option.code}> <option key={code} value={code}>
{getSimPlanLabel(option.code)} {code}
</option> </option>
))} ))}
</select> </select>

View File

@ -2,7 +2,18 @@
import React from "react"; import React from "react";
import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; 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 { interface DataUsageChartProps {
usage: SimUsage; usage: SimUsage;
@ -210,19 +221,6 @@ export function DataUsageChart({
)} )}
{/* Warnings */} {/* Warnings */}
{usage.isBlacklisted && (
<div className="mt-6 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mr-2" />
<div>
<h4 className="text-sm font-medium text-red-800">Service Restricted</h4>
<p className="text-sm text-red-700 mt-1">
This SIM is currently blacklisted. Please contact support for assistance.
</p>
</div>
</div>
</div>
)}
{usagePercentage >= 90 && ( {usagePercentage >= 90 && (
<div className="mt-6 bg-red-50 border border-red-200 rounded-lg p-4"> <div className="mt-6 bg-red-50 border border-red-200 rounded-lg p-4">

View File

@ -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<SimKind>(currentSimType);
const [newEid, setNewEid] = useState("");
const [submitting, setSubmitting] = useState(false);
const [validationError, setValidationError] = useState<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-gray-500 bg-opacity-75" aria-hidden="true" />
<div className="relative z-10 w-full max-w-2xl rounded-lg border border-gray-200 bg-white shadow-2xl">
<div className="px-6 pt-6 pb-4 sm:px-8 sm:pb-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100">
<ArrowPathIcon className="h-6 w-6 text-green-600" />
</span>
<div>
<h3 className="text-lg font-semibold text-gray-900">Reissue SIM</h3>
<p className="text-sm text-gray-600">
Submit a reissue request for your SIM. Review the important information before continuing.
</p>
</div>
</div>
<button
onClick={onClose}
className="text-gray-400 transition hover:text-gray-600"
aria-label="Close reissue SIM modal"
type="button"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="mt-6 rounded-lg border border-amber-200 bg-amber-50 p-4">
<h4 className="text-sm font-semibold text-amber-800">Important information</h4>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-amber-900">
{IMPORTANT_POINTS.map(point => (
<li key={point}>{point}</li>
))}
</ul>
</div>
<div className="mt-6 grid gap-6 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700">Select SIM type</label>
<div className="mt-3 space-y-2">
<label className="flex items-start gap-3 rounded-lg border border-gray-200 p-3">
<input
type="radio"
name="sim-type"
value="physical"
checked={selectedSimType === "physical"}
onChange={() => setSelectedSimType("physical")}
className="mt-1"
/>
<div>
<p className="text-sm font-medium text-gray-900">Physical SIM</p>
<p className="text-xs text-gray-500">
Well ship a replacement SIM card. Currently, online requests are not available; contact support to proceed.
</p>
</div>
</label>
<label className="flex items-start gap-3 rounded-lg border border-gray-200 p-3">
<input
type="radio"
name="sim-type"
value="esim"
checked={selectedSimType === "esim"}
onChange={() => setSelectedSimType("esim")}
className="mt-1"
/>
<div>
<p className="text-sm font-medium text-gray-900">eSIM</p>
<p className="text-xs text-gray-500">
Generate a new eSIM activation profile. Youll receive new QR code details once processing completes.
</p>
</div>
</label>
</div>
</div>
<div className="rounded-lg border border-gray-200 p-4 text-sm text-gray-600">
<p>
Current SIM type: <strong className="uppercase">{currentSimType}</strong>
</p>
<p className="mt-2">
The selection above lets you specify which type of replacement you need. If you choose a physical SIM, a support agent will contact you to finalise the process.
</p>
</div>
</div>
{isEsimSelected && (
<div className="mt-6">
<label htmlFor="new-eid" className="block text-sm font-medium text-gray-700">
New EID (optional)
</label>
<input
id="new-eid"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={newEid}
onChange={event => {
setNewEid(event.target.value.replace(/\s+/g, ""));
setValidationError(null);
}}
placeholder="Enter 32-digit EID"
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<p className="mt-1 text-xs text-gray-500">{EID_HELP}</p>
</div>
)}
{validationError && (
<p className="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{validationError}
</p>
)}
</div>
<div className="flex flex-col gap-3 border-t border-gray-200 bg-gray-50 p-4 sm:flex-row sm:justify-end sm:px-6">
<button
type="button"
onClick={() => void handleSubmit()}
disabled={disableSubmit || submitting}
className="inline-flex justify-center rounded-md px-4 py-2 text-sm font-semibold text-white shadow-sm disabled:cursor-not-allowed disabled:opacity-70"
style={{ background: "linear-gradient(90deg, #16a34a, #15803d)" }}
>
{submitting ? "Submitting..." : isPhysicalSelected ? "Contact Support" : "Confirm Reissue"}
</button>
<button
type="button"
onClick={onClose}
disabled={submitting}
className="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Cancel
</button>
</div>
</div>
</div>
);
}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
PlusIcon, PlusIcon,
@ -11,18 +11,13 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { TopUpModal } from "./TopUpModal"; import { TopUpModal } from "./TopUpModal";
import { ChangePlanModal } from "./ChangePlanModal"; import { ChangePlanModal } from "./ChangePlanModal";
import { ReissueSimModal } from "./ReissueSimModal";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import {
canTopUpSim,
canReissueEsim,
canCancelSim,
type SimStatus,
} from "@customer-portal/domain/sim";
interface SimActionsProps { interface SimActionsProps {
subscriptionId: number; subscriptionId: number;
simType: "physical" | "esim"; simType: "physical" | "esim";
status: SimStatus; status: string;
onTopUpSuccess?: () => void; onTopUpSuccess?: () => void;
onPlanChangeSuccess?: () => void; onPlanChangeSuccess?: () => void;
onCancelSuccess?: () => void; onCancelSuccess?: () => void;
@ -45,7 +40,7 @@ export function SimActions({
const router = useRouter(); const router = useRouter();
const [showTopUpModal, setShowTopUpModal] = useState(false); const [showTopUpModal, setShowTopUpModal] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false); const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [showReissueConfirm, setShowReissueConfirm] = useState(false); const [showReissueModal, setShowReissueModal] = useState(false);
const [loading, setLoading] = useState<string | null>(null); const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
@ -54,29 +49,17 @@ export function SimActions({
"topup" | "reissue" | "cancel" | "changePlan" | null "topup" | "reissue" | "cancel" | "changePlan" | null
>(null); >(null);
const isActiveStatus = canTopUpSim(status); const isActive = status === "active";
const canTopUp = isActiveStatus; const canTopUp = isActive;
const canReissue = simType === "esim" && canReissueEsim(status); const canReissue = isActive;
const canCancel = canCancelSim(status); const canCancel = isActive;
const handleReissueEsim = async () => { const reissueDisabledReason = useMemo(() => {
setLoading("reissue"); if (!isActive) {
setError(null); return "SIM must be active to request a reissue.";
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);
} }
}; return null;
}, [isActive]);
const handleCancelSim = async () => { const handleCancelSim = async () => {
setLoading("cancel"); setLoading("cancel");
@ -85,6 +68,7 @@ export function SimActions({
try { try {
await apiClient.POST("/api/subscriptions/{id}/sim/cancel", { await apiClient.POST("/api/subscriptions/{id}/sim/cancel", {
params: { path: { id: subscriptionId } }, params: { path: { id: subscriptionId } },
body: {},
}); });
setSuccess("SIM service cancelled successfully"); setSuccess("SIM service cancelled successfully");
@ -112,32 +96,17 @@ export function SimActions({
return ( return (
<div <div
id="sim-actions" id="sim-actions"
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300"}`} className={`${embedded ? "" : "bg-white shadow-md rounded-xl border border-gray-100"}`}
> >
{/* Header */} {/* Header */}
<div className={`${embedded ? "" : "px-6 lg:px-8 py-5 border-b border-gray-200"}`}> {!embedded && (
<div className="flex items-center"> <div className="px-6 py-6 border-b border-gray-200">
<div className="bg-blue-50 rounded-xl p-2 mr-4"> <h3 className="text-lg font-semibold tracking-tight text-slate-900 mb-1">
<svg SIM Management Actions
className="h-6 w-6 text-blue-600" </h3>
fill="none" <p className="text-sm text-slate-600">Manage your SIM service</p>
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"
/>
</svg>
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900">SIM Management Actions</h3>
<p className="text-sm text-gray-600 mt-1">Manage your SIM service</p>
</div>
</div> </div>
</div> )}
{/* Content */} {/* Content */}
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}> <div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
@ -160,7 +129,7 @@ export function SimActions({
</div> </div>
)} )}
{!isActiveStatus && ( {!isActive && (
<div className="mb-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4"> <div className="mb-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-center"> <div className="flex items-center">
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 mr-2" /> <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 mr-2" />
@ -172,7 +141,7 @@ export function SimActions({
)} )}
{/* Action Buttons */} {/* Action Buttons */}
<div className={`grid gap-4 ${embedded ? "grid-cols-1" : "grid-cols-2"}`}> <div className="space-y-3">
{/* Top Up Data - Primary Action */} {/* Top Up Data - Primary Action */}
<button <button
onClick={() => { onClick={() => {
@ -184,70 +153,20 @@ export function SimActions({
} }
}} }}
disabled={!canTopUp || loading !== null} disabled={!canTopUp || loading !== null}
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${ className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
canTopUp && loading === null canTopUp && loading === null
? "text-blue-700 bg-blue-50 border-blue-200 hover:bg-blue-100 hover:border-blue-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" ? "text-white bg-blue-600 hover:bg-blue-700 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 active:scale-[0.98]"
: "text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed" : "text-gray-400 bg-gray-100 cursor-not-allowed"
}`} }`}
> >
<div className="flex items-center"> <div className="flex items-center">
<div className="bg-blue-100 rounded-lg p-1 mr-3"> <PlusIcon className="h-4 w-4 mr-3" />
<PlusIcon className="h-5 w-5 text-blue-600" /> <div className="text-left">
</div> <div className="font-medium">
<span>{loading === "topup" ? "Processing..." : "Top Up Data"}</span> {loading === "topup" ? "Processing..." : "Top Up Data"}
</div>
</button>
{/* Reissue eSIM (only for eSIMs) */}
{simType === "esim" && (
<button
onClick={() => {
setActiveInfo("reissue");
try {
router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
} catch {
setShowReissueConfirm(true);
}
}}
disabled={!canReissue || loading !== null}
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
canReissue && loading === null
? "text-green-700 bg-green-50 border-green-200 hover:bg-green-100 hover:border-green-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
: "text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed"
}`}
>
<div className="flex items-center">
<div className="bg-green-100 rounded-lg p-1 mr-3">
<ArrowPathIcon className="h-5 w-5 text-green-600" />
</div> </div>
<span>{loading === "reissue" ? "Processing..." : "Reissue eSIM"}</span> <div className="text-xs opacity-90">Add more data to your plan</div>
</div> </div>
</button>
)}
{/* Cancel SIM - Destructive Action */}
<button
onClick={() => {
setActiveInfo("cancel");
try {
router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
} catch {
// Fallback to inline confirmation modal if navigation is unavailable
setShowCancelConfirm(true);
}
}}
disabled={!canCancel || loading !== null}
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
canCancel && loading === null
? "text-red-700 bg-red-50 border-red-200 hover:bg-red-100 hover:border-red-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
: "text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed"
}`}
>
<div className="flex items-center">
<div className="bg-red-100 rounded-lg p-1 mr-3">
<XMarkIcon className="h-5 w-5 text-red-600" />
</div>
<span>{loading === "cancel" ? "Processing..." : "Cancel SIM"}</span>
</div> </div>
</button> </button>
@ -261,30 +180,81 @@ export function SimActions({
setShowChangePlanModal(true); setShowChangePlanModal(true);
} }
}} }}
disabled={loading !== null} disabled={!isActive || 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 ${ className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
loading === null isActive && 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-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-50 border-gray-300 cursor-not-allowed" : "text-gray-400 bg-gray-100 cursor-not-allowed"
}`} }`}
> >
<div className="flex items-center"> <div className="flex items-center">
<div className="bg-purple-100 rounded-lg p-1 mr-3"> <ArrowPathIcon className="h-4 w-4 mr-3" />
<svg <div className="text-left">
className="h-5 w-5 text-purple-600" <div className="font-medium">
fill="none" {loading === "change-plan" ? "Processing..." : "Change Plan"}
stroke="currentColor" </div>
viewBox="0 0 24 24" <div className="text-xs opacity-70">Switch to a different plan</div>
> </div>
<path </div>
strokeLinecap="round" </button>
strokeLinejoin="round"
strokeWidth={2} {/* Reissue SIM */}
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" <button
/> onClick={() => {
</svg> setActiveInfo("reissue");
setShowReissueModal(true);
}}
disabled={!canReissue || loading !== null}
className={`w-full flex flex-col items-start justify-start rounded-lg border px-4 py-4 text-left text-sm font-medium transition-all duration-200 ${
canReissue && loading === null
? "border-green-200 bg-green-50 text-green-900 hover:bg-green-100 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 active:scale-[0.98]"
: "text-gray-400 bg-gray-100 border-gray-200 cursor-not-allowed"
}`}
>
<div className="flex w-full items-center justify-between">
<div className="flex items-center">
<ArrowPathIcon className="h-4 w-4 mr-3" />
<div className="text-left">
<div className="font-medium">{"Reissue SIM"}</div>
<div className="text-xs opacity-70">
Configure replacement options and submit your request.
</div>
</div>
</div>
</div>
{!canReissue && reissueDisabledReason && (
<div className="mt-3 w-full rounded-md border border-yellow-200 bg-yellow-50 px-3 py-2 text-xs text-yellow-800">
{reissueDisabledReason}
</div>
)}
</button>
{/* Cancel SIM - Destructive Action */}
<button
onClick={() => {
setActiveInfo("cancel");
try {
router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
} catch {
// Fallback to inline confirmation modal if navigation is unavailable
setShowCancelConfirm(true);
}
}}
disabled={!canCancel || loading !== null}
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
canCancel && loading === null
? "text-red-700 bg-white border border-red-200 hover:bg-red-50 hover:border-red-300 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 active:scale-[0.98]"
: "text-gray-400 bg-gray-100 cursor-not-allowed"
}`}
>
<div className="flex items-center">
<XMarkIcon className="h-4 w-4 mr-3" />
<div className="text-left">
<div className="font-medium">
{loading === "cancel" ? "Processing..." : "Cancel SIM"}
</div>
<div className="text-xs opacity-70">Permanently cancel service</div>
</div> </div>
<span>Change Plan</span>
</div> </div>
</button> </button>
</div> </div>
@ -305,8 +275,7 @@ export function SimActions({
<div className="flex items-start"> <div className="flex items-start">
<ArrowPathIcon className="h-4 w-4 text-green-600 mr-2 mt-0.5 flex-shrink-0" /> <ArrowPathIcon className="h-4 w-4 text-green-600 mr-2 mt-0.5 flex-shrink-0" />
<div> <div>
<strong>Reissue eSIM:</strong> Generate a new eSIM profile for download. Use this <strong>Reissue SIM:</strong> 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.
if your previous download failed or you need to install on a new device.
</div> </div>
</div> </div>
)} )}
@ -382,54 +351,24 @@ export function SimActions({
/> />
)} )}
{/* Reissue eSIM Confirmation */} {/* Reissue SIM Modal */}
{showReissueConfirm && ( {showReissueModal && (
<div className="fixed inset-0 z-50 overflow-y-auto"> <ReissueSimModal
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> subscriptionId={subscriptionId}
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div> currentSimType={simType}
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"> onClose={() => {
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> setShowReissueModal(false);
<div className="sm:flex sm:items-start"> setActiveInfo(null);
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10"> }}
<ArrowPathIcon className="h-6 w-6 text-green-600" /> onSuccess={() => {
</div> setShowReissueModal(false);
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> setSuccess("SIM reissue request submitted successfully");
<h3 className="text-lg leading-6 font-medium text-gray-900"> onReissueSuccess?.();
Reissue eSIM Profile }}
</h3> onError={message => {
<div className="mt-2"> setError(message);
<p className="text-sm text-gray-500"> }}
This will generate a new eSIM profile for download. Your current eSIM will />
remain active until you activate the new profile.
</p>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
onClick={() => void handleReissueEsim()}
disabled={loading === "reissue"}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
>
{loading === "reissue" ? "Processing..." : "Reissue eSIM"}
</button>
<button
type="button"
onClick={() => {
setShowReissueConfirm(false);
setActiveInfo(null);
}}
disabled={loading === "reissue"}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Back
</button>
</div>
</div>
</div>
</div>
)} )}
{/* Cancel Confirmation */} {/* Cancel Confirmation */}

View File

@ -1,211 +1,430 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { formatSimPlanShort } from "@customer-portal/domain/sim";
import { import {
DevicePhoneMobileIcon, DevicePhoneMobileIcon,
WifiIcon,
SignalIcon,
ClockIcon,
CheckCircleIcon, CheckCircleIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
XCircleIcon, XCircleIcon,
ClockIcon,
} from "@heroicons/react/24/outline"; } 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 { interface SimDetailsCardProps {
simDetails: SimDetails; simDetails: SimDetails;
isLoading?: boolean; isLoading?: boolean;
error?: string | null; error?: string | null;
embedded?: boolean; embedded?: boolean; // when true, render content without card container
showFeaturesSummary?: boolean; showFeaturesSummary?: boolean; // show the right-side Service Features summary
} }
const STATUS_ICON_MAP: Record<SimStatus, React.ReactNode> = {
active: <CheckCircleIcon className="h-5 w-5 text-green-500" />,
suspended: <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />,
cancelled: <XCircleIcon className="h-5 w-5 text-red-500" />,
pending: <ClockIcon className="h-5 w-5 text-blue-500" />,
};
const STATUS_BADGE_CLASS_MAP: Record<SimStatus, string> = {
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 }) => (
<div className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">{label}</span>
<span
className={`text-xs font-semibold px-2 py-1 rounded-full ${
enabled ? "bg-green-100 text-green-700" : "bg-gray-200 text-gray-600"
}`}
>
{enabled ? "Enabled" : "Disabled"}
</span>
</div>
);
const LoadingCard = ({ embedded }: { embedded: boolean }) => (
<div
className={`${embedded ? "" : "bg-white shadow rounded-xl border border-gray-100"} p-6 lg:p-8`}
>
<div className="animate-pulse space-y-4">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-full bg-gray-200" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-1/2" />
<div className="h-4 bg-gray-200 rounded w-1/3" />
</div>
</div>
<div className="h-4 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="h-24 bg-gray-200 rounded" />
<div className="h-24 bg-gray-200 rounded" />
</div>
</div>
</div>
);
const ErrorCard = ({ embedded, message }: { embedded: boolean; message: string }) => (
<div
className={`${embedded ? "" : "bg-white shadow rounded-xl border border-red-100"} p-6 lg:p-8`}
>
<div className="text-center text-red-600 text-sm">{message}</div>
</div>
);
export function SimDetailsCard({ export function SimDetailsCard({
simDetails, simDetails,
isLoading = false, isLoading,
error = null, error,
embedded = false, embedded = false,
showFeaturesSummary = true, showFeaturesSummary = true,
}: SimDetailsCardProps) { }: 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 <CheckCircleIcon className="h-6 w-6 text-green-500" />;
case "suspended":
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
case "cancelled":
return <XCircleIcon className="h-6 w-6 text-red-500" />;
case "pending":
return <ClockIcon className="h-6 w-6 text-blue-500" />;
default:
return <DevicePhoneMobileIcon className="h-6 w-6 text-gray-500" />;
}
};
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) { if (isLoading) {
return <LoadingCard embedded={embedded} />; const Skeleton = (
<div
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300 "}p-6 lg:p-8`}
>
<div className="animate-pulse">
<div className="flex items-center space-x-4">
<div className="rounded-full bg-gradient-to-br from-blue-200 to-blue-300 h-14 w-14"></div>
<div className="flex-1 space-y-3">
<div className="h-5 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-3/4"></div>
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-1/2"></div>
</div>
</div>
<div className="mt-8 space-y-4">
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg"></div>
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-5/6"></div>
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-4/6"></div>
</div>
</div>
</div>
);
return Skeleton;
} }
if (error) { if (error) {
return <ErrorCard embedded={embedded} message={error} />; return (
<div
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-red-100 "}p-6 lg:p-8`}
>
<div className="text-center">
<div className="bg-red-50 rounded-full p-3 w-16 h-16 mx-auto mb-4">
<ExclamationTriangleIcon className="h-10 w-10 text-red-500 mx-auto" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Error Loading SIM Details</h3>
<p className="text-red-600 text-sm">{error}</p>
</div>
</div>
);
} }
const planName = simDetails.planName || formatSimPlanShort(simDetails.planCode) || "SIM Plan"; // Modern eSIM details view with usage visualization
const statusIcon = STATUS_ICON_MAP[simDetails.status] ?? ( if (simDetails.simType === "esim") {
<DevicePhoneMobileIcon className="h-5 w-5 text-gray-500" /> const remainingGB = simDetails.remainingQuotaMb / 1000;
); const totalGB = 1048.6; // Mock total - should come from API
const statusClass = STATUS_BADGE_CLASS_MAP[simDetails.status] ?? "bg-gray-100 text-gray-800"; const usedGB = totalGB - remainingGB;
const containerClasses = embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100"; const usagePercentage = (usedGB / totalGB) * 100;
return ( // Usage Sparkline Component
<div className={`${containerClasses} ${embedded ? "" : "p-6 lg:p-8"}`}> const UsageSparkline = ({ data }: { data: Array<{ date: string; usedMB: number }> }) => {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> const maxValue = Math.max(...data.map(d => d.usedMB), 1);
<div className="flex items-center gap-3"> const width = 80;
<div className="rounded-xl bg-blue-50 p-3"> const height = 16;
<DevicePhoneMobileIcon className="h-7 w-7 text-blue-600" />
</div> const points = data.map((d, i) => {
<div> const x = (i / (data.length - 1)) * width;
<h3 className="text-xl font-semibold text-gray-900">{planName}</h3> const y = height - (d.usedMB / maxValue) * height;
<p className="text-sm text-gray-600">Account #{simDetails.account}</p> return `${x},${y}`;
}).join(' ');
return (
<svg width={width} height={height} className="text-blue-500">
<polyline
fill="none"
stroke="currentColor"
strokeWidth="1.5"
points={points}
/>
</svg>
);
};
// 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 (
<div className="relative flex items-center justify-center">
<svg width={size} height={size} className="transform -rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="rgb(241 245 249)"
strokeWidth="8"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="rgb(59 130 246)"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
className="transition-all duration-300"
/>
</svg>
<div className="absolute text-center">
<div className="text-3xl font-semibold text-slate-900">{remainingGB.toFixed(1)}</div>
<div className="text-sm text-slate-500 -mt-1">GB remaining</div>
<div className="text-xs text-slate-400 mt-1">{usagePercentage.toFixed(1)}% used</div>
</div>
</div>
);
};
return (
<div className={`${embedded ? "" : "bg-white shadow-md rounded-xl border border-gray-100"}`}>
{/* Compact Header Bar */}
<div className={`${embedded ? "" : "px-6 py-4 border-b border-gray-200"}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span
className={`inline-flex px-3 py-1 text-xs font-medium rounded-full ${getStatusColor(simDetails.status)}`}
>
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
</span>
<span className="text-lg font-semibold text-slate-900">
{formatPlan(simDetails.planCode)}
</span>
</div>
</div>
<div className="text-sm text-slate-600 mt-1">{simDetails.msisdn}</div>
</div>
<div className={`${embedded ? "" : "px-6 py-6"}`}>
{/* Usage Visualization */}
<div className="flex justify-center mb-6">
<UsageDonut size={160} />
</div>
<div className="border-t border-gray-200 pt-4">
<h4 className="text-sm font-medium text-slate-900 mb-3">Recent Usage History</h4>
<div className="space-y-2">
{[
{ date: "Sep 29", usage: "0 MB" },
{ date: "Sep 28", usage: "0 MB" },
{ date: "Sep 27", usage: "0 MB" },
].map((entry, index) => (
<div key={index} className="flex justify-between items-center text-xs">
<span className="text-slate-600">{entry.date}</span>
<span className="text-slate-900">{entry.usage}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
// Default view for physical SIM cards
return (
<div className={`${embedded ? "" : "bg-white shadow-md rounded-xl border border-gray-100"}`}>
{/* Header */}
<div className={`${embedded ? "" : "px-6 py-4 border-b border-gray-200"}`}>
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="text-2xl mr-3">
<DevicePhoneMobileIcon className="h-8 w-8 text-blue-600" />
</div>
<div>
<h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
<p className="text-sm text-gray-500">
{formatPlan(simDetails.planCode)} {`${simDetails.size} SIM`}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
{getStatusIcon(simDetails.status)}
<span
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)}`}
>
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
</span>
</div> </div>
</div> </div>
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-full ${statusClass}`}>
{statusIcon}
<span className="text-sm font-medium capitalize">{simDetails.status}</span>
</span>
</div> </div>
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6"> {/* Content */}
<div className="space-y-4"> <div className={`${embedded ? "" : "px-6 py-4"}`}>
<section className="bg-gray-50 rounded-lg p-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3"> {/* SIM Information */}
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
SIM Information SIM Information
</h4> </h4>
<dl className="space-y-2 text-sm text-gray-700"> <div className="space-y-3">
<div className="flex justify-between"> <div>
<dt className="font-medium text-gray-600">Phone Number</dt> <label className="text-xs text-gray-500">Phone Number</label>
<dd className="font-semibold text-gray-900">{simDetails.msisdn}</dd> <p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p>
</div> </div>
<div className="flex justify-between">
<dt className="font-medium text-gray-600">SIM Type</dt> {simDetails.simType === "physical" && (
<dd className="font-semibold text-gray-900">{simDetails.simType}</dd> <div>
</div> <label className="text-xs text-gray-500">ICCID</label>
<div className="flex justify-between"> <p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p>
<dt className="font-medium text-gray-600">ICCID</dt>
<dd className="font-mono text-gray-900 break-all">{simDetails.iccid}</dd>
</div>
{simDetails.eid && (
<div className="flex justify-between">
<dt className="font-medium text-gray-600">EID</dt>
<dd className="font-mono text-gray-900 break-all">{simDetails.eid}</dd>
</div> </div>
)} )}
<div className="flex justify-between">
<dt className="font-medium text-gray-600">Network Type</dt>
<dd className="font-semibold text-gray-900">{simDetails.networkType}</dd>
</div>
</dl>
</section>
<section className="bg-gray-50 rounded-lg p-4"> {simDetails.eid && (
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3"> <div>
Data Remaining <label className="text-xs text-gray-500">EID (eSIM)</label>
</h4> <p className="text-sm font-mono text-gray-900 break-all">{simDetails.eid}</p>
<p className="text-2xl font-bold text-green-600"> </div>
{formatQuota(simDetails.remainingQuotaMb)} )}
</p>
<p className="text-xs text-gray-500 mt-1">Remaining allowance in current cycle</p>
</section>
<section className="bg-gray-50 rounded-lg p-4"> {simDetails.imsi && (
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3"> <div>
Activation Timeline <label className="text-xs text-gray-500">IMSI</label>
</h4> <p className="text-sm font-mono text-gray-900">{simDetails.imsi}</p>
<dl className="space-y-2 text-sm text-gray-700"> </div>
<div className="flex justify-between"> )}
<dt className="font-medium text-gray-600">Activated</dt>
<dd>{formatDate(simDetails.activatedAt)}</dd> {simDetails.startDate && (
<div>
<label className="text-xs text-gray-500">Service Start Date</label>
<p className="text-sm text-gray-900">{formatDate(simDetails.startDate)}</p>
</div>
)}
</div>
</div>
{/* Service Features */}
{showFeaturesSummary && (
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
Service Features
</h4>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500">Data Remaining</label>
<p className="text-lg font-semibold text-green-600">
{formatQuota(simDetails.remainingQuotaMb)}
</p>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center">
<SignalIcon
className={`h-4 w-4 mr-1 ${simDetails.hasVoice ? "text-green-500" : "text-gray-400"}`}
/>
<span
className={`text-sm ${simDetails.hasVoice ? "text-green-600" : "text-gray-500"}`}
>
Voice {simDetails.hasVoice ? "Enabled" : "Disabled"}
</span>
</div>
<div className="flex items-center">
<DevicePhoneMobileIcon
className={`h-4 w-4 mr-1 ${simDetails.hasSms ? "text-green-500" : "text-gray-400"}`}
/>
<span
className={`text-sm ${simDetails.hasSms ? "text-green-600" : "text-gray-500"}`}
>
SMS {simDetails.hasSms ? "Enabled" : "Disabled"}
</span>
</div>
</div>
{(simDetails.ipv4 || simDetails.ipv6) && (
<div>
<label className="text-xs text-gray-500">IP Address</label>
<div className="space-y-1">
{simDetails.ipv4 && (
<p className="text-sm font-mono text-gray-900">IPv4: {simDetails.ipv4}</p>
)}
{simDetails.ipv6 && (
<p className="text-sm font-mono text-gray-900">IPv6: {simDetails.ipv6}</p>
)}
</div>
</div>
)}
</div> </div>
<div className="flex justify-between"> </div>
<dt className="font-medium text-gray-600">Expires</dt> )}
<dd>{formatDate(simDetails.expiresAt)}</dd>
</div>
</dl>
</section>
</div> </div>
{showFeaturesSummary && ( {/* Pending Operations */}
<section className="space-y-3"> {simDetails.pendingOperations && simDetails.pendingOperations.length > 0 && (
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wide"> <div className="mt-6 pt-6 border-t border-gray-200">
Service Features <h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
Pending Operations
</h4> </h4>
<FeatureToggleRow label="Voice Mail" enabled={simDetails.voiceMailEnabled} /> <div className="bg-blue-50 rounded-lg p-4">
<FeatureToggleRow label="Call Waiting" enabled={simDetails.callWaitingEnabled} /> {simDetails.pendingOperations.map((operation, index) => (
<FeatureToggleRow <div key={index} className="flex items-center text-sm">
label="International Roaming" <ClockIcon className="h-4 w-4 text-blue-500 mr-2" />
enabled={simDetails.internationalRoamingEnabled} <span className="text-blue-800">
/> {operation.operation} scheduled for {formatDate(operation.scheduledDate)}
</section> </span>
</div>
))}
</div>
</div>
)} )}
</div> </div>
</div> </div>
); );
} }
export type { SimDetails };

View File

@ -1,11 +1,7 @@
"use client"; "use client";
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import {
buildSimFeaturesUpdatePayload,
type SimFeatureToggleSnapshot,
} from "@customer-portal/domain/sim";
interface SimFeatureTogglesProps { interface SimFeatureTogglesProps {
subscriptionId: number; subscriptionId: number;
@ -27,42 +23,37 @@ export function SimFeatureToggles({
embedded = false, embedded = false,
}: SimFeatureTogglesProps) { }: SimFeatureTogglesProps) {
// Initial values // Initial values
const initial = useMemo<SimFeatureToggleSnapshot>( const initial = useMemo(
() => ({ () => ({
voiceMailEnabled: !!voiceMailEnabled, vm: !!voiceMailEnabled,
callWaitingEnabled: !!callWaitingEnabled, cw: !!callWaitingEnabled,
internationalRoamingEnabled: !!internationalRoamingEnabled, ir: !!internationalRoamingEnabled,
networkType: networkType === "5G" ? "5G" : "4G", nt: networkType === "5G" ? "5G" : "4G",
}), }),
[voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType] [voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]
); );
// Working values // Working values
const [vm, setVm] = useState(initial.voiceMailEnabled); const [vm, setVm] = useState(initial.vm);
const [cw, setCw] = useState(initial.callWaitingEnabled); const [cw, setCw] = useState(initial.cw);
const [ir, setIr] = useState(initial.internationalRoamingEnabled); const [ir, setIr] = useState(initial.ir);
const [nt, setNt] = useState<"4G" | "5G">(initial.networkType); const [nt, setNt] = useState<"4G" | "5G">(initial.nt as "4G" | "5G");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const successTimerRef = useRef<number | null>(null);
useEffect(() => { useEffect(() => {
setVm(initial.voiceMailEnabled); setVm(initial.vm);
setCw(initial.callWaitingEnabled); setCw(initial.cw);
setIr(initial.internationalRoamingEnabled); setIr(initial.ir);
setNt(initial.networkType); setNt(initial.nt as "4G" | "5G");
}, [initial]); }, [initial.vm, initial.cw, initial.ir, initial.nt]);
const reset = () => { const reset = () => {
if (successTimerRef.current) { setVm(initial.vm);
clearTimeout(successTimerRef.current); setCw(initial.cw);
successTimerRef.current = null; setIr(initial.ir);
} setNt(initial.nt as "4G" | "5G");
setVm(initial.voiceMailEnabled);
setCw(initial.callWaitingEnabled);
setIr(initial.internationalRoamingEnabled);
setNt(initial.networkType);
setError(null); setError(null);
setSuccess(null); setSuccess(null);
}; };
@ -72,21 +63,22 @@ export function SimFeatureToggles({
setError(null); setError(null);
setSuccess(null); setSuccess(null);
try { try {
const featurePayload = buildSimFeaturesUpdatePayload(initial, { const featurePayload: {
voiceMailEnabled: vm, voiceMailEnabled?: boolean;
callWaitingEnabled: cw, callWaitingEnabled?: boolean;
internationalRoamingEnabled: ir, internationalRoamingEnabled?: boolean;
networkType: nt, 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", { await apiClient.POST("/api/subscriptions/{id}/sim/features", {
params: { path: { id: subscriptionId } }, params: { path: { id: subscriptionId } },
body: featurePayload, body: featurePayload,
}); });
} else {
setSuccess("No changes detected");
return;
} }
setSuccess("Changes submitted successfully"); setSuccess("Changes submitted successfully");
@ -95,224 +87,132 @@ export function SimFeatureToggles({
setError(e instanceof Error ? e.message : "Failed to submit changes"); setError(e instanceof Error ? e.message : "Failed to submit changes");
} finally { } finally {
setLoading(false); setLoading(false);
if (successTimerRef.current) { setTimeout(() => setSuccess(null), 3000);
clearTimeout(successTimerRef.current);
}
successTimerRef.current = window.setTimeout(() => {
setSuccess(null);
successTimerRef.current = null;
}, 3000);
} }
}; };
useEffect(() => {
return () => {
if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
successTimerRef.current = null;
}
};
}, []);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Service Options */} {/* Service Options */}
<div <div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-100 shadow-md"}`}>
className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 overflow-hidden"}`} <div className={`${embedded ? "" : "p-6"} space-y-4`}>
>
<div className={`${embedded ? "" : "p-6"} space-y-6`}>
{/* Voice Mail */} {/* Voice Mail */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0 p-4 bg-gray-50 rounded-lg border border-gray-200"> <div className="flex items-center justify-between py-4">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center space-x-3"> <div className="text-sm font-medium text-slate-900">Voice Mail</div>
<div className="bg-blue-100 rounded-lg p-2"> <div className="text-xs text-slate-500">¥300/month</div>
<svg
className="h-4 w-4 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/>
</svg>
</div>
<div>
<div className="text-sm font-semibold text-gray-900">Voice Mail</div>
<div className="text-xs text-gray-600">¥300/month</div>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-sm">
<span className="text-gray-500">Current: </span>
<span
className={`font-medium ${initial.voiceMailEnabled ? "text-green-600" : "text-gray-600"}`}
>
{initial.voiceMailEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="text-gray-400"></div>
<select
value={vm ? "Enabled" : "Disabled"}
onChange={e => setVm(e.target.value === "Enabled")}
className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
>
<option>Disabled</option>
<option>Enabled</option>
</select>
</div> </div>
<button
type="button"
role="switch"
aria-checked={vm}
onClick={() => setVm(!vm)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
vm ? "bg-blue-600" : "bg-gray-200"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
vm ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div> </div>
{/* Call Waiting */} {/* Call Waiting */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0 p-4 bg-gray-50 rounded-lg border border-gray-200"> <div className="flex items-center justify-between py-4">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center space-x-3"> <div className="text-sm font-medium text-slate-900">Call Waiting</div>
<div className="bg-purple-100 rounded-lg p-2"> <div className="text-xs text-slate-500">¥300/month</div>
<svg
className="h-4 w-4 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<div className="text-sm font-semibold text-gray-900">Call Waiting</div>
<div className="text-xs text-gray-600">¥300/month</div>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-sm">
<span className="text-gray-500">Current: </span>
<span
className={`font-medium ${initial.callWaitingEnabled ? "text-green-600" : "text-gray-600"}`}
>
{initial.callWaitingEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="text-gray-400"></div>
<select
value={cw ? "Enabled" : "Disabled"}
onChange={e => setCw(e.target.value === "Enabled")}
className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
>
<option>Disabled</option>
<option>Enabled</option>
</select>
</div> </div>
<button
type="button"
role="switch"
aria-checked={cw}
onClick={() => setCw(!cw)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
cw ? "bg-blue-600" : "bg-gray-200"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
cw ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div> </div>
{/* International Roaming */} {/* International Roaming */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0 p-4 bg-gray-50 rounded-lg border border-gray-200"> <div className="flex items-center justify-between py-4">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center space-x-3"> <div className="text-sm font-medium text-slate-900">International Roaming</div>
<div className="bg-green-100 rounded-lg p-2"> <div className="text-xs text-slate-500">Global connectivity</div>
<svg
className="h-4 w-4 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<div className="text-sm font-semibold text-gray-900">International Roaming</div>
<div className="text-xs text-gray-600">Global connectivity</div>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="text-sm">
<span className="text-gray-500">Current: </span>
<span
className={`font-medium ${initial.internationalRoamingEnabled ? "text-green-600" : "text-gray-600"}`}
>
{initial.internationalRoamingEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="text-gray-400"></div>
<select
value={ir ? "Enabled" : "Disabled"}
onChange={e => setIr(e.target.value === "Enabled")}
className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
>
<option>Disabled</option>
<option>Enabled</option>
</select>
</div> </div>
<button
type="button"
role="switch"
aria-checked={ir}
onClick={() => setIr(!ir)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
ir ? "bg-blue-600" : "bg-gray-200"
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
ir ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
</div> </div>
{/* Network Type */} <div className="border-t border-gray-200 pt-6">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0 p-4 bg-gray-50 rounded-lg border border-gray-200"> <div className="mb-4">
<div className="flex-1"> <div className="text-sm font-medium text-slate-900 mb-1">Network Type</div>
<div className="flex items-center space-x-3"> <div className="text-xs text-slate-500">Choose your preferred connectivity</div>
<div className="bg-orange-100 rounded-lg p-2"> <div className="text-xs text-red-600 mt-1">
<svg 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.
className="h-4 w-4 text-orange-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
/>
</svg>
</div>
<div>
<div className="text-sm font-semibold text-gray-900">Network Type</div>
<div className="text-xs text-gray-600">4G/5G connectivity</div>
</div>
</div> </div>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex gap-4">
<div className="text-sm"> <div className="flex items-center space-x-2">
<span className="text-gray-500">Current: </span> <input
<span className="font-medium text-blue-600">{initial.networkType}</span> type="radio"
id="4g"
name="networkType"
value="4G"
checked={nt === "4G"}
onChange={() => setNt("4G")}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<label htmlFor="4g" className="text-sm text-slate-700">
4G
</label>
</div>
<div className="flex items-center space-x-2">
<input
type="radio"
id="5g"
name="networkType"
value="5G"
checked={nt === "5G"}
onChange={() => setNt("5G")}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<label htmlFor="5g" className="text-sm text-slate-700">
5G
</label>
</div> </div>
<div className="text-gray-400"></div>
<select
value={nt}
onChange={e => setNt(e.target.value as "4G" | "5G")}
className="block w-20 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300"
>
<option value="4G">4G</option>
<option value="5G">5G</option>
</select>
</div> </div>
<p className="text-xs text-slate-500 mt-2">5G connectivity for enhanced speeds</p>
</div> </div>
</div> </div>
</div> </div>
{/* Notes and Actions */} {/* Notes and Actions */}
<div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 p-6"}`}> <div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 p-6"}`}>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="flex items-start"> <h4 className="text-sm font-medium text-blue-900 mb-2 flex items-center gap-2">
<svg <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="h-5 w-5 text-yellow-600 mt-0.5 mr-3 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
@ -320,21 +220,26 @@ export function SimFeatureToggles({
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </svg>
<div className="space-y-2 text-sm text-yellow-800"> Important Notes
<p> </h4>
<strong>Important Notes:</strong> <ul className="text-xs text-blue-800 space-y-1">
</p> <li className="flex items-start gap-2">
<ul className="list-disc list-inside space-y-1 ml-4"> <span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
<li>Changes will take effect instantaneously (approx. 30min)</li> Changes will take effect instantaneously (approx. 30min)
<li>May require smartphone/device restart after changes are applied</li> </li>
<li>5G requires a compatible smartphone/device. Will not function on 4G devices</li> <li className="flex items-start gap-2">
<li> <span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
Changes to Voice Mail / Call Waiting must be requested before the 25th of the May require smartphone device restart after changes are applied
month </li>
</li> <li className="flex items-start gap-2">
</ul> <span className="w-1 h-1 bg-red-600 rounded-full mt-1.5 flex-shrink-0"></span>
</div> <span className="text-red-600">Voice, network, and plan changes must be requested at least 30 minutes apart.</span>
</div> </li>
<li className="flex items-start gap-2">
<span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
Changes to Voice Mail / Call Waiting must be requested before the 25th of the month
</li>
</ul>
</div> </div>
{success && ( {success && (

View File

@ -1,84 +1,62 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback, useRef } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { import {
DevicePhoneMobileIcon, DevicePhoneMobileIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
ArrowPathIcon, ArrowPathIcon,
DocumentTextIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { SimDetailsCard } from "./SimDetailsCard"; import { type SimDetails } from "./SimDetailsCard";
import { DataUsageChart } from "./DataUsageChart";
import { SimActions } from "./SimActions";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import { SimFeatureToggles } from "./SimFeatureToggles"; import Link from "next/link";
import { simInfoSchema, type SimInfo } from "@customer-portal/domain/sim";
interface SimManagementSectionProps { interface SimManagementSectionProps {
subscriptionId: number; subscriptionId: number;
} }
interface SimInfo {
details: SimDetails;
usage?: {
todayUsageMb: number;
recentDaysUsage: Array<{ date: string; usageMb: number }>;
};
}
export function SimManagementSection({ subscriptionId }: SimManagementSectionProps) { export function SimManagementSection({ subscriptionId }: SimManagementSectionProps) {
const [simInfo, setSimInfo] = useState<SimInfo | null>(null); const [simInfo, setSimInfo] = useState<SimInfo | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const [activeTab, setActiveTab] = useState<"sim" | "invoices">("sim");
const isMountedRef = useRef(true);
useEffect(() => {
return () => {
isMountedRef.current = false;
abortControllerRef.current?.abort();
};
}, []);
const fetchSimInfo = useCallback(async () => { const fetchSimInfo = useCallback(async () => {
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
if (isMountedRef.current) {
setLoading(true);
setError(null);
}
try { try {
const response = await apiClient.GET<SimInfo>("/api/subscriptions/{id}/sim", { setError(null);
const response = await apiClient.GET("/api/subscriptions/{id}/sim", {
params: { path: { id: subscriptionId } }, 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"); throw new Error("Failed to load SIM information");
} }
const payload = simInfoSchema.parse(response.data);
if (controller.signal.aborted || !isMountedRef.current) {
return;
}
setSimInfo(payload); setSimInfo(payload);
} catch (err: unknown) { } catch (err: unknown) {
if (controller.signal.aborted || !isMountedRef.current) { const hasStatus = (v: unknown): v is { status: number } =>
return; typeof v === "object" &&
} v !== null &&
const hasStatus = (value: unknown): value is { status: number } => "status" in v &&
typeof value === "object" && typeof (v as { status: unknown }).status === "number";
value !== null &&
"status" in value &&
typeof (value as { status: unknown }).status === "number";
if (hasStatus(err) && err.status === 400) { if (hasStatus(err) && err.status === 400) {
// Not a SIM subscription - this component shouldn't be shown
setError("This subscription is not a SIM service"); 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 { } finally {
if (!controller.signal.aborted && isMountedRef.current) { setLoading(false);
setLoading(false);
}
} }
}, [subscriptionId]); }, [subscriptionId]);
@ -87,35 +65,41 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
}, [fetchSimInfo]); }, [fetchSimInfo]);
const handleRefresh = () => { const handleRefresh = () => {
setLoading(true);
void fetchSimInfo(); void fetchSimInfo();
}; };
const handleActionSuccess = () => { const handleActionSuccess = () => {
// Refresh SIM info after any successful action
void fetchSimInfo(); void fetchSimInfo();
}; };
if (loading) { if (loading) {
return ( return (
<div className="space-y-8"> <div className="min-h-screen bg-gray-50">
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-8"> {/* Header */}
<div className="flex items-center mb-6"> <div className="bg-[#2F80ED] rounded-b-3xl px-5 py-4">
<div className="bg-blue-50 rounded-xl p-2 mr-4"> <div className="flex items-center justify-between">
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600" /> <h1 className="text-white text-xl font-bold">Service Management</h1>
</div> <div className="flex gap-2">
<div> <div className="px-4 py-2 rounded-full border-2 border-white bg-white text-gray-900 text-sm font-medium">
<h2 className="text-2xl font-bold text-gray-900">SIM Management</h2> SIM Management
<p className="text-gray-600 mt-1">Loading your SIM service details...</p> </div>
<div className="px-4 py-2 rounded-full border-2 border-white text-white text-sm font-medium">
Invoices
</div>
</div> </div>
</div> </div>
<div className="animate-pulse space-y-6"> </div>
<div className="h-6 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-3/4"></div>
<div className="h-5 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-1/2"></div> {/* Loading Animation */}
<div className="h-48 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div> <div className="p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="animate-pulse space-y-4">
<div className="h-32 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div> <div className="h-24 bg-gray-200 rounded-2xl"></div>
<div className="h-32 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div> <div className="grid grid-cols-2 gap-4">
<div className="h-40 bg-gray-200 rounded-2xl"></div>
<div className="h-40 bg-gray-200 rounded-2xl"></div>
</div> </div>
<div className="h-12 bg-gray-200 rounded-full"></div>
</div> </div>
</div> </div>
</div> </div>
@ -124,27 +108,19 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
if (error) { if (error) {
return ( return (
<div className="bg-white shadow-lg rounded-xl border border-red-100 p-8"> <div className="min-h-screen bg-gray-50">
<div className="flex items-center mb-6"> <div className="bg-[#2F80ED] rounded-b-3xl px-5 py-4">
<div className="bg-blue-50 rounded-xl p-2 mr-4"> <h1 className="text-white text-xl font-bold">Service Management</h1>
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600" />
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900">SIM Management</h2>
<p className="text-gray-600 mt-1">Unable to load SIM information</p>
</div>
</div> </div>
<div className="text-center py-12"> <div className="p-5 text-center py-12">
<div className="bg-red-50 rounded-full p-4 w-20 h-20 mx-auto mb-6"> <div className="bg-red-50 rounded-full p-4 w-20 h-20 mx-auto mb-6">
<ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto" /> <ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto" />
</div> </div>
<h3 className="text-xl font-semibold text-gray-900 mb-3"> <h3 className="text-xl font-semibold text-gray-900 mb-3">Unable to Load SIM Information</h3>
Unable to Load SIM Information
</h3>
<p className="text-gray-600 mb-8 max-w-md mx-auto">{error}</p> <p className="text-gray-600 mb-8 max-w-md mx-auto">{error}</p>
<button <button
onClick={handleRefresh} onClick={handleRefresh}
className="inline-flex items-center px-6 py-3 border border-transparent text-sm font-semibold rounded-xl text-white bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 hover:shadow-lg hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200" className="inline-flex items-center px-6 py-3 rounded-full text-white bg-[#2F80ED] hover:bg-[#2671d9] font-semibold"
> >
<ArrowPathIcon className="h-5 w-5 mr-2" /> <ArrowPathIcon className="h-5 w-5 mr-2" />
Retry Retry
@ -158,106 +134,188 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
return null; return null;
} }
const actionSimType: "esim" | "physical" = const remainingGB = simInfo.details.remainingQuotaMb / 1000;
simInfo.details.simType.toLowerCase() === "esim" ? "esim" : "physical"; const usedMB = simInfo.usage?.todayUsageMb || 0;
const totalGB = 50; // Mock - should come from plan
const usagePercent = ((totalGB * 1000 - simInfo.details.remainingQuotaMb) / (totalGB * 1000)) * 100;
return ( return (
<div id="sim-management" className="space-y-8"> <div id="sim-management" className="min-h-screen bg-gray-50">
{/* SIM Details and Usage - Main Content */} {/* 1. Top Header Bar */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8"> <div className="bg-[#2F80ED] rounded-b-3xl px-5 py-4 shadow-lg">
{/* Main Content Area - Actions and Settings (Left Side) */} <div className="flex items-center justify-between">
<div className="order-2 xl:col-span-2 xl:order-1"> <h1 className="text-white text-[22px] font-bold">Service Management</h1>
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6"> <div className="flex gap-2">
<SimActions <button
subscriptionId={subscriptionId} onClick={() => setActiveTab("sim")}
simType={actionSimType} className={`px-4 py-2 rounded-full border-2 border-white text-sm font-medium transition-all ${
status={simInfo.details.status} activeTab === "sim"
currentPlanCode={simInfo.details.planCode} ? "bg-white text-gray-900"
onTopUpSuccess={handleActionSuccess} : "bg-transparent text-white"
onPlanChangeSuccess={handleActionSuccess} }`}
onCancelSuccess={handleActionSuccess} >
onReissueSuccess={handleActionSuccess} SIM Management
embedded={true} </button>
/> <Link
<div className="mt-6"> href={`/subscriptions/${subscriptionId}`}
<p className="text-sm text-gray-600 font-medium mb-3">Modify service options</p> className="px-4 py-2 rounded-full border-2 border-white text-white text-sm font-medium"
<SimFeatureToggles >
subscriptionId={subscriptionId} Invoices
voiceMailEnabled={simInfo.details.voiceMailEnabled} </Link>
callWaitingEnabled={simInfo.details.callWaitingEnabled} </div>
internationalRoamingEnabled={simInfo.details.internationalRoamingEnabled} </div>
networkType={simInfo.details.networkType} </div>
onChanged={handleActionSuccess}
embedded {/* Content */}
/> <div className="p-5 space-y-4">
{/* 2. Billing Information Section */}
<div className="bg-white rounded-2xl shadow-sm p-5">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-sm text-gray-500 mb-1">Monthly Cost</p>
<p className="text-base font-semibold text-[#1A1A1A]">¥3,100</p>
</div>
<div>
<p className="text-sm text-gray-500 mb-1">Next Billing</p>
<p className="text-base font-semibold text-[#1A1A1A]">Jul 1 2025</p>
</div>
<div>
<p className="text-sm text-gray-500 mb-1">Registered</p>
<p className="text-base font-semibold text-[#1A1A1A]">Aug 2 2023</p>
</div> </div>
</div> </div>
</div> </div>
{/* Sidebar - Compact Info (Right Side) */} {/* 3. Invoice & Data Usage Row */}
<div className="order-1 xl:order-2 space-y-8"> <div className="grid grid-cols-2 gap-4">
{/* Details + Usage combined card for mobile-first */} {/* Left Column - Invoice Card */}
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6 space-y-6"> <div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-2xl p-5 flex flex-col justify-between">
<SimDetailsCard <div>
simDetails={simInfo.details} <p className="text-sm text-gray-600 mb-2">Latest Invoice</p>
isLoading={false} <p className="text-3xl font-bold text-[#2F80ED] mb-4">3400 ¥</p>
error={null}
embedded={true}
showFeaturesSummary={false}
/>
<DataUsageChart
usage={simInfo.usage}
remainingQuotaMb={simInfo.details.remainingQuotaMb}
isLoading={false}
error={null}
embedded={true}
/>
</div>
{/* Important Information Card */}
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
<div className="flex items-center mb-4">
<div className="bg-blue-200 rounded-lg p-2 mr-3">
<svg
className="h-5 w-5 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h3 className="text-lg font-semibold text-blue-900">Important Information</h3>
</div> </div>
<ul className="space-y-2 text-sm text-blue-800"> <button className="w-full bg-[#2F80ED] text-white font-semibold py-3 rounded-full hover:bg-[#2671d9] transition-colors">
<li className="flex items-start"> PAY
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span> </button>
Data usage is updated in real-time and may take a few minutes to reflect recent
activity
</li>
<li className="flex items-start">
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
Top-up data will be available immediately after successful processing
</li>
<li className="flex items-start">
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
SIM cancellation is permanent and cannot be undone
</li>
{simInfo.details.simType === "esim" && (
<li className="flex items-start">
<span className="inline-block w-1.5 h-1.5 bg-blue-600 rounded-full mt-2 mr-3 flex-shrink-0"></span>
eSIM profile reissue will provide a new QR code for activation
</li>
)}
</ul>
</div> </div>
{/* (On desktop, details+usage are above; on mobile they appear first since this section is above actions) */} {/* Right Column - Data Usage Circle */}
<div className="bg-white rounded-2xl p-5 flex flex-col items-center justify-center">
<p className="text-xs text-gray-500 mb-2">Remaining data</p>
<div className="relative w-32 h-32">
<svg className="w-full h-full transform -rotate-90">
<circle
cx="64"
cy="64"
r="56"
fill="none"
stroke="#E5E7EB"
strokeWidth="8"
/>
<circle
cx="64"
cy="64"
r="56"
fill="none"
stroke="#4B8CF7"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={`${2 * Math.PI * 56}`}
strokeDashoffset={`${2 * Math.PI * 56 * (1 - usagePercent / 100)}`}
className="transition-all duration-500"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<p className="text-2xl font-bold text-gray-900">{remainingGB.toFixed(1)} GB</p>
<p className="text-sm text-[#D72828] font-medium">{usedMB.toFixed(2)} GB</p>
</div>
</div>
</div>
</div>
{/* 4. Top Up Button */}
<button className="w-full bg-[#2F80ED] text-white font-semibold py-3.5 rounded-full hover:bg-[#2671d9] transition-colors shadow-md">
Top Up Data
</button>
{/* 5. SIM Management Actions Section */}
<div className="bg-[#D7D7D7] rounded-2xl p-4">
<h2 className="text-base font-semibold text-gray-900 mb-3">SIM Management Actions</h2>
<div className="grid grid-cols-2 gap-3">
<button className="bg-[#E5E5E5] rounded-xl py-6 text-center font-medium text-gray-900 hover:bg-gray-300 transition-colors">
Top Up Data
</button>
<button className="bg-[#E5E5E5] rounded-xl py-6 text-center font-medium text-gray-900 hover:bg-gray-300 transition-colors">
Change Plan
</button>
<button className="bg-[#E5E5E5] rounded-xl py-6 text-center font-medium text-gray-900 hover:bg-gray-300 transition-colors">
Reissue SIM
</button>
<button className="bg-[#E5E5E5] rounded-xl py-6 text-center font-medium text-gray-900 hover:bg-gray-300 transition-colors">
Cancel SIM
</button>
</div>
</div>
{/* 6. Voice Status Section */}
<div className="bg-[#D7D7D7] rounded-2xl p-4">
<h2 className="text-base font-semibold text-gray-900 mb-3">Voice Status</h2>
<div className="grid grid-cols-2 gap-3">
<button
className={`rounded-xl py-6 text-center font-medium transition-colors ${
simInfo.details.voiceMailEnabled
? "bg-blue-100 text-blue-700 border-2 border-blue-400"
: "bg-[#E5E5E5] text-gray-900 hover:bg-gray-300"
}`}
>
Voice Mail
</button>
<button className="bg-[#E5E5E5] rounded-xl py-6 text-center font-medium text-gray-900 hover:bg-gray-300 transition-colors">
Network Type
<span className="block text-sm text-gray-600 mt-1">{simInfo.details.networkType || "4G"}</span>
</button>
<button
className={`rounded-xl py-6 text-center font-medium transition-colors ${
simInfo.details.callWaitingEnabled
? "bg-blue-100 text-blue-700 border-2 border-blue-400"
: "bg-[#E5E5E5] text-gray-900 hover:bg-gray-300"
}`}
>
Call Waiting
</button>
<button
className={`rounded-xl py-6 text-center font-medium transition-colors ${
simInfo.details.internationalRoamingEnabled
? "bg-blue-100 text-blue-700 border-2 border-blue-400"
: "bg-[#E5E5E5] text-gray-900 hover:bg-gray-300"
}`}
>
International Roaming
</button>
</div>
</div>
{/* 7. Important Notes Section */}
<div className="bg-white rounded-2xl shadow-sm p-5">
<h3 className="text-base font-semibold text-gray-900 mb-3">Important Notes</h3>
<ul className="space-y-2 text-[13px] text-[#7A7A7A] leading-relaxed">
<li className="flex items-start">
<span className="mr-2"></span>
<span>Changes to SIM settings typically take effect instantaneously (approx. 30min)</span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span>May require smartphone device restart after changes are applied</span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span>Voice/Network/Plan change requests must be requested at least 30 minutes apart</span>
</li>
<li className="flex items-start">
<span className="mr-2"></span>
<span>Changes to Voice Mail / Call Waiting must be requested before the 25th of the month</span>
</li>
</ul>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,13 +1,8 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState } from "react";
import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import { useSimTopUpPricing } from "../hooks/useSimTopUpPricing";
import {
simTopUpRequestSchema,
type SimTopUpPricingPreviewResponse,
} from "@customer-portal/domain/sim";
interface TopUpModalProps { interface TopUpModalProps {
subscriptionId: number; subscriptionId: number;
@ -19,22 +14,6 @@ interface TopUpModalProps {
export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) { export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) {
const [gbAmount, setGbAmount] = useState<string>("1"); const [gbAmount, setGbAmount] = useState<string>("1");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { pricing, loading: pricingLoading, calculatePreview } = useSimTopUpPricing();
const [preview, setPreview] = useState<SimTopUpPricingPreviewResponse | null>(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 getCurrentAmountMb = () => {
const gb = parseInt(gbAmount, 10); const gb = parseInt(gbAmount, 10);
@ -42,39 +21,35 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
}; };
const isValidAmount = () => { const isValidAmount = () => {
if (!pricing) return false; const gb = Number(gbAmount);
const mb = getCurrentAmountMb(); return Number.isInteger(gb) && gb >= 1 && gb <= 50; // 1-50 GB, whole numbers only (Freebit API limit)
return mb >= pricing.minQuotaMb && mb <= pricing.maxQuotaMb;
}; };
const displayCost = preview?.totalPriceJpy ?? 0; const calculateCost = () => {
const pricePerGb = pricing?.pricePerGbJpy ?? 500; const gb = parseInt(gbAmount, 10);
return isNaN(gb) ? 0 : gb * 500; // 1GB = 500 JPY
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!isValidAmount()) { if (!isValidAmount()) {
onError( onError("Please enter a whole number between 1 GB and 100 GB");
`Please enter a valid amount between ${pricing ? pricing.minQuotaMb / 1000 : 1} GB and ${pricing ? pricing.maxQuotaMb / 1000 : 50} GB`
);
return; return;
} }
setLoading(true); setLoading(true);
try { try {
const validationResult = simTopUpRequestSchema.safeParse({ const requestBody = {
quotaMb: getCurrentAmountMb(), quotaMb: getCurrentAmountMb(),
}); amount: calculateCost(),
currency: "JPY",
if (!validationResult.success) { };
onError(validationResult.error.issues[0]?.message ?? "Invalid top-up amount");
return;
}
await apiClient.POST("/api/subscriptions/{id}/sim/top-up", { await apiClient.POST("/api/subscriptions/{id}/sim/top-up", {
params: { path: { id: subscriptionId } }, params: { path: { id: subscriptionId } },
body: validationResult.data, body: requestBody,
}); });
onSuccess(); onSuccess();
@ -137,9 +112,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
</div> </div>
</div> </div>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
Enter the amount of data you want to add ( Enter the amount of data you want to add (1 - 50 GB, whole numbers)
{pricing ? `${pricing.minQuotaMb / 1000} - ${pricing.maxQuotaMb / 1000}` : "1 - 50"}{" "}
GB, whole numbers)
</p> </p>
</div> </div>
@ -154,21 +127,20 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-lg font-bold text-blue-900"> <div className="text-lg font-bold text-blue-900">
¥{displayCost.toLocaleString()} ¥{calculateCost().toLocaleString()}
</div> </div>
<div className="text-xs text-blue-700">(1GB = ¥{pricePerGb})</div> <div className="text-xs text-blue-700">(1GB = ¥500)</div>
</div> </div>
</div> </div>
</div> </div>
{/* Validation Warning */} {/* Validation Warning */}
{!isValidAmount() && gbAmount && pricing && ( {!isValidAmount() && gbAmount && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3"> <div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-center"> <div className="flex items-center">
<ExclamationTriangleIcon className="h-4 w-4 text-red-500 mr-2" /> <ExclamationTriangleIcon className="h-4 w-4 text-red-500 mr-2" />
<p className="text-sm text-red-800"> <p className="text-sm text-red-800">
Amount must be between {pricing.minQuotaMb / 1000} GB and{" "} Amount must be a whole number between 1 GB and 50 GB
{pricing.maxQuotaMb / 1000} GB
</p> </p>
</div> </div>
</div> </div>
@ -186,14 +158,10 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
</button> </button>
<button <button
type="submit" type="submit"
disabled={loading || !isValidAmount() || pricingLoading} disabled={loading || !isValidAmount()}
className="w-full sm:w-auto px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50" className="w-full sm:w-auto px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
> >
{loading {loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
? "Processing..."
: pricingLoading
? "Loading..."
: `Top Up Now - ¥${displayCost.toLocaleString()}`}
</button> </button>
</div> </div>
</form> </form>

View File

@ -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<string, string> = {
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;
}

0
scripts/bundle-analyze.sh Normal file → Executable file
View File

0
scripts/dev/manage.sh Normal file → Executable file
View File

0
scripts/migrate-imports.sh Normal file → Executable file
View File

0
scripts/plesk-deploy.sh Normal file → Executable file
View File

0
scripts/plesk/build-images.sh Normal file → Executable file
View File

0
scripts/prod/manage.sh Normal file → Executable file
View File

0
scripts/set-log-level.sh Normal file → Executable file
View File

0
scripts/validate-deps.sh Normal file → Executable file
View File