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

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

View File

@ -30,7 +30,10 @@ export class FreebitClientService {
const config = this.authService.getConfig();
const requestPayload = { ...payload, authKey };
const url = `${config.baseUrl}${endpoint}`;
// Ensure proper URL construction - remove double slashes
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const url = `${baseUrl}${cleanEndpoint}`;
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
try {
@ -52,6 +55,15 @@ export class FreebitClientService {
clearTimeout(timeout);
if (!response.ok) {
const errorText = await response.text().catch(() => "Unable to read response body");
this.logger.error(`Freebit API HTTP error`, {
url,
status: response.status,
statusText: response.statusText,
responseBody: errorText,
attempt,
payload: this.sanitizePayload(requestPayload),
});
throw new FreebitError(
`HTTP ${response.status}: ${response.statusText}`,
response.status.toString()
@ -60,18 +72,29 @@ export class FreebitClientService {
const responseData = (await response.json()) as TResponse;
if (responseData.resultCode && responseData.resultCode !== "100") {
const resultCode = this.normalizeResultCode(responseData.resultCode);
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
if (resultCode && resultCode !== "100") {
this.logger.warn("Freebit API returned error response", {
url,
resultCode,
statusCode,
statusMessage: responseData.status?.message,
fullResponse: responseData,
});
throw new FreebitError(
`API Error: ${responseData.status?.message || "Unknown error"}`,
responseData.resultCode,
responseData.status?.statusCode,
resultCode,
statusCode,
responseData.status?.message
);
}
this.logger.debug("Freebit API request successful", {
url,
resultCode: responseData.resultCode,
resultCode,
});
return responseData;
@ -117,7 +140,10 @@ export class FreebitClientService {
TPayload extends object,
>(endpoint: string, payload: TPayload): Promise<TResponse> {
const config = this.authService.getConfig();
const url = `${config.baseUrl}${endpoint}`;
// Ensure proper URL construction - remove double slashes
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const url = `${baseUrl}${cleanEndpoint}`;
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
try {
@ -147,18 +173,29 @@ export class FreebitClientService {
const responseData = (await response.json()) as TResponse;
if (responseData.resultCode && responseData.resultCode !== "100") {
const resultCode = this.normalizeResultCode(responseData.resultCode);
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
if (resultCode && resultCode !== "100") {
this.logger.error(`Freebit API returned error result code`, {
url,
resultCode,
statusCode,
message: responseData.status?.message,
responseData: this.sanitizePayload(responseData as unknown as Record<string, unknown>),
attempt,
});
throw new FreebitError(
`API Error: ${responseData.status?.message || "Unknown error"}`,
responseData.resultCode,
responseData.status?.statusCode,
resultCode,
statusCode,
responseData.status?.message
);
}
this.logger.debug("Freebit JSON API request successful", {
url,
resultCode: responseData.resultCode,
resultCode,
});
return responseData;
@ -204,7 +241,10 @@ export class FreebitClientService {
*/
async makeSimpleRequest(endpoint: string): Promise<boolean> {
const config = this.authService.getConfig();
const url = `${config.baseUrl}${endpoint}`;
// Ensure proper URL construction - remove double slashes
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
const url = `${baseUrl}${cleanEndpoint}`;
try {
const controller = new AbortController();
@ -243,4 +283,13 @@ export class FreebitClientService {
return sanitized;
}
private normalizeResultCode(code?: string | number | null): string | undefined {
if (code === undefined || code === null) {
return undefined;
}
const normalized = String(code).trim();
return normalized.length > 0 ? normalized : undefined;
}
}

View File

@ -81,6 +81,19 @@ export class FreebitError extends Error {
return "SIM service request timed out. Please try again.";
}
// Specific error codes
if (this.resultCode === "215" || this.statusCode === "215") {
return "Plan change failed. This may be due to: (1) Account has existing scheduled operations, (2) Invalid plan code for this account, (3) Account restrictions. Please check the Freebit Partner Tools for account status or contact support.";
}
if (this.resultCode === "381" || this.statusCode === "381") {
return "Network type change rejected. The current plan does not allow switching to the requested contract line. Adjust the plan first or contact support.";
}
if (this.resultCode === "382" || this.statusCode === "382") {
return "Network type change rejected because the contract line is not eligible for modification at this time. Please verify the SIM's status in Freebit before retrying.";
}
return "SIM operation failed. Please try again or contact support.";
}
}

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 { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { FreebitOperationException } from "@bff/core/exceptions/domain-exceptions";
import type { SimDetails, SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim";
import { Freebit as FreebitProvider } from "@customer-portal/domain/sim/providers/freebit";
import { FreebitClientService } from "./freebit-client.service";
import { FreebitMapperService } from "./freebit-mapper.service";
import { FreebitAuthService } from "./freebit-auth.service";
// Type imports from domain (following clean import pattern from README)
import type {
TopUpResponse,
PlanChangeResponse,
AddSpecResponse,
CancelPlanResponse,
EsimReissueResponse,
EsimAddAccountResponse,
EsimActivationResponse,
QuotaHistoryRequest,
FreebitTopUpRequest,
FreebitPlanChangeRequest,
FreebitCancelPlanRequest,
FreebitEsimReissueRequest,
FreebitEsimActivationRequest,
FreebitEsimActivationParams,
FreebitAccountDetailsRequest,
FreebitAccountDetailsResponse,
FreebitTrafficInfoRequest,
FreebitTrafficInfoResponse,
FreebitTopUpRequest,
FreebitTopUpResponse,
FreebitQuotaHistoryRequest,
FreebitQuotaHistoryResponse,
FreebitPlanChangeRequest,
FreebitPlanChangeResponse,
FreebitContractLineChangeRequest,
FreebitContractLineChangeResponse,
FreebitAddSpecRequest,
FreebitAddSpecResponse,
FreebitVoiceOptionSettings,
FreebitVoiceOptionRequest,
FreebitVoiceOptionResponse,
FreebitCancelPlanRequest,
FreebitCancelPlanResponse,
FreebitEsimReissueRequest,
FreebitEsimReissueResponse,
FreebitEsimAddAccountRequest,
FreebitAccountDetailsRaw,
FreebitTrafficInfoRaw,
FreebitQuotaHistoryRaw,
} from "@customer-portal/domain/sim/providers/freebit";
FreebitEsimAddAccountResponse,
FreebitEsimAccountActivationRequest,
FreebitEsimAccountActivationResponse,
SimDetails,
SimUsage,
SimTopUpHistory,
} from "../interfaces/freebit.types";
@Injectable()
export class FreebitOperationsService {
constructor(
private readonly client: FreebitClientService,
private readonly mapper: FreebitMapperService,
private readonly auth: FreebitAuthService,
@Inject(Logger) private readonly logger: Logger
@Inject(Logger) private readonly logger: Logger,
@Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: any
) {}
private readonly operationTimestamps = new Map<
string,
{
voice?: number;
network?: number;
plan?: number;
cancellation?: number;
}
>();
private getOperationWindow(account: string) {
if (!this.operationTimestamps.has(account)) {
this.operationTimestamps.set(account, {});
}
return this.operationTimestamps.get(account)!;
}
private assertOperationSpacing(account: string, op: "voice" | "network" | "plan") {
const windowMs = 30 * 60 * 1000;
const now = Date.now();
const entry = this.getOperationWindow(account);
if (op === "voice") {
if (entry.plan && now - entry.plan < windowMs) {
throw new BadRequestException(
"Voice feature changes must be at least 30 minutes apart from plan changes. Please try again later."
);
}
if (entry.network && now - entry.network < windowMs) {
throw new BadRequestException(
"Voice feature changes must be at least 30 minutes apart from network type updates. Please try again later."
);
}
}
if (op === "network") {
if (entry.voice && now - entry.voice < windowMs) {
throw new BadRequestException(
"Network type updates must be requested 30 minutes after voice option changes. Please try again later."
);
}
if (entry.plan && now - entry.plan < windowMs) {
throw new BadRequestException(
"Network type updates must be requested at least 30 minutes apart from plan changes. Please try again later."
);
}
}
if (op === "plan") {
if (entry.voice && now - entry.voice < windowMs) {
throw new BadRequestException(
"Plan changes must be requested 30 minutes after voice option changes. Please try again later."
);
}
if (entry.network && now - entry.network < windowMs) {
throw new BadRequestException(
"Plan changes must be requested 30 minutes after network type updates. Please try again later."
);
}
if (entry.cancellation) {
throw new BadRequestException(
"This subscription has a pending cancellation. Plan changes are no longer permitted."
);
}
}
}
private stampOperation(account: string, op: "voice" | "network" | "plan" | "cancellation") {
const entry = this.getOperationWindow(account);
entry[op] = Date.now();
}
/**
* Get SIM account details with endpoint fallback
*/
async getSimDetails(account: string): Promise<SimDetails> {
try {
const request: FreebitAccountDetailsRequest = FreebitProvider.schemas.accountDetails.parse({
const request: Omit<FreebitAccountDetailsRequest, "authKey"> = {
version: "2",
requestDatas: [{ kind: "MVNO", account }],
});
};
const config = this.auth.getConfig();
const configured = config.detailsEndpoint || "/master/getAcnt/";
@ -73,7 +150,7 @@ export class FreebitOperationsService {
])
);
let response: FreebitAccountDetailsRaw | undefined;
let response: FreebitAccountDetailsResponse | undefined;
let lastError: unknown;
for (const ep of candidates) {
@ -82,7 +159,7 @@ export class FreebitOperationsService {
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
}
response = await this.client.makeAuthenticatedRequest<
FreebitAccountDetailsRaw,
FreebitAccountDetailsResponse,
typeof request
>(ep, request);
break;
@ -98,14 +175,10 @@ export class FreebitOperationsService {
if (lastError instanceof Error) {
throw lastError;
}
throw new FreebitOperationException("Failed to get SIM details from any endpoint", {
operation: "getSimDetails",
account,
attemptedEndpoints: ["simDetailsHiho", "simDetailsGet"],
});
throw new Error("Failed to get SIM details from any endpoint");
}
return FreebitProvider.transformFreebitAccountDetails(response);
return await this.mapper.mapToSimDetails(response);
} catch (error) {
const message = getErrorMessage(error);
this.logger.error(`Failed to get SIM details for account ${account}`, {
@ -121,16 +194,14 @@ export class FreebitOperationsService {
*/
async getSimUsage(account: string): Promise<SimUsage> {
try {
const request: FreebitTrafficInfoRequest = FreebitProvider.schemas.trafficInfo.parse({
account,
});
const request: Omit<FreebitTrafficInfoRequest, "authKey"> = { account };
const response = await this.client.makeAuthenticatedRequest<
FreebitTrafficInfoRaw,
FreebitTrafficInfoResponse,
typeof request
>("/mvno/getTrafficInfo/", request);
return FreebitProvider.transformFreebitTrafficInfo(response);
return this.mapper.mapToSimUsage(response);
} catch (error) {
const message = getErrorMessage(error);
this.logger.error(`Failed to get SIM usage for account ${account}`, {
@ -150,26 +221,22 @@ export class FreebitOperationsService {
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
): Promise<void> {
try {
const payload: FreebitTopUpRequest = FreebitProvider.schemas.topUp.parse({
const quotaKb = Math.round(quotaMb * 1024);
const baseRequest: Omit<FreebitTopUpRequest, "authKey"> = {
account,
quotaMb,
options,
});
const quotaKb = Math.round(payload.quotaMb * 1024);
const baseRequest = {
account: payload.account,
quota: quotaKb,
quotaCode: payload.options?.campaignCode,
expire: payload.options?.expiryDate,
quotaCode: options.campaignCode,
expire: options.expiryDate,
};
const scheduled = Boolean(payload.options?.scheduledAt);
const scheduled = !!options.scheduledAt;
const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
const request = scheduled
? { ...baseRequest, runTime: payload.options?.scheduledAt }
: baseRequest;
const request = scheduled ? { ...baseRequest, runTime: options.scheduledAt } : baseRequest;
await this.client.makeAuthenticatedRequest<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}`, {
account,
@ -198,18 +265,18 @@ export class FreebitOperationsService {
toDate: string
): Promise<SimTopUpHistory> {
try {
const request: FreebitQuotaHistoryRequest = FreebitProvider.schemas.quotaHistory.parse({
const request: Omit<FreebitQuotaHistoryRequest, "authKey"> = {
account,
fromDate,
toDate,
});
};
const response = await this.client.makeAuthenticatedRequest<
FreebitQuotaHistoryRaw,
QuotaHistoryRequest
FreebitQuotaHistoryResponse,
typeof request
>("/mvno/getQuotaHistory/", request);
return FreebitProvider.transformFreebitQuotaHistory(response, account);
return this.mapper.mapToSimTopUpHistory(response, account);
} catch (error) {
const message = getErrorMessage(error);
this.logger.error(`Failed to get SIM top-up history for account ${account}`, {
@ -224,42 +291,92 @@ export class FreebitOperationsService {
/**
* Change SIM plan
* Uses PA05-21 changePlan endpoint
*
* IMPORTANT CONSTRAINTS:
* - Requires runTime parameter set to 1st of following month (YYYYMMDDHHmm format)
* - Does NOT take effect immediately (unlike PA05-06 and PA05-38)
* - Must be done AFTER PA05-06 and PA05-38 (with 30-minute gaps)
* - Cannot coexist with PA02-04 (cancellation) - plan changes will cancel the cancellation
* - Must run 30 minutes apart from PA05-06 and PA05-38
*/
async changeSimPlan(
account: string,
newPlanCode: string,
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
): Promise<{ ipv4?: string; ipv6?: string }> {
// Import and validate with the schema
const parsed: FreebitPlanChangeRequest = FreebitProvider.schemas.planChange.parse({
account,
newPlanCode,
assignGlobalIp: options.assignGlobalIp,
scheduledAt: options.scheduledAt,
});
try {
const request = {
account: parsed.account,
plancode: parsed.newPlanCode,
globalip: parsed.assignGlobalIp ? "1" : "0",
runTime: parsed.scheduledAt,
this.assertOperationSpacing(account, "plan");
// First, get current SIM details to log for debugging
let currentPlanCode: string | undefined;
try {
const simDetails = await this.getSimDetails(account);
currentPlanCode = simDetails.planCode;
this.logger.log(`Current SIM plan details before change`, {
account,
currentPlanCode: simDetails.planCode,
status: simDetails.status,
simType: simDetails.simType,
});
} catch (detailsError) {
this.logger.warn(`Could not fetch current SIM details`, {
account,
error: getErrorMessage(detailsError),
});
}
// PA05-21 requires runTime parameter in YYYYMMDD format (8 digits, date only)
// If not provided, default to 1st of next month
let runTime = options.scheduledAt || undefined;
if (!runTime) {
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1);
const year = nextMonth.getFullYear();
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
const day = "01";
runTime = `${year}${month}${day}`;
this.logger.log(`No scheduledAt provided, defaulting to 1st of next month: ${runTime}`, {
account,
runTime,
});
}
const request: Omit<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<
PlanChangeResponse,
FreebitPlanChangeResponse,
typeof request
>("/mvno/changePlan/", request);
this.logger.log(
`Successfully changed plan for account ${parsed.account} to ${parsed.newPlanCode}`,
{
account: parsed.account,
newPlanCode: parsed.newPlanCode,
assignGlobalIp: parsed.assignGlobalIp,
scheduled: Boolean(parsed.scheduledAt),
}
);
this.logger.log(`Successfully changed plan for account ${account} to ${newPlanCode}`, {
account,
newPlanCode,
assignGlobalIp: options.assignGlobalIp,
scheduled: !!options.scheduledAt,
response: {
resultCode: response.resultCode,
statusCode: response.status?.statusCode,
message: response.status?.message,
},
});
this.stampOperation(account, "plan");
return {
ipv4: response.ipv4,
@ -267,17 +384,48 @@ export class FreebitOperationsService {
};
} catch (error) {
const message = getErrorMessage(error);
this.logger.error(`Failed to change SIM plan for account ${account}`, {
// Extract Freebit error details if available
const errorDetails: Record<string, unknown> = {
account,
newPlanCode,
planCode: newPlanCode, // Use camelCase
globalip: options.assignGlobalIp ? "1" : undefined,
runTime: options.scheduledAt,
error: message,
});
};
if (error instanceof Error) {
errorDetails.errorName = error.name;
errorDetails.errorMessage = error.message;
// Check if it's a FreebitError with additional properties
if ('resultCode' in error) {
errorDetails.resultCode = error.resultCode;
}
if ('statusCode' in error) {
errorDetails.statusCode = error.statusCode;
}
if ('statusMessage' in error) {
errorDetails.statusMessage = error.statusMessage;
}
}
this.logger.error(`Failed to change SIM plan for account ${account}`, errorDetails);
throw new BadRequestException(`Failed to change SIM plan: ${message}`);
}
}
/**
* Update SIM features (voice options and network type)
*
* IMPORTANT TIMING CONSTRAINTS from Freebit API:
* - PA05-06 (voice features): Runs with immediate effect
* - PA05-38 (contract line): Runs with immediate effect
* - PA05-21 (plan change): Requires runTime parameter, scheduled for 1st of following month
* - These must run 30 minutes apart to avoid canceling each other
* - PA05-06 and PA05-38 should be done first, then PA05-21 last (since it's scheduled)
* - PA05-21 and PA02-04 (cancellation) cannot coexist
*/
async updateSimFeatures(
account: string,
@ -289,45 +437,76 @@ export class FreebitOperationsService {
}
): Promise<void> {
try {
// Import and validate with the new schema
const parsed = FreebitProvider.schemas.simFeatures.parse({
account,
const voiceFeatures = {
voiceMailEnabled: features.voiceMailEnabled,
callWaitingEnabled: features.callWaitingEnabled,
callForwardingEnabled: undefined, // Not supported in this interface yet
callerIdEnabled: undefined,
});
const payload: Record<string, unknown> = {
account: parsed.account,
internationalRoamingEnabled: features.internationalRoamingEnabled,
};
if (typeof parsed.voiceMailEnabled === "boolean") {
const flag = parsed.voiceMailEnabled ? "10" : "20";
payload.voiceMail = flag;
payload.voicemail = flag;
}
const hasVoiceFeatures = Object.values(voiceFeatures).some(value => typeof value === "boolean");
const hasNetworkTypeChange = typeof features.networkType === "string";
if (typeof parsed.callWaitingEnabled === "boolean") {
const flag = parsed.callWaitingEnabled ? "10" : "20";
payload.callWaiting = flag;
payload.callwaiting = flag;
}
// Execute in sequence with 30-minute delays as per Freebit API requirements
if (hasVoiceFeatures && hasNetworkTypeChange) {
// Both voice features and network type change requested
this.logger.log(`Updating both voice features and network type with required 30-minute delay`, {
account,
hasVoiceFeatures,
hasNetworkTypeChange,
});
if (typeof features.internationalRoamingEnabled === "boolean") {
const flag = features.internationalRoamingEnabled ? "10" : "20";
payload.worldWing = flag;
payload.worldwing = flag;
}
// Step 1: Update voice features immediately (PA05-06)
await this.updateVoiceFeatures(account, voiceFeatures);
this.logger.log(`Voice features updated, scheduling network type change in 30 minutes`, {
account,
networkType: features.networkType,
});
if (features.networkType) {
payload.contractLine = features.networkType;
}
// Step 2: Schedule network type change 30 minutes later (PA05-38)
// Note: This uses setTimeout which is not ideal for production
// Consider using a job queue like Bull or agenda for production
setTimeout(async () => {
try {
await this.updateNetworkType(account, features.networkType!);
this.logger.log(`Network type change completed after 30-minute delay`, {
account,
networkType: features.networkType,
});
} catch (error) {
this.logger.error(`Failed to update network type after 30-minute delay`, {
account,
networkType: features.networkType,
error: getErrorMessage(error),
});
}
}, 30 * 60 * 1000); // 30 minutes
await this.client.makeAuthenticatedRequest<AddSpecResponse, typeof payload>(
"/master/addSpec/",
payload
);
this.logger.log(`Voice features updated immediately, network type scheduled for 30 minutes`, {
account,
voiceMailEnabled: features.voiceMailEnabled,
callWaitingEnabled: features.callWaitingEnabled,
internationalRoamingEnabled: features.internationalRoamingEnabled,
networkType: features.networkType,
});
} else if (hasVoiceFeatures) {
// Only voice features (PA05-06)
await this.updateVoiceFeatures(account, voiceFeatures);
this.logger.log(`Voice features updated successfully`, {
account,
voiceMailEnabled: features.voiceMailEnabled,
callWaitingEnabled: features.callWaitingEnabled,
internationalRoamingEnabled: features.internationalRoamingEnabled,
});
} else if (hasNetworkTypeChange) {
// Only network type change (PA05-38)
await this.updateNetworkType(account, features.networkType!);
this.logger.log(`Network type updated successfully`, {
account,
networkType: features.networkType,
});
}
this.logger.log(`Successfully updated SIM features for account ${account}`, {
account,
@ -342,27 +521,221 @@ export class FreebitOperationsService {
account,
features,
error: message,
errorStack: error instanceof Error ? error.stack : undefined,
});
throw new BadRequestException(`Failed to update SIM features: ${message}`);
}
}
/**
* Update voice features (voicemail, call waiting, international roaming)
* Uses PA05-06 MVNO Voice Option Change endpoint - runs with immediate effect
*
* Error codes specific to PA05-06:
* - 243: Voice option (list) problem
* - 244: Voicemail parameter problem
* - 245: Call waiting parameter problem
* - 250: WORLD WING parameter problem
*/
private async updateVoiceFeatures(
account: string,
features: {
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
}
): Promise<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
* 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> {
try {
const parsed: FreebitCancelPlanRequest = FreebitProvider.schemas.cancelPlan.parse({
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
account,
runDate: scheduledAt,
});
const request = {
account: parsed.account,
runTime: parsed.runDate,
runTime: scheduledAt,
};
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/",
request
);
@ -371,6 +744,7 @@ export class FreebitOperationsService {
account,
runTime: scheduledAt,
});
this.stampOperation(account, "cancellation");
} catch (error) {
const message = getErrorMessage(error);
this.logger.error(`Failed to cancel SIM for account ${account}`, {
@ -387,11 +761,11 @@ export class FreebitOperationsService {
*/
async reissueEsimProfile(account: string): Promise<void> {
try {
const request = {
const request: Omit<FreebitEsimReissueRequest, "authKey"> = {
requestDatas: [{ kind: "MVNO", account }],
};
await this.client.makeAuthenticatedRequest<EsimReissueResponse, typeof request>(
await this.client.makeAuthenticatedRequest<FreebitEsimReissueResponse, typeof request>(
"/mvno/reissueEsim/",
request
);
@ -416,41 +790,25 @@ export class FreebitOperationsService {
options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {}
): Promise<void> {
try {
const parsed: FreebitEsimReissueRequest = FreebitProvider.schemas.esimReissue.parse({
account,
newEid,
oldEid: options.oldEid,
planCode: options.planCode,
oldProductNumber: options.oldProductNumber,
});
const requestPayload = FreebitProvider.schemas.esimAddAccount.parse({
const request: Omit<FreebitEsimAddAccountRequest, "authKey"> = {
aladinOperated: "20",
account: parsed.account,
eid: parsed.newEid,
account,
eid: newEid,
addKind: "R",
planCode: parsed.planCode,
});
const payload: FreebitEsimAddAccountRequest = {
...requestPayload,
authKey: await this.auth.getAuthKey(),
planCode: options.planCode,
};
await this.client.makeAuthenticatedRequest<
EsimAddAccountResponse,
FreebitEsimAddAccountRequest
>("/mvno/esim/addAcnt/", payload);
this.logger.log(
`Successfully reissued eSIM profile via addAcnt for account ${parsed.account}`,
{
account: parsed.account,
newEid: parsed.newEid,
oldProductNumber: parsed.oldProductNumber,
oldEid: parsed.oldEid,
}
await this.client.makeAuthenticatedRequest<FreebitEsimAddAccountResponse, typeof request>(
"/mvno/esim/addAcnt/",
request
);
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
account,
newEid,
oldProductNumber: options.oldProductNumber,
oldEid: options.oldEid,
});
} catch (error) {
const message = getErrorMessage(error);
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
@ -472,7 +830,12 @@ export class FreebitOperationsService {
contractLine?: "4G" | "5G";
aladinOperated?: "10" | "20";
shipDate?: string;
mnp?: { reserveNumber: string; reserveExpireDate: string };
addKind?: "N" | "M" | "R"; // N:新規, M:MNP転入, R:再発行
simKind?: "E0" | "E2" | "E3"; // E0:音声あり, E2:SMSなし, E3:SMSあり (Required except when addKind='R')
repAccount?: string; // 代表番号
deliveryCode?: string; // 顧客コード
globalIp?: "10" | "20"; // 10:なし, 20:あり
mnp?: { reserveNumber: string; reserveExpireDate?: string };
identity?: {
firstnameKanji?: string;
lastnameKanji?: string;
@ -489,55 +852,58 @@ export class FreebitOperationsService {
contractLine,
aladinOperated = "10",
shipDate,
addKind,
simKind,
repAccount,
deliveryCode,
globalIp,
mnp,
identity,
} = params;
// Import schemas dynamically to avoid circular dependencies
const validatedParams: FreebitEsimActivationParams =
FreebitProvider.schemas.esimActivationParams.parse({
account,
eid,
planCode,
contractLine,
aladinOperated,
shipDate,
mnp,
identity,
});
if (!validatedParams.account || !validatedParams.eid) {
if (!account || !eid) {
throw new BadRequestException("activateEsimAccountNew requires account and eid");
}
try {
const payload: FreebitEsimActivationRequest = {
authKey: await this.auth.getAuthKey(),
aladinOperated: validatedParams.aladinOperated,
createType: "new",
account: validatedParams.account,
eid: validatedParams.eid,
simkind: "esim",
planCode: validatedParams.planCode,
contractLine: validatedParams.contractLine,
shipDate: validatedParams.shipDate,
...(validatedParams.mnp ? { mnp: validatedParams.mnp } : {}),
...(validatedParams.identity ? validatedParams.identity : {}),
};
const finalAddKind = addKind || "N";
// Validate the full API request payload
FreebitProvider.schemas.esimActivationRequest.parse(payload);
// Validate simKind: Required except when addKind is 'R' (reissue)
if (finalAddKind !== "R" && !simKind) {
throw new BadRequestException(
"simKind is required for eSIM activation (use 'E0' for voice, 'E3' for SMS, 'E2' for data-only)"
);
}
try {
const payload: FreebitEsimAccountActivationRequest = {
authKey: await this.auth.getAuthKey(),
aladinOperated,
createType: "new",
eid,
account,
simkind: simKind || "E0", // Default to voice-enabled if not specified
addKind: finalAddKind,
planCode,
contractLine,
shipDate,
repAccount,
deliveryCode,
globalIp,
...(mnp ? { mnp } : {}),
...(identity ? identity : {}),
} as FreebitEsimAccountActivationRequest;
// Use JSON request for PA05-41
await this.client.makeAuthenticatedJsonRequest<EsimActivationResponse, typeof payload>(
"/mvno/esim/addAcct/",
payload
);
await this.client.makeAuthenticatedJsonRequest<
FreebitEsimAccountActivationResponse,
FreebitEsimAccountActivationRequest
>("/mvno/esim/addAcct/", payload);
this.logger.log("Successfully activated new eSIM account via PA05-41", {
account,
planCode,
contractLine,
addKind: addKind || "N",
scheduled: !!shipDate,
mnp: !!mnp,
});
@ -547,6 +913,7 @@ export class FreebitOperationsService {
account,
eid,
planCode,
addKind,
error: message,
});
throw new BadRequestException(`Failed to activate new eSIM account: ${message}`);

View File

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

View File

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

View File

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

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

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

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 { apiClient } from "@/lib/api";
import { XMarkIcon } from "@heroicons/react/24/outline";
import {
SIM_PLAN_OPTIONS,
type SimPlanCode,
getSimPlanLabel,
} from "@customer-portal/domain/sim";
import { mapToSimplifiedFormat } from "../utils/plan";
interface ChangePlanModalProps {
subscriptionId: number;
@ -24,9 +20,16 @@ export function ChangePlanModal({
onSuccess,
onError,
}: ChangePlanModalProps) {
const allowedPlans = SIM_PLAN_OPTIONS.filter(option => option.code !== currentPlanCode);
const PLAN_CODES = ["5GB", "10GB", "25GB", "50GB"] as const;
type PlanCode = (typeof PLAN_CODES)[number];
const [newPlanCode, setNewPlanCode] = useState<"" | SimPlanCode>("");
const normalizedCurrentPlan = mapToSimplifiedFormat(currentPlanCode);
const allowedPlans = (PLAN_CODES as readonly PlanCode[]).filter(
code => code !== (normalizedCurrentPlan as PlanCode)
);
const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>("");
const [loading, setLoading] = useState(false);
const submit = async () => {
@ -78,13 +81,13 @@ export function ChangePlanModal({
</label>
<select
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"
>
<option value="">Choose a plan</option>
{allowedPlans.map(option => (
<option key={option.code} value={option.code}>
{getSimPlanLabel(option.code)}
{allowedPlans.map(code => (
<option key={code} value={code}>
{code}
</option>
))}
</select>

View File

@ -2,7 +2,18 @@
import React from "react";
import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import type { SimUsage } from "@customer-portal/domain/sim";
export interface SimUsage {
account: string;
todayUsageKb: number;
todayUsageMb: number;
recentDaysUsage: Array<{
date: string;
usageKb: number;
usageMb: number;
}>;
isBlacklisted: boolean;
}
interface DataUsageChartProps {
usage: SimUsage;
@ -210,19 +221,6 @@ export function DataUsageChart({
)}
{/* Warnings */}
{usage.isBlacklisted && (
<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 && (
<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";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
PlusIcon,
@ -11,18 +11,13 @@ import {
} from "@heroicons/react/24/outline";
import { TopUpModal } from "./TopUpModal";
import { ChangePlanModal } from "./ChangePlanModal";
import { ReissueSimModal } from "./ReissueSimModal";
import { apiClient } from "@/lib/api";
import {
canTopUpSim,
canReissueEsim,
canCancelSim,
type SimStatus,
} from "@customer-portal/domain/sim";
interface SimActionsProps {
subscriptionId: number;
simType: "physical" | "esim";
status: SimStatus;
status: string;
onTopUpSuccess?: () => void;
onPlanChangeSuccess?: () => void;
onCancelSuccess?: () => void;
@ -45,7 +40,7 @@ export function SimActions({
const router = useRouter();
const [showTopUpModal, setShowTopUpModal] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [showReissueConfirm, setShowReissueConfirm] = useState(false);
const [showReissueModal, setShowReissueModal] = useState(false);
const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
@ -54,29 +49,17 @@ export function SimActions({
"topup" | "reissue" | "cancel" | "changePlan" | null
>(null);
const isActiveStatus = canTopUpSim(status);
const canTopUp = isActiveStatus;
const canReissue = simType === "esim" && canReissueEsim(status);
const canCancel = canCancelSim(status);
const isActive = status === "active";
const canTopUp = isActive;
const canReissue = isActive;
const canCancel = isActive;
const handleReissueEsim = async () => {
setLoading("reissue");
setError(null);
try {
await apiClient.POST("/api/subscriptions/{id}/sim/reissue-esim", {
params: { path: { id: subscriptionId } },
});
setSuccess("eSIM profile reissued successfully");
setShowReissueConfirm(false);
onReissueSuccess?.();
} catch (error: unknown) {
setError(error instanceof Error ? error.message : "Failed to reissue eSIM profile");
} finally {
setLoading(null);
const reissueDisabledReason = useMemo(() => {
if (!isActive) {
return "SIM must be active to request a reissue.";
}
};
return null;
}, [isActive]);
const handleCancelSim = async () => {
setLoading("cancel");
@ -85,6 +68,7 @@ export function SimActions({
try {
await apiClient.POST("/api/subscriptions/{id}/sim/cancel", {
params: { path: { id: subscriptionId } },
body: {},
});
setSuccess("SIM service cancelled successfully");
@ -112,32 +96,17 @@ export function SimActions({
return (
<div
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 */}
<div className={`${embedded ? "" : "px-6 lg:px-8 py-5 border-b border-gray-200"}`}>
<div className="flex items-center">
<div className="bg-blue-50 rounded-xl p-2 mr-4">
<svg
className="h-6 w-6 text-blue-600"
fill="none"
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>
{!embedded && (
<div className="px-6 py-6 border-b border-gray-200">
<h3 className="text-lg font-semibold tracking-tight text-slate-900 mb-1">
SIM Management Actions
</h3>
<p className="text-sm text-slate-600">Manage your SIM service</p>
</div>
</div>
)}
{/* Content */}
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
@ -160,7 +129,7 @@ export function SimActions({
</div>
)}
{!isActiveStatus && (
{!isActive && (
<div className="mb-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-center">
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 mr-2" />
@ -172,7 +141,7 @@ export function SimActions({
)}
{/* Action Buttons */}
<div className={`grid gap-4 ${embedded ? "grid-cols-1" : "grid-cols-2"}`}>
<div className="space-y-3">
{/* Top Up Data - Primary Action */}
<button
onClick={() => {
@ -184,70 +153,20 @@ export function SimActions({
}
}}
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
? "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-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed"
? "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-100 cursor-not-allowed"
}`}
>
<div className="flex items-center">
<div className="bg-blue-100 rounded-lg p-1 mr-3">
<PlusIcon className="h-5 w-5 text-blue-600" />
</div>
<span>{loading === "topup" ? "Processing..." : "Top Up Data"}</span>
</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" />
<PlusIcon className="h-4 w-4 mr-3" />
<div className="text-left">
<div className="font-medium">
{loading === "topup" ? "Processing..." : "Top Up Data"}
</div>
<span>{loading === "reissue" ? "Processing..." : "Reissue eSIM"}</span>
<div className="text-xs opacity-90">Add more data to your plan</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>
</button>
@ -261,30 +180,81 @@ export function SimActions({
setShowChangePlanModal(true);
}
}}
disabled={loading !== null}
className={`group relative flex items-center justify-center px-6 py-4 border-2 border-dashed rounded-xl text-sm font-semibold transition-all duration-200 ${
loading === null
? "text-purple-700 bg-purple-50 border-purple-300 hover:bg-purple-100 hover:border-purple-400 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
: "text-gray-400 bg-gray-50 border-gray-300 cursor-not-allowed"
disabled={!isActive || loading !== null}
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
isActive && loading === null
? "text-slate-700 bg-slate-100 hover:bg-slate-200 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 active:scale-[0.98]"
: "text-gray-400 bg-gray-100 cursor-not-allowed"
}`}
>
<div className="flex items-center">
<div className="bg-purple-100 rounded-lg p-1 mr-3">
<svg
className="h-5 w-5 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
/>
</svg>
<ArrowPathIcon className="h-4 w-4 mr-3" />
<div className="text-left">
<div className="font-medium">
{loading === "change-plan" ? "Processing..." : "Change Plan"}
</div>
<div className="text-xs opacity-70">Switch to a different plan</div>
</div>
</div>
</button>
{/* Reissue SIM */}
<button
onClick={() => {
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>
<span>Change Plan</span>
</div>
</button>
</div>
@ -305,8 +275,7 @@ export function SimActions({
<div className="flex items-start">
<ArrowPathIcon className="h-4 w-4 text-green-600 mr-2 mt-0.5 flex-shrink-0" />
<div>
<strong>Reissue eSIM:</strong> Generate a new eSIM profile for download. Use this
if your previous download failed or you need to install on a new device.
<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.
</div>
</div>
)}
@ -382,54 +351,24 @@ export function SimActions({
/>
)}
{/* Reissue eSIM Confirmation */}
{showReissueConfirm && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
<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">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<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" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Reissue eSIM Profile
</h3>
<div className="mt-2">
<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>
{/* Reissue SIM Modal */}
{showReissueModal && (
<ReissueSimModal
subscriptionId={subscriptionId}
currentSimType={simType}
onClose={() => {
setShowReissueModal(false);
setActiveInfo(null);
}}
onSuccess={() => {
setShowReissueModal(false);
setSuccess("SIM reissue request submitted successfully");
onReissueSuccess?.();
}}
onError={message => {
setError(message);
}}
/>
)}
{/* Cancel Confirmation */}

View File

@ -1,211 +1,430 @@
"use client";
import React from "react";
import { formatSimPlanShort } from "@customer-portal/domain/sim";
import {
DevicePhoneMobileIcon,
WifiIcon,
SignalIcon,
ClockIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
XCircleIcon,
ClockIcon,
} from "@heroicons/react/24/outline";
import type { SimDetails, SimStatus } from "@customer-portal/domain/sim";
// Inline formatPlanShort function
function formatPlanShort(planCode?: string): string {
if (!planCode) return "—";
const m = planCode.match(/(?:^|[_-])(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
if (m && m[1]) {
return `${m[1]}G`;
}
// Try extracting trailing number+G anywhere in the string
const m2 = planCode.match(/(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
if (m2 && m2[1]) {
return `${m2[1]}G`;
}
return planCode;
}
export interface SimDetails {
account: string;
msisdn: string;
iccid?: string;
imsi?: string;
eid?: string;
planCode: string;
status: "active" | "suspended" | "cancelled" | "pending";
simType: "physical" | "esim";
size: "standard" | "nano" | "micro" | "esim";
hasVoice: boolean;
hasSms: boolean;
remainingQuotaKb: number;
remainingQuotaMb: number;
startDate?: string;
ipv4?: string;
ipv6?: string;
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: string;
pendingOperations?: Array<{
operation: string;
scheduledDate: string;
}>;
}
interface SimDetailsCardProps {
simDetails: SimDetails;
isLoading?: boolean;
error?: string | null;
embedded?: boolean;
showFeaturesSummary?: boolean;
embedded?: boolean; // when true, render content without card container
showFeaturesSummary?: boolean; // show the right-side Service Features summary
}
const STATUS_ICON_MAP: Record<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({
simDetails,
isLoading = false,
error = null,
isLoading,
error,
embedded = false,
showFeaturesSummary = true,
}: SimDetailsCardProps) {
const formatPlan = (code?: string) => {
const formatted = formatPlanShort(code);
// Remove "PASI" prefix if present
return formatted?.replace(/^PASI\s*/, "") || formatted;
};
const getStatusIcon = (status: string) => {
switch (status) {
case "active":
return <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) {
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) {
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";
const statusIcon = STATUS_ICON_MAP[simDetails.status] ?? (
<DevicePhoneMobileIcon className="h-5 w-5 text-gray-500" />
);
const statusClass = STATUS_BADGE_CLASS_MAP[simDetails.status] ?? "bg-gray-100 text-gray-800";
const containerClasses = embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100";
// Modern eSIM details view with usage visualization
if (simDetails.simType === "esim") {
const remainingGB = simDetails.remainingQuotaMb / 1000;
const totalGB = 1048.6; // Mock total - should come from API
const usedGB = totalGB - remainingGB;
const usagePercentage = (usedGB / totalGB) * 100;
return (
<div className={`${containerClasses} ${embedded ? "" : "p-6 lg:p-8"}`}>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-blue-50 p-3">
<DevicePhoneMobileIcon className="h-7 w-7 text-blue-600" />
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900">{planName}</h3>
<p className="text-sm text-gray-600">Account #{simDetails.account}</p>
// Usage Sparkline Component
const UsageSparkline = ({ data }: { data: Array<{ date: string; usedMB: number }> }) => {
const maxValue = Math.max(...data.map(d => d.usedMB), 1);
const width = 80;
const height = 16;
const points = data.map((d, i) => {
const x = (i / (data.length - 1)) * width;
const y = height - (d.usedMB / maxValue) * height;
return `${x},${y}`;
}).join(' ');
return (
<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>
<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 className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
<section className="bg-gray-50 rounded-lg p-4">
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
{/* Content */}
<div className={`${embedded ? "" : "px-6 py-4"}`}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* SIM Information */}
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
SIM Information
</h4>
<dl className="space-y-2 text-sm text-gray-700">
<div className="flex justify-between">
<dt className="font-medium text-gray-600">Phone Number</dt>
<dd className="font-semibold text-gray-900">{simDetails.msisdn}</dd>
<div className="space-y-3">
<div>
<label className="text-xs text-gray-500">Phone Number</label>
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p>
</div>
<div className="flex justify-between">
<dt className="font-medium text-gray-600">SIM Type</dt>
<dd className="font-semibold text-gray-900">{simDetails.simType}</dd>
</div>
<div className="flex justify-between">
<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>
{simDetails.simType === "physical" && (
<div>
<label className="text-xs text-gray-500">ICCID</label>
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p>
</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">
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
Data Remaining
</h4>
<p className="text-2xl font-bold text-green-600">
{formatQuota(simDetails.remainingQuotaMb)}
</p>
<p className="text-xs text-gray-500 mt-1">Remaining allowance in current cycle</p>
</section>
{simDetails.eid && (
<div>
<label className="text-xs text-gray-500">EID (eSIM)</label>
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.eid}</p>
</div>
)}
<section className="bg-gray-50 rounded-lg p-4">
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
Activation Timeline
</h4>
<dl className="space-y-2 text-sm text-gray-700">
<div className="flex justify-between">
<dt className="font-medium text-gray-600">Activated</dt>
<dd>{formatDate(simDetails.activatedAt)}</dd>
{simDetails.imsi && (
<div>
<label className="text-xs text-gray-500">IMSI</label>
<p className="text-sm font-mono text-gray-900">{simDetails.imsi}</p>
</div>
)}
{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 className="flex justify-between">
<dt className="font-medium text-gray-600">Expires</dt>
<dd>{formatDate(simDetails.expiresAt)}</dd>
</div>
</dl>
</section>
</div>
)}
</div>
{showFeaturesSummary && (
<section className="space-y-3">
<h4 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Service Features
{/* Pending Operations */}
{simDetails.pendingOperations && simDetails.pendingOperations.length > 0 && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
Pending Operations
</h4>
<FeatureToggleRow label="Voice Mail" enabled={simDetails.voiceMailEnabled} />
<FeatureToggleRow label="Call Waiting" enabled={simDetails.callWaitingEnabled} />
<FeatureToggleRow
label="International Roaming"
enabled={simDetails.internationalRoamingEnabled}
/>
</section>
<div className="bg-blue-50 rounded-lg p-4">
{simDetails.pendingOperations.map((operation, index) => (
<div key={index} className="flex items-center text-sm">
<ClockIcon className="h-4 w-4 text-blue-500 mr-2" />
<span className="text-blue-800">
{operation.operation} scheduled for {formatDate(operation.scheduledDate)}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
export type { SimDetails };

View File

@ -1,11 +1,7 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { apiClient } from "@/lib/api";
import {
buildSimFeaturesUpdatePayload,
type SimFeatureToggleSnapshot,
} from "@customer-portal/domain/sim";
interface SimFeatureTogglesProps {
subscriptionId: number;
@ -27,42 +23,37 @@ export function SimFeatureToggles({
embedded = false,
}: SimFeatureTogglesProps) {
// Initial values
const initial = useMemo<SimFeatureToggleSnapshot>(
const initial = useMemo(
() => ({
voiceMailEnabled: !!voiceMailEnabled,
callWaitingEnabled: !!callWaitingEnabled,
internationalRoamingEnabled: !!internationalRoamingEnabled,
networkType: networkType === "5G" ? "5G" : "4G",
vm: !!voiceMailEnabled,
cw: !!callWaitingEnabled,
ir: !!internationalRoamingEnabled,
nt: networkType === "5G" ? "5G" : "4G",
}),
[voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]
);
// Working values
const [vm, setVm] = useState(initial.voiceMailEnabled);
const [cw, setCw] = useState(initial.callWaitingEnabled);
const [ir, setIr] = useState(initial.internationalRoamingEnabled);
const [nt, setNt] = useState<"4G" | "5G">(initial.networkType);
const [vm, setVm] = useState(initial.vm);
const [cw, setCw] = useState(initial.cw);
const [ir, setIr] = useState(initial.ir);
const [nt, setNt] = useState<"4G" | "5G">(initial.nt as "4G" | "5G");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const successTimerRef = useRef<number | null>(null);
useEffect(() => {
setVm(initial.voiceMailEnabled);
setCw(initial.callWaitingEnabled);
setIr(initial.internationalRoamingEnabled);
setNt(initial.networkType);
}, [initial]);
setVm(initial.vm);
setCw(initial.cw);
setIr(initial.ir);
setNt(initial.nt as "4G" | "5G");
}, [initial.vm, initial.cw, initial.ir, initial.nt]);
const reset = () => {
if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
successTimerRef.current = null;
}
setVm(initial.voiceMailEnabled);
setCw(initial.callWaitingEnabled);
setIr(initial.internationalRoamingEnabled);
setNt(initial.networkType);
setVm(initial.vm);
setCw(initial.cw);
setIr(initial.ir);
setNt(initial.nt as "4G" | "5G");
setError(null);
setSuccess(null);
};
@ -72,21 +63,22 @@ export function SimFeatureToggles({
setError(null);
setSuccess(null);
try {
const featurePayload = buildSimFeaturesUpdatePayload(initial, {
voiceMailEnabled: vm,
callWaitingEnabled: cw,
internationalRoamingEnabled: ir,
networkType: nt,
});
const featurePayload: {
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: "4G" | "5G";
} = {};
if (vm !== initial.vm) featurePayload.voiceMailEnabled = vm;
if (cw !== initial.cw) featurePayload.callWaitingEnabled = cw;
if (ir !== initial.ir) featurePayload.internationalRoamingEnabled = ir;
if (nt !== initial.nt) featurePayload.networkType = nt;
if (featurePayload) {
if (Object.keys(featurePayload).length > 0) {
await apiClient.POST("/api/subscriptions/{id}/sim/features", {
params: { path: { id: subscriptionId } },
body: featurePayload,
});
} else {
setSuccess("No changes detected");
return;
}
setSuccess("Changes submitted successfully");
@ -95,224 +87,132 @@ export function SimFeatureToggles({
setError(e instanceof Error ? e.message : "Failed to submit changes");
} finally {
setLoading(false);
if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
}
successTimerRef.current = window.setTimeout(() => {
setSuccess(null);
successTimerRef.current = null;
}, 3000);
setTimeout(() => setSuccess(null), 3000);
}
};
useEffect(() => {
return () => {
if (successTimerRef.current) {
clearTimeout(successTimerRef.current);
successTimerRef.current = null;
}
};
}, []);
return (
<div className="space-y-6">
{/* Service Options */}
<div
className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 overflow-hidden"}`}
>
<div className={`${embedded ? "" : "p-6"} space-y-6`}>
<div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-100 shadow-md"}`}>
<div className={`${embedded ? "" : "p-6"} space-y-4`}>
{/* 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 items-center space-x-3">
<div className="bg-blue-100 rounded-lg p-2">
<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 className="text-sm font-medium text-slate-900">Voice Mail</div>
<div className="text-xs text-slate-500">¥300/month</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>
{/* 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 items-center space-x-3">
<div className="bg-purple-100 rounded-lg p-2">
<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 className="text-sm font-medium text-slate-900">Call Waiting</div>
<div className="text-xs text-slate-500">¥300/month</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>
{/* 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 items-center space-x-3">
<div className="bg-green-100 rounded-lg p-2">
<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 className="text-sm font-medium text-slate-900">International Roaming</div>
<div className="text-xs text-slate-500">Global connectivity</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>
{/* Network Type */}
<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-1">
<div className="flex items-center space-x-3">
<div className="bg-orange-100 rounded-lg p-2">
<svg
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 className="border-t border-gray-200 pt-6">
<div className="mb-4">
<div className="text-sm font-medium text-slate-900 mb-1">Network Type</div>
<div className="text-xs text-slate-500">Choose your preferred connectivity</div>
<div className="text-xs text-red-600 mt-1">
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.
</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 text-blue-600">{initial.networkType}</span>
<div className="flex gap-4">
<div className="flex items-center space-x-2">
<input
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 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>
<p className="text-xs text-slate-500 mt-2">5G connectivity for enhanced speeds</p>
</div>
</div>
</div>
{/* Notes and Actions */}
<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="flex items-start">
<svg
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"
>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h4 className="text-sm font-medium text-blue-900 mb-2 flex items-center gap-2">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="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"
/>
</svg>
<div className="space-y-2 text-sm text-yellow-800">
<p>
<strong>Important Notes:</strong>
</p>
<ul className="list-disc list-inside space-y-1 ml-4">
<li>Changes will take effect instantaneously (approx. 30min)</li>
<li>May require smartphone/device restart after changes are applied</li>
<li>5G requires a compatible smartphone/device. Will not function on 4G devices</li>
<li>
Changes to Voice Mail / Call Waiting must be requested before the 25th of the
month
</li>
</ul>
</div>
</div>
Important Notes
</h4>
<ul className="text-xs text-blue-800 space-y-1">
<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 will take effect instantaneously (approx. 30min)
</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>
May require smartphone device restart after changes are applied
</li>
<li className="flex items-start gap-2">
<span className="w-1 h-1 bg-red-600 rounded-full mt-1.5 flex-shrink-0"></span>
<span className="text-red-600">Voice, network, and plan changes must be requested at least 30 minutes apart.</span>
</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>
{success && (

View File

@ -1,84 +1,62 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import React, { useState, useEffect, useCallback } from "react";
import {
DevicePhoneMobileIcon,
ExclamationTriangleIcon,
ArrowPathIcon,
DocumentTextIcon,
} from "@heroicons/react/24/outline";
import { SimDetailsCard } from "./SimDetailsCard";
import { DataUsageChart } from "./DataUsageChart";
import { SimActions } from "./SimActions";
import { type SimDetails } from "./SimDetailsCard";
import { apiClient } from "@/lib/api";
import { SimFeatureToggles } from "./SimFeatureToggles";
import { simInfoSchema, type SimInfo } from "@customer-portal/domain/sim";
import Link from "next/link";
interface SimManagementSectionProps {
subscriptionId: number;
}
interface SimInfo {
details: SimDetails;
usage?: {
todayUsageMb: number;
recentDaysUsage: Array<{ date: string; usageMb: number }>;
};
}
export function SimManagementSection({ subscriptionId }: SimManagementSectionProps) {
const [simInfo, setSimInfo] = useState<SimInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const isMountedRef = useRef(true);
useEffect(() => {
return () => {
isMountedRef.current = false;
abortControllerRef.current?.abort();
};
}, []);
const [activeTab, setActiveTab] = useState<"sim" | "invoices">("sim");
const fetchSimInfo = useCallback(async () => {
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
if (isMountedRef.current) {
setLoading(true);
setError(null);
}
try {
const response = await apiClient.GET<SimInfo>("/api/subscriptions/{id}/sim", {
setError(null);
const response = await apiClient.GET("/api/subscriptions/{id}/sim", {
params: { path: { id: subscriptionId } },
signal: controller.signal,
});
if (!response.data) {
const payload = response.data as { details: SimDetails; usage: any } | undefined;
if (!payload) {
throw new Error("Failed to load SIM information");
}
const payload = simInfoSchema.parse(response.data);
if (controller.signal.aborted || !isMountedRef.current) {
return;
}
setSimInfo(payload);
} catch (err: unknown) {
if (controller.signal.aborted || !isMountedRef.current) {
return;
}
const hasStatus = (value: unknown): value is { status: number } =>
typeof value === "object" &&
value !== null &&
"status" in value &&
typeof (value as { status: unknown }).status === "number";
const hasStatus = (v: unknown): v is { status: number } =>
typeof v === "object" &&
v !== null &&
"status" in v &&
typeof (v as { status: unknown }).status === "number";
if (hasStatus(err) && err.status === 400) {
// Not a SIM subscription - this component shouldn't be shown
setError("This subscription is not a SIM service");
return;
} else {
setError(err instanceof Error ? err.message : "Failed to load SIM information");
}
setError(err instanceof Error ? err.message : "Failed to load SIM information");
} finally {
if (!controller.signal.aborted && isMountedRef.current) {
setLoading(false);
}
setLoading(false);
}
}, [subscriptionId]);
@ -87,35 +65,41 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
}, [fetchSimInfo]);
const handleRefresh = () => {
setLoading(true);
void fetchSimInfo();
};
const handleActionSuccess = () => {
// Refresh SIM info after any successful action
void fetchSimInfo();
};
if (loading) {
return (
<div className="space-y-8">
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-8">
<div className="flex items-center mb-6">
<div className="bg-blue-50 rounded-xl p-2 mr-4">
<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">Loading your SIM service details...</p>
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-[#2F80ED] rounded-b-3xl px-5 py-4">
<div className="flex items-center justify-between">
<h1 className="text-white text-xl font-bold">Service Management</h1>
<div className="flex gap-2">
<div className="px-4 py-2 rounded-full border-2 border-white bg-white text-gray-900 text-sm font-medium">
SIM Management
</div>
<div className="px-4 py-2 rounded-full border-2 border-white text-white text-sm font-medium">
Invoices
</div>
</div>
</div>
<div className="animate-pulse space-y-6">
<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>
<div className="h-48 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="h-32 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div>
<div className="h-32 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div>
</div>
{/* Loading Animation */}
<div className="p-5 space-y-4">
<div className="animate-pulse space-y-4">
<div className="h-24 bg-gray-200 rounded-2xl"></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 className="h-12 bg-gray-200 rounded-full"></div>
</div>
</div>
</div>
@ -124,27 +108,19 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
if (error) {
return (
<div className="bg-white shadow-lg rounded-xl border border-red-100 p-8">
<div className="flex items-center mb-6">
<div className="bg-blue-50 rounded-xl p-2 mr-4">
<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 className="min-h-screen bg-gray-50">
<div className="bg-[#2F80ED] rounded-b-3xl px-5 py-4">
<h1 className="text-white text-xl font-bold">Service Management</h1>
</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">
<ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">
Unable to Load SIM Information
</h3>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Unable to Load SIM Information</h3>
<p className="text-gray-600 mb-8 max-w-md mx-auto">{error}</p>
<button
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" />
Retry
@ -158,106 +134,188 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
return null;
}
const actionSimType: "esim" | "physical" =
simInfo.details.simType.toLowerCase() === "esim" ? "esim" : "physical";
const remainingGB = simInfo.details.remainingQuotaMb / 1000;
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 (
<div id="sim-management" className="space-y-8">
{/* SIM Details and Usage - Main Content */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
{/* Main Content Area - Actions and Settings (Left Side) */}
<div className="order-2 xl:col-span-2 xl:order-1">
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
<SimActions
subscriptionId={subscriptionId}
simType={actionSimType}
status={simInfo.details.status}
currentPlanCode={simInfo.details.planCode}
onTopUpSuccess={handleActionSuccess}
onPlanChangeSuccess={handleActionSuccess}
onCancelSuccess={handleActionSuccess}
onReissueSuccess={handleActionSuccess}
embedded={true}
/>
<div className="mt-6">
<p className="text-sm text-gray-600 font-medium mb-3">Modify service options</p>
<SimFeatureToggles
subscriptionId={subscriptionId}
voiceMailEnabled={simInfo.details.voiceMailEnabled}
callWaitingEnabled={simInfo.details.callWaitingEnabled}
internationalRoamingEnabled={simInfo.details.internationalRoamingEnabled}
networkType={simInfo.details.networkType}
onChanged={handleActionSuccess}
embedded
/>
<div id="sim-management" className="min-h-screen bg-gray-50">
{/* 1. Top Header Bar */}
<div className="bg-[#2F80ED] rounded-b-3xl px-5 py-4 shadow-lg">
<div className="flex items-center justify-between">
<h1 className="text-white text-[22px] font-bold">Service Management</h1>
<div className="flex gap-2">
<button
onClick={() => setActiveTab("sim")}
className={`px-4 py-2 rounded-full border-2 border-white text-sm font-medium transition-all ${
activeTab === "sim"
? "bg-white text-gray-900"
: "bg-transparent text-white"
}`}
>
SIM Management
</button>
<Link
href={`/subscriptions/${subscriptionId}`}
className="px-4 py-2 rounded-full border-2 border-white text-white text-sm font-medium"
>
Invoices
</Link>
</div>
</div>
</div>
{/* 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>
{/* Sidebar - Compact Info (Right Side) */}
<div className="order-1 xl:order-2 space-y-8">
{/* Details + Usage combined card for mobile-first */}
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6 space-y-6">
<SimDetailsCard
simDetails={simInfo.details}
isLoading={false}
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>
{/* 3. Invoice & Data Usage Row */}
<div className="grid grid-cols-2 gap-4">
{/* Left Column - Invoice Card */}
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-2xl p-5 flex flex-col justify-between">
<div>
<p className="text-sm text-gray-600 mb-2">Latest Invoice</p>
<p className="text-3xl font-bold text-[#2F80ED] mb-4">3400 ¥</p>
</div>
<ul className="space-y-2 text-sm text-blue-800">
<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>
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>
<button className="w-full bg-[#2F80ED] text-white font-semibold py-3 rounded-full hover:bg-[#2671d9] transition-colors">
PAY
</button>
</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>

View File

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