Refactor Freebit service and update SIM management components for improved clarity and functionality

- Consolidated and cleaned up type definitions in Freebit service interfaces for better readability.
- Enhanced error handling and logging in Freebit service methods to provide clearer feedback.
- Updated SIM management pages to streamline user interactions and improve UI components.
- Removed deprecated subscription detail page and restructured routing for better navigation.
- Added new notice and info row components in SIM cancellation page for improved user experience.
This commit is contained in:
T. Narantuya 2025-09-11 16:21:54 +09:00
parent 86cd636b87
commit bef5abcbda
11 changed files with 209 additions and 1203 deletions

View File

@ -180,7 +180,8 @@ export class OrderFulfillmentOrchestrator {
} }
// Extract configurations from the original payload // Extract configurations from the original payload
const configurations = payload.configurations || {}; const configurations: Record<string, unknown> =
(payload.configurations as Record<string, unknown> | undefined) ?? {};
await this.simFulfillmentService.fulfillSimOrder({ await this.simFulfillmentService.fulfillSimOrder({
orderDetails: context.orderDetails, orderDetails: context.orderDetails,

View File

@ -89,8 +89,8 @@ export class SimFulfillmentService {
activationType: "Immediate" | "Scheduled"; activationType: "Immediate" | "Scheduled";
scheduledAt?: string; scheduledAt?: string;
mnp?: { mnp?: {
reserveNumber: string; reserveNumber?: string;
reserveExpireDate: string; reserveExpireDate?: string;
account?: string; account?: string;
firstnameKanji?: string; firstnameKanji?: string;
lastnameKanji?: string; lastnameKanji?: string;
@ -124,10 +124,13 @@ export class SimFulfillmentService {
planCode: planSku, planCode: planSku,
contractLine: "5G", contractLine: "5G",
shipDate: activationType === "Scheduled" ? scheduledAt : undefined, shipDate: activationType === "Scheduled" ? scheduledAt : undefined,
mnp: mnp ? { mnp:
reserveNumber: mnp.reserveNumber, mnp && mnp.reserveNumber && mnp.reserveExpireDate
reserveExpireDate: mnp.reserveExpireDate, ? {
} : undefined, reserveNumber: mnp.reserveNumber,
reserveExpireDate: mnp.reserveExpireDate,
}
: undefined,
identity: mnp ? { identity: mnp ? {
firstnameKanji: mnp.firstnameKanji, firstnameKanji: mnp.firstnameKanji,
lastnameKanji: mnp.lastnameKanji, lastnameKanji: mnp.lastnameKanji,

View File

@ -1,17 +1,4 @@
import { import { Inject, Injectable, BadRequestException, InternalServerErrorException } from "@nestjs/common";
Injectable,
Inject,
BadRequestException,
InternalServerErrorException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import {
Injectable,
Inject,
BadRequestException,
InternalServerErrorException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import type { import type {
@ -30,46 +17,41 @@ import type {
FreebititPlanChangeResponse, FreebititPlanChangeResponse,
FreebititCancelPlanRequest, FreebititCancelPlanRequest,
FreebititCancelPlanResponse, FreebititCancelPlanResponse,
FreebititEsimReissueRequest,
FreebititEsimReissueResponse,
FreebititEsimAddAccountRequest, FreebititEsimAddAccountRequest,
FreebititEsimAddAccountResponse, FreebititEsimAddAccountResponse,
FreebititEsimAccountActivationRequest,
FreebititEsimAccountActivationResponse,
FreebititAddSpecRequest,
FreebititAddSpecResponse,
SimDetails, SimDetails,
SimUsage, SimUsage,
SimTopUpHistory, SimTopUpHistory,
FreebititAddSpecRequest,
FreebititAddSpecResponse,
} from "./interfaces/freebit.types"; } from "./interfaces/freebit.types";
@Injectable() @Injectable()
export class FreebititService { export class FreebititService {
private readonly config: FreebititConfig; private readonly config: FreebititConfig;
private authKeyCache: { private authKeyCache: { token: string; expiresAt: number } | null = null;
token: string;
expiresAt: number;
} | null = null;
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
@Inject(Logger) private readonly logger: Logger
) { ) {
this.config = { this.config = {
baseUrl: baseUrl: this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api",
this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api",
oemId: this.configService.get<string>("FREEBIT_OEM_ID") || "PASI", oemId: this.configService.get<string>("FREEBIT_OEM_ID") || "PASI",
oemKey: this.configService.get<string>("FREEBIT_OEM_KEY") || "", oemKey: this.configService.get<string>("FREEBIT_OEM_KEY") || "",
timeout: this.configService.get<number>("FREEBIT_TIMEOUT") || 30000, timeout: this.configService.get<number>("FREEBIT_TIMEOUT") || 30000,
retryAttempts: this.configService.get<number>("FREEBIT_RETRY_ATTEMPTS") || 3, retryAttempts: this.configService.get<number>("FREEBIT_RETRY_ATTEMPTS") || 3,
detailsEndpoint: detailsEndpoint: this.configService.get<string>("FREEBIT_DETAILS_ENDPOINT") || "/master/getAcnt/",
this.configService.get<string>("FREEBIT_DETAILS_ENDPOINT") || "/master/getAcnt/",
}; };
// Warn if critical configuration is missing
if (!this.config.oemKey) { if (!this.config.oemKey) {
this.logger.warn("FREEBIT_OEM_KEY is not configured. SIM management features will not work."); this.logger.warn("FREEBIT_OEM_KEY is not configured. SIM management features will not work.");
this.logger.warn("FREEBIT_OEM_KEY is not configured. SIM management features will not work.");
} }
this.logger.debug("Freebit service initialized", {
this.logger.debug("Freebit service initialized", { this.logger.debug("Freebit service initialized", {
baseUrl: this.config.baseUrl, baseUrl: this.config.baseUrl,
oemId: this.config.oemId, oemId: this.config.oemId,
@ -77,21 +59,8 @@ export class FreebititService {
}); });
} }
/** private mapSimStatus(status: string): "active" | "suspended" | "cancelled" | "pending" {
* Map Freebit SIM status to portal status switch (status) {
*/
private mapSimStatus(freebititStatus: string): "active" | "suspended" | "cancelled" | "pending" {
private mapSimStatus(freebititStatus: string): "active" | "suspended" | "cancelled" | "pending" {
switch (freebititStatus) {
case "active":
return "active";
case "suspended":
return "suspended";
case "temporary":
case "waiting":
return "pending";
case "obsolete":
return "cancelled";
case "active": case "active":
return "active"; return "active";
case "suspended": case "suspended":
@ -103,38 +72,24 @@ export class FreebititService {
return "cancelled"; return "cancelled";
default: default:
return "pending"; return "pending";
return "pending";
} }
} }
/**
* Get or refresh authentication token
*/
private async getAuthKey(): Promise<string> { private async getAuthKey(): Promise<string> {
// Check if we have a valid cached token
if (this.authKeyCache && this.authKeyCache.expiresAt > Date.now()) { if (this.authKeyCache && this.authKeyCache.expiresAt > Date.now()) {
return this.authKeyCache.token; return this.authKeyCache.token;
} }
try { try {
// Check if configuration is available
if (!this.config.oemKey) { if (!this.config.oemKey) {
throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing"); throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing");
throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing");
} }
const request: FreebititAuthRequest = { const request: FreebititAuthRequest = { oemId: this.config.oemId, oemKey: this.config.oemKey };
oemId: this.config.oemId,
oemKey: this.config.oemKey,
};
const response = await fetch(`${this.config.baseUrl}/authOem/`, { const response = await fetch(`${this.config.baseUrl}/authOem/`, {
method: "POST", method: "POST",
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Type": "application/x-www-form-urlencoded",
},
body: `json=${JSON.stringify(request)}`, body: `json=${JSON.stringify(request)}`,
}); });
@ -143,9 +98,6 @@ export class FreebititService {
} }
const data = (await response.json()) as FreebititAuthResponse; const data = (await response.json()) as FreebititAuthResponse;
const data = (await response.json()) as FreebititAuthResponse;
if (data.resultCode !== "100") {
if (data.resultCode !== "100") { if (data.resultCode !== "100") {
throw new FreebititErrorImpl( throw new FreebititErrorImpl(
`Authentication failed: ${data.status.message}`, `Authentication failed: ${data.status.message}`,
@ -155,13 +107,7 @@ export class FreebititService {
); );
} }
// Cache the token for 50 minutes (assuming 60min expiry) this.authKeyCache = { token: data.authKey, expiresAt: Date.now() + 50 * 60 * 1000 };
this.authKeyCache = {
token: data.authKey,
expiresAt: Date.now() + 50 * 60 * 1000,
};
this.logger.log("Successfully authenticated with Freebit API");
this.logger.log("Successfully authenticated with Freebit API"); this.logger.log("Successfully authenticated with Freebit API");
return data.authKey; return data.authKey;
} catch (error: any) { } catch (error: any) {
@ -170,22 +116,15 @@ export class FreebititService {
} }
} }
/** private async makeAuthenticatedRequest<T>(endpoint: string, data: Record<string, unknown>): Promise<T> {
* Make authenticated API request with error handling
*/
private async makeAuthenticatedRequest<T>(endpoint: string, data: any): Promise<T> {
const authKey = await this.getAuthKey(); const authKey = await this.getAuthKey();
const requestData = { ...(data as Record<string, unknown>), authKey }; const requestData = { ...data, authKey };
try { try {
const url = `${this.config.baseUrl}${endpoint}`; const url = `${this.config.baseUrl}${endpoint}`;
const response = await fetch(url, { const response = await fetch(url, {
method: "POST", method: "POST",
method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Type": "application/x-www-form-urlencoded",
},
body: `json=${JSON.stringify(requestData)}`, body: `json=${JSON.stringify(requestData)}`,
}); });
@ -205,50 +144,57 @@ export class FreebititService {
throw new Error(`HTTP ${response.status}: ${response.statusText}`); throw new Error(`HTTP ${response.status}: ${response.statusText}`);
} }
const responseData = (await response.json()) as T; const responseData = (await response.json()) as any;
if (responseData && responseData.resultCode && responseData.resultCode !== "100") {
// Check for API-level errors
if (responseData && (responseData as any).resultCode !== "100") {
const errorData = responseData as any;
throw new FreebititErrorImpl( throw new FreebititErrorImpl(
`API Error: ${errorData.status?.message || "Unknown error"}`, `API Error: ${responseData.status?.message || "Unknown error"}`,
errorData.resultCode, responseData.resultCode,
errorData.status?.statusCode, responseData.status?.statusCode,
errorData.status?.message responseData.status?.message
); );
} }
this.logger.debug("Freebit API Request Success", { this.logger.debug("Freebit API Request Success", { endpoint });
this.logger.debug("Freebit API Request Success", { return responseData as T;
endpoint,
resultCode: rc,
});
return responseData;
} catch (error) { } catch (error) {
if (error instanceof FreebititErrorImpl) { if (error instanceof FreebititErrorImpl) {
throw error; throw error;
} }
this.logger.error(`Freebit API request failed: ${endpoint}`, { error: (error as any).message });
this.logger.error(`Freebit API request failed: ${endpoint}`, { throw new InternalServerErrorException(`Freebit API request failed: ${(error as any).message}`);
error: (error as any).message, }
}); }
throw new InternalServerErrorException(
`Freebit API request failed: ${(error as any).message}` private async makeAuthenticatedJsonRequest<T>(endpoint: string, payload: Record<string, unknown>): Promise<T> {
); const url = `${this.config.baseUrl}${endpoint}`;
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const responseData = (await response.json()) as any;
if (responseData && responseData.resultCode && responseData.resultCode !== "100") {
throw new FreebititErrorImpl(
`API Error: ${responseData.status?.message || "Unknown error"}`,
responseData.resultCode,
responseData.status?.statusCode,
responseData.status?.message
);
}
this.logger.debug("Freebit JSON API Request Success", { endpoint });
return responseData as T;
} catch (error) {
this.logger.error(`Freebit JSON API request failed: ${endpoint}`, { error: (error as any).message });
throw new InternalServerErrorException(`Freebit JSON API request failed: ${(error as any).message}`);
} }
this.logger.debug("Freebit JSON API Request Success", { endpoint, resultCode: rc });
return data;
} }
/**
* Get detailed SIM account information
*/
async getSimDetails(account: string): Promise<SimDetails> { async getSimDetails(account: string): Promise<SimDetails> {
try { try {
const request: Omit<FreebititAccountDetailsRequest, "authKey"> = {
version: "2",
requestDatas: [{ kind: "MVNO", account }],
const request: Omit<FreebititAccountDetailsRequest, "authKey"> = { const request: Omit<FreebititAccountDetailsRequest, "authKey"> = {
version: "2", version: "2",
requestDatas: [{ kind: "MVNO", account }], requestDatas: [{ kind: "MVNO", account }],
@ -276,28 +222,6 @@ export class FreebititService {
]) ])
); );
const configured = this.config.detailsEndpoint || "/master/getAcnt/";
const candidates = Array.from(
new Set([
configured,
configured.replace(/\/$/, ""),
"/master/getAcnt/",
"/master/getAcnt",
"/mvno/getAccountDetail/",
"/mvno/getAccountDetail",
"/mvno/getAcntDetail/",
"/mvno/getAcntDetail",
"/mvno/getAccountInfo/",
"/mvno/getAccountInfo",
"/mvno/getSubscriberInfo/",
"/mvno/getSubscriberInfo",
"/mvno/getInfo/",
"/mvno/getInfo",
"/master/getDetail/",
"/master/getDetail",
])
);
let response: FreebititAccountDetailsResponse | undefined; let response: FreebititAccountDetailsResponse | undefined;
let lastError: unknown; let lastError: unknown;
for (const ep of candidates) { for (const ep of candidates) {
@ -305,132 +229,87 @@ export class FreebititService {
if (ep !== candidates[0]) { if (ep !== candidates[0]) {
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`); this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
} }
response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>( response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(ep, request as any);
ep, break;
request } catch (err: any) {
);
response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(
ep,
request
);
break; // success
} catch (err: unknown) {
lastError = err; lastError = err;
if (typeof err?.message === "string" && err.message.includes("HTTP 404")) { if (typeof err?.message === "string" && err.message.includes("HTTP 404")) {
// try next candidate continue; // try next
continue;
} }
// non-404 error, rethrow
throw err;
} }
} }
if (!response) { if (!response) {
throw ( throw lastError || new Error("Failed to fetch account details");
lastError ||
new InternalServerErrorException("Failed to fetch SIM details: all endpoints failed")
);
} }
const datas = (response as any).responseDatas; const resp = response.responseDatas as any;
const list = Array.isArray(datas) ? datas : datas ? [datas] : []; const simData = Array.isArray(resp)
if (!list.length) { ? (resp.find((d) => String(d.kind).toUpperCase() === "MVNO") || resp[0])
throw new BadRequestException("No SIM details found for this account"); : resp;
throw new BadRequestException("No SIM details found for this account");
}
// Prefer the MVNO entry if present
const mvno =
list.find((d: any) => (d.kind || "").toString().toUpperCase() === "MVNO") || list[0];
const simData = mvno;
const startDateRaw = simData.startDate ? String(simData.startDate) : undefined; const size = String(simData.size || "").toLowerCase();
const startDate = const isEsim = size === "esim" || !!simData.eid;
startDateRaw && /^\d{8}$/.test(startDateRaw) const planCode = String(simData.planCode || "");
? `${startDateRaw.slice(0, 4)}-${startDateRaw.slice(4, 6)}-${startDateRaw.slice(6, 8)}` const status = this.mapSimStatus(String(simData.state || ""));
: startDateRaw;
const startDate =
startDateRaw && /^\d{8}$/.test(startDateRaw)
? `${startDateRaw.slice(0, 4)}-${startDateRaw.slice(4, 6)}-${startDateRaw.slice(6, 8)}`
: startDateRaw;
const simDetails: SimDetails = { const remainingKb = Number(simData.quota) || 0;
account: String(simData.account ?? account), const details: SimDetails = {
msisdn: String(simData.account ?? account), account: String(simData.account || account),
msisdn: String(simData.account || account),
iccid: simData.iccid ? String(simData.iccid) : undefined, iccid: simData.iccid ? String(simData.iccid) : undefined,
imsi: simData.imsi ? String(simData.imsi) : undefined, imsi: simData.imsi ? String(simData.imsi) : undefined,
eid: simData.eid, eid: simData.eid ? String(simData.eid) : undefined,
planCode: simData.planCode, planCode,
status: this.mapSimStatus(String(simData.state || "pending")), status,
simType: simData.eid ? "esim" : "physical", simType: isEsim ? "esim" : "physical",
size: simData.size, size: (size as any) || (isEsim ? "esim" : "nano"),
hasVoice: simData.talk === 10, hasVoice: Number(simData.talk) === 10,
hasSms: simData.sms === 10, hasSms: Number(simData.sms) === 10,
remainingQuotaKb: typeof simData.quota === "number" ? simData.quota : 0, remainingQuotaKb: remainingKb,
remainingQuotaMb: remainingQuotaMb: Math.round((remainingKb / 1024) * 100) / 100,
typeof simData.quota === "number" ? Math.round((simData.quota / 1024) * 100) / 100 : 0, startDate: simData.startDate ? String(simData.startDate) : undefined,
startDate,
ipv4: simData.ipv4, ipv4: simData.ipv4,
ipv6: simData.ipv6, ipv6: simData.ipv6,
voiceMailEnabled: simData.voicemail === 10 || simData.voiceMail === 10, voiceMailEnabled: Number(simData.voicemail ?? simData.voiceMail) === 10,
callWaitingEnabled: simData.callwaiting === 10 || simData.callWaiting === 10, callWaitingEnabled: Number(simData.callwaiting ?? simData.callWaiting) === 10,
internationalRoamingEnabled: simData.worldwing === 10 || simData.worldWing === 10, internationalRoamingEnabled: Number(simData.worldwing ?? simData.worldWing) === 10,
networkType: simData.contractLine || undefined, networkType: simData.contractLine || undefined,
pendingOperations: simData.async pendingOperations: simData.async
? [ ? [{ operation: String(simData.async.func), scheduledDate: String(simData.async.date) }]
{
operation: simData.async.func,
scheduledDate: String(simData.async.date),
},
]
: undefined,
pendingOperations: simData.async
? [
{
operation: simData.async.func,
scheduledDate: String(simData.async.date),
},
]
: undefined, : undefined,
}; };
this.logger.log(`Retrieved SIM details for account ${account}`, { this.logger.log(`Retrieved SIM details for account ${account}`, {
account, account,
status: simDetails.status, status: details.status,
planCode: simDetails.planCode, planCode: details.planCode,
}); });
return simDetails; return details;
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to get SIM details for account ${account}`, { this.logger.error(`Failed to get SIM details for account ${account}`, { error: error.message });
error: error.message,
});
throw error; throw error;
} }
} }
/**
* Get SIM data usage information
*/
async getSimUsage(account: string): Promise<SimUsage> { async getSimUsage(account: string): Promise<SimUsage> {
try { try {
const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account }; const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account };
const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account };
const response = await this.makeAuthenticatedRequest<FreebititTrafficInfoResponse>( const response = await this.makeAuthenticatedRequest<FreebititTrafficInfoResponse>(
"/mvno/getTrafficInfo/", "/mvno/getTrafficInfo/",
"/mvno/getTrafficInfo/", request as any
request
); );
const todayUsageKb = parseInt(response.traffic.today, 10) || 0; const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({ const recentDaysData = response.traffic.inRecentDays
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0], .split(",")
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({ .map((usage, index) => ({
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0], date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000)
usageKb: parseInt(usage, 10) || 0, .toISOString()
usageMb: Math.round((parseInt(usage, 10) / 1024) * 100) / 100, .split("T")[0],
})); usageKb: parseInt(usage, 10) || 0,
usageMb: Math.round(((parseInt(usage, 10) || 0) / 1024) * 100) / 100,
}));
const simUsage: SimUsage = { const simUsage: SimUsage = {
account, account,
@ -438,7 +317,6 @@ export class FreebititService {
todayUsageMb: Math.round((todayUsageKb / 1024) * 100) / 100, todayUsageMb: Math.round((todayUsageKb / 1024) * 100) / 100,
recentDaysUsage: recentDaysData, recentDaysUsage: recentDaysData,
isBlacklisted: response.traffic.blackList === "10", isBlacklisted: response.traffic.blackList === "10",
isBlacklisted: response.traffic.blackList === "10",
}; };
this.logger.log(`Retrieved SIM usage for account ${account}`, { this.logger.log(`Retrieved SIM usage for account ${account}`, {
@ -455,30 +333,13 @@ export class FreebititService {
} }
} }
/**
* Top up SIM data quota
*/
async topUpSim( async topUpSim(
account: string, account: string,
quotaMb: number, quotaMb: number,
options: { options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
campaignCode?: string;
expiryDate?: string;
scheduledAt?: string;
} = {}
): Promise<void> {
async topUpSim(
account: string,
quotaMb: number,
options: {
campaignCode?: string;
expiryDate?: string;
scheduledAt?: string;
} = {}
): Promise<void> { ): Promise<void> {
try { try {
const quotaKb = quotaMb * 1024; const quotaKb = Math.round(quotaMb * 1024);
const request: Omit<FreebititTopUpRequest, "authKey"> = { const request: Omit<FreebititTopUpRequest, "authKey"> = {
account, account,
quota: quotaKb, quota: quotaKb,
@ -486,23 +347,19 @@ export class FreebititService {
expire: options.expiryDate, expire: options.expiryDate,
}; };
// Use PA05-22 for scheduled top-ups, PA04-04 for immediate const scheduled = !!options.scheduledAt;
const endpoint = options.scheduledAt ? "/mvno/eachQuota/" : "/master/addSpec/"; const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
if (scheduled) {
if (options.scheduledAt && endpoint === "/mvno/eachQuota/") {
(request as any).runTime = options.scheduledAt; (request as any).runTime = options.scheduledAt;
} }
await this.makeAuthenticatedRequest<FreebititTopUpResponse>(endpoint, request); await this.makeAuthenticatedRequest<FreebititTopUpResponse>(endpoint, request as any);
this.logger.log(`Successfully topped up SIM ${account}`, { this.logger.log(`Successfully topped up SIM ${account}`, {
account, account,
endpoint, endpoint,
quotaMb, quotaMb,
quotaKb, quotaKb,
units: isScheduled ? "KB (PA05-22)" : "MB (PA04-04)", scheduled,
campaignCode: options.campaignCode,
scheduled: isScheduled,
}); });
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to top up SIM ${account}`, { this.logger.error(`Failed to top up SIM ${account}`, {
@ -514,38 +371,19 @@ export class FreebititService {
} }
} }
/** async getSimTopUpHistory(account: string, fromDate: string, toDate: string): Promise<SimTopUpHistory> {
* Get SIM top-up history
*/
async getSimTopUpHistory(
account: string,
fromDate: string,
toDate: string
): Promise<SimTopUpHistory> {
async getSimTopUpHistory(
account: string,
fromDate: string,
toDate: string
): Promise<SimTopUpHistory> {
try { try {
const request: Omit<FreebititQuotaHistoryRequest, "authKey"> = { const request: Omit<FreebititQuotaHistoryRequest, "authKey"> = { account, fromDate, toDate };
const request: Omit<FreebititQuotaHistoryRequest, "authKey"> = {
account,
fromDate,
toDate,
};
const response = await this.makeAuthenticatedRequest<FreebititQuotaHistoryResponse>( const response = await this.makeAuthenticatedRequest<FreebititQuotaHistoryResponse>(
"/mvno/getQuotaHistory/", "/mvno/getQuotaHistory/",
"/mvno/getQuotaHistory/", request as any
request
); );
const history: SimTopUpHistory = { const history: SimTopUpHistory = {
account, account,
totalAdditions: response.total, totalAdditions: Number(response.total) || 0,
additionCount: response.count, additionCount: Number(response.count) || 0,
history: response.quotaHistory.map(item => ({ history: response.quotaHistory.map((item) => ({
quotaKb: parseInt(item.quota, 10), quotaKb: parseInt(item.quota, 10),
quotaMb: Math.round((parseInt(item.quota, 10) / 1024) * 100) / 100, quotaMb: Math.round((parseInt(item.quota, 10) / 1024) * 100) / 100,
addedDate: item.date, addedDate: item.date,
@ -569,27 +407,12 @@ export class FreebititService {
} }
} }
/**
* Change SIM plan
*/
async changeSimPlan( async changeSimPlan(
account: string, account: string,
newPlanCode: string, newPlanCode: string,
options: { options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
assignGlobalIp?: boolean;
scheduledAt?: string;
} = {}
): Promise<{ ipv4?: string; ipv6?: string }> {
async changeSimPlan(
account: string,
newPlanCode: string,
options: {
assignGlobalIp?: boolean;
scheduledAt?: string;
} = {}
): Promise<{ ipv4?: string; ipv6?: string }> { ): Promise<{ ipv4?: string; ipv6?: string }> {
try { try {
const request: Omit<FreebititPlanChangeRequest, "authKey"> = {
const request: Omit<FreebititPlanChangeRequest, "authKey"> = { const request: Omit<FreebititPlanChangeRequest, "authKey"> = {
account, account,
plancode: newPlanCode, plancode: newPlanCode,
@ -599,8 +422,7 @@ export class FreebititService {
const response = await this.makeAuthenticatedRequest<FreebititPlanChangeResponse>( const response = await this.makeAuthenticatedRequest<FreebititPlanChangeResponse>(
"/mvno/changePlan/", "/mvno/changePlan/",
"/mvno/changePlan/", request as any
request
); );
this.logger.log(`Successfully changed SIM plan for account ${account}`, { this.logger.log(`Successfully changed SIM plan for account ${account}`, {
@ -610,10 +432,7 @@ export class FreebititService {
scheduled: !!options.scheduledAt, scheduled: !!options.scheduledAt,
}); });
return { return { ipv4: response.ipv4, ipv6: response.ipv6 };
ipv4: response.ipv4,
ipv6: response.ipv6,
};
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to change SIM plan for account ${account}`, { this.logger.error(`Failed to change SIM plan for account ${account}`, {
error: error.message, error: error.message,
@ -624,53 +443,30 @@ export class FreebititService {
} }
} }
/**
* Update SIM optional features (voicemail, call waiting, international roaming, network type)
* Uses AddSpec endpoint for immediate changes
*/
async updateSimFeatures( async updateSimFeatures(
account: string, account: string,
features: { features: { voiceMailEnabled?: boolean; callWaitingEnabled?: boolean; internationalRoamingEnabled?: boolean; networkType?: string }
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: string; // '4G' | '5G'
}
): Promise<void> {
async updateSimFeatures(
account: string,
features: {
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: string; // '4G' | '5G'
}
): Promise<void> { ): Promise<void> {
try { try {
const request: Omit<FreebititAddSpecRequest, "authKey"> = { const request: Omit<FreebititAddSpecRequest, "authKey"> = { account };
account,
};
if (typeof features.voiceMailEnabled === "boolean") { if (typeof features.voiceMailEnabled === "boolean") {
request.voiceMail = features.voiceMailEnabled ? ("10" as const) : ("20" as const); request.voiceMail = features.voiceMailEnabled ? "10" : "20";
request.voicemail = request.voiceMail; // include alternate casing for compatibility request.voicemail = request.voiceMail;
} }
if (typeof features.callWaitingEnabled === "boolean") { if (typeof features.callWaitingEnabled === "boolean") {
request.callWaiting = features.callWaitingEnabled ? ("10" as const) : ("20" as const); request.callWaiting = features.callWaitingEnabled ? "10" : "20";
request.callwaiting = request.callWaiting; request.callwaiting = request.callWaiting;
} }
if (typeof features.internationalRoamingEnabled === "boolean") { if (typeof features.internationalRoamingEnabled === "boolean") {
request.worldWing = features.internationalRoamingEnabled request.worldWing = features.internationalRoamingEnabled ? "10" : "20";
? ("10" as const)
: ("20" as const);
request.worldwing = request.worldWing; request.worldwing = request.worldWing;
} }
if (features.networkType) { if (features.networkType) {
request.contractLine = features.networkType; request.contractLine = features.networkType;
} }
await this.makeAuthenticatedRequest<FreebititAddSpecResponse>("/master/addSpec/", request); await this.makeAuthenticatedRequest<FreebititAddSpecResponse>("/master/addSpec/", request as any);
this.logger.log(`Updated SIM features for account ${account}`, { this.logger.log(`Updated SIM features for account ${account}`, {
account, account,
voiceMailEnabled: features.voiceMailEnabled, voiceMailEnabled: features.voiceMailEnabled,
@ -680,104 +476,53 @@ export class FreebititService {
}); });
} catch (error: unknown) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to update SIM features for account ${account}`, { this.logger.error(`Failed to update SIM features for account ${account}`, { error: message, account });
error: message,
account,
});
throw error as Error; throw error as Error;
} }
} }
/**
* Cancel SIM service via PA02-04 (master/cnclAcnt)
*/
async cancelSim(account: string, scheduledAt?: string): Promise<void> { async cancelSim(account: string, scheduledAt?: string): Promise<void> {
try { try {
const request: Omit<FreebititCancelPlanRequest, "authKey"> = { const request: Omit<FreebititCancelPlanRequest, "authKey"> = { account, runTime: scheduledAt };
account, await this.makeAuthenticatedRequest<FreebititCancelPlanResponse>("/mvno/releasePlan/", request as any);
runDate: scheduledAt, this.logger.log(`Successfully cancelled SIM for account ${account}`, { account, runTime: scheduledAt });
};
await this.makeAuthenticatedRequest<FreebititCancelPlanResponse>(
"/mvno/releasePlan/",
request
);
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
account,
runDate: scheduledAt,
});
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to cancel SIM for account ${account}`, { this.logger.error(`Failed to cancel SIM for account ${account}`, { error: error.message, account });
error: error.message,
account,
});
throw error as Error; throw error as Error;
} }
} }
/**
* Reissue eSIM profile using reissueProfile endpoint
*/
async reissueEsimProfile(account: string): Promise<void> { async reissueEsimProfile(account: string): Promise<void> {
try { try {
const request: Omit<FreebititEsimReissueRequest, "authKey"> = { account }; const request: Omit<FreebititEsimReissueRequest, "authKey"> = { account };
await this.makeAuthenticatedRequest<FreebititEsimReissueResponse>("/esim/reissueProfile/", request as any);
await this.makeAuthenticatedRequest<FreebititEsimReissueResponse>( this.logger.log(`Successfully requested eSIM reissue for account ${account}`);
"/esim/reissueProfile/",
request
);
this.logger.log(`Successfully reissued eSIM profile for account ${account}`, { account });
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to reissue eSIM profile for account ${account}`, { this.logger.error(`Failed to reissue eSIM profile for account ${account}`, { error: error.message, account });
error: error.message,
account,
});
throw error as Error; throw error as Error;
} }
} }
/**
* Reissue eSIM profile using addAcnt endpoint (enhanced method based on Salesforce implementation)
*/
async reissueEsimProfileEnhanced( async reissueEsimProfileEnhanced(
account: string,
account: string, account: string,
newEid: string, newEid: string,
options: { options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {}
oldProductNumber?: string;
oldEid?: string;
planCode?: string;
} = {}
): Promise<void> { ): Promise<void> {
try { try {
const request: Omit<FreebititEsimAddAccountRequest, "authKey"> = {
aladinOperated: "20",
const request: Omit<FreebititEsimAddAccountRequest, "authKey"> = { const request: Omit<FreebititEsimAddAccountRequest, "authKey"> = {
aladinOperated: "20", aladinOperated: "20",
account, account,
eid: newEid, eid: newEid,
addKind: "R", // R = reissue addKind: "R",
addKind: "R", // R = reissue reissue: { oldProductNumber: options.oldProductNumber, oldEid: options.oldEid },
reissue: { planCode: options.planCode,
oldProductNumber: options.oldProductNumber,
oldEid: options.oldEid,
},
}; };
// Add optional fields
if (options.planCode) {
request.planCode = options.planCode;
}
await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse>( await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse>(
"/mvno/esim/addAcnt/", "/mvno/esim/addAcnt/",
"/mvno/esim/addAcnt/", request as any
request
); );
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, { this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
account, account,
newEid, newEid,
@ -794,17 +539,13 @@ export class FreebititService {
} }
} }
/**
* Activate a new eSIM account via PA05-41 addAcct (JSON API)
* This supports optional scheduling (shipDate) and MNP payload.
*/
async activateEsimAccountNew(params: { async activateEsimAccountNew(params: {
account: string; // MSISDN to be activated (required by Freebit) account: string;
eid: string; // 32-digit EID eid: string;
planCode?: string; planCode?: string;
contractLine?: "4G" | "5G"; contractLine?: "4G" | "5G";
aladinOperated?: "10" | "20"; aladinOperated?: "10" | "20";
shipDate?: string; // YYYYMMDD; if provided we send as scheduled activation date shipDate?: string;
mnp?: { reserveNumber: string; reserveExpireDate: string }; mnp?: { reserveNumber: string; reserveExpireDate: string };
identity?: { identity?: {
firstnameKanji?: string; firstnameKanji?: string;
@ -815,16 +556,7 @@ export class FreebititService {
birthday?: string; birthday?: string;
}; };
}): Promise<void> { }): Promise<void> {
const { const { account, eid, planCode, contractLine, aladinOperated = "10", shipDate, mnp, identity } = params;
account,
eid,
planCode,
contractLine,
aladinOperated = "10",
shipDate,
mnp,
identity,
} = params;
if (!account || !eid) { if (!account || !eid) {
throw new BadRequestException("activateEsimAccountNew requires account and eid"); throw new BadRequestException("activateEsimAccountNew requires account and eid");
@ -858,9 +590,6 @@ export class FreebititService {
}); });
} }
/**
* Health check for Freebit API
*/
async healthCheck(): Promise<boolean> { async healthCheck(): Promise<boolean> {
try { try {
await this.getAuthKey(); await this.getAuthKey();
@ -872,19 +601,17 @@ export class FreebititService {
} }
} }
// Custom error class for Freebit API errors
class FreebititErrorImpl extends Error { class FreebititErrorImpl extends Error {
public readonly resultCode: string; public readonly resultCode: string;
public readonly statusCode: string; public readonly statusCode: string | number;
public readonly freebititMessage: string; public readonly freebititMessage: string;
constructor(message: string, resultCode: string, statusCode: string, freebititMessage: string) { constructor(message: string, resultCode: string | number, statusCode: string | number, freebititMessage: string) {
constructor(message: string, resultCode: string, statusCode: string, freebititMessage: string) {
super(message); super(message);
this.name = "FreebititError"; this.name = "FreebititError";
this.name = "FreebititError"; this.resultCode = String(resultCode);
this.resultCode = resultCode;
this.statusCode = statusCode; this.statusCode = statusCode;
this.freebititMessage = freebititMessage; this.freebititMessage = String(freebititMessage);
} }
} }

View File

@ -1,20 +1,17 @@
// Freebit API Type Definitions // Freebit API Type Definitions (cleaned)
export interface FreebititAuthRequest { export interface FreebititAuthRequest {
oemId: string; // 4-char alphanumeric ISP identifier oemId: string; // 4-char alphanumeric ISP identifier
oemKey: string; // 32-char auth key oemKey: string; // 32-char auth key
oemId: string; // 4-char alphanumeric ISP identifier
oemKey: string; // 32-char auth key
} }
export interface FreebititAuthResponse { export interface FreebititAuthResponse {
resultCode: string; resultCode: string;
status: { status: {
message: string; message: string;
statusCode: string; statusCode: string | number;
}; };
authKey: string; // Token for subsequent API calls authKey: string; // Token for subsequent API calls
authKey: string; // Token for subsequent API calls
} }
export interface FreebititAccountDetailsRequest { export interface FreebititAccountDetailsRequest {
@ -33,7 +30,6 @@ export interface FreebititAccountDetailsResponse {
statusCode: string | number; statusCode: string | number;
}; };
masterAccount?: string; masterAccount?: string;
// Docs show this can be an array (MASTER + MVNO) or a single object for MVNO
responseDatas: responseDatas:
| { | {
kind: "MASTER" | "MVNO" | string; kind: "MASTER" | "MVNO" | string;
@ -52,11 +48,8 @@ export interface FreebititAccountDetailsResponse {
talk?: number; // 10=active, 20=inactive talk?: number; // 10=active, 20=inactive
ipv4?: string; ipv4?: string;
ipv6?: string; ipv6?: string;
quota?: number; // Remaining quota (units vary by env) quota?: number; // Remaining quota
async?: { async?: { func: string; date: string | number };
func: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string;
date: string | number;
};
} }
| Array<{ | Array<{
kind: "MASTER" | "MVNO" | string; kind: "MASTER" | "MVNO" | string;
@ -76,11 +69,7 @@ export interface FreebititAccountDetailsResponse {
ipv4?: string; ipv4?: string;
ipv6?: string; ipv6?: string;
quota?: number; quota?: number;
async?: { async?: { func: string; date: string | number };
func: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string;
date: string | number;
};
}>;
}>; }>;
} }
@ -93,15 +82,13 @@ export interface FreebititTrafficInfoResponse {
resultCode: string; resultCode: string;
status: { status: {
message: string; message: string;
statusCode: string; statusCode: string | number;
}; };
account: string; account: string;
traffic: { traffic: {
today: string; // Today's usage in KB
today: string; // Today's usage in KB today: string; // Today's usage in KB
inRecentDays: string; // Comma-separated recent days usage inRecentDays: string; // Comma-separated recent days usage
blackList: string; // 10=blacklisted, 20=not blacklisted blackList: string; // 10=blacklisted, 20=not blacklisted
blackList: string; // 10=blacklisted, 20=not blacklisted
}; };
} }
@ -115,17 +102,14 @@ export interface FreebititTopUpRequest {
export interface FreebititTopUpResponse { export interface FreebititTopUpResponse {
resultCode: string; resultCode: string;
status: { status: { message: string; statusCode: string | number };
message: string;
statusCode: string;
};
} }
// AddSpec request for updating SIM options/features immediately // AddSpec request for updating SIM options/features immediately
export interface FreebititAddSpecRequest { export interface FreebititAddSpecRequest {
authKey: string; authKey: string;
account: string; account: string;
kind?: string; // Required by PA04-04 (/master/addSpec/), e.g. 'MVNO' kind?: string; // e.g. 'MVNO'
// Feature flags: 10 = enabled, 20 = disabled // Feature flags: 10 = enabled, 20 = disabled
voiceMail?: "10" | "20"; voiceMail?: "10" | "20";
voicemail?: "10" | "20"; voicemail?: "10" | "20";
@ -133,21 +117,12 @@ export interface FreebititAddSpecRequest {
callwaiting?: "10" | "20"; callwaiting?: "10" | "20";
worldWing?: "10" | "20"; worldWing?: "10" | "20";
worldwing?: "10" | "20"; worldwing?: "10" | "20";
voiceMail?: "10" | "20";
voicemail?: "10" | "20";
callWaiting?: "10" | "20";
callwaiting?: "10" | "20";
worldWing?: "10" | "20";
worldwing?: "10" | "20";
contractLine?: string; // '4G' or '5G' contractLine?: string; // '4G' or '5G'
} }
export interface FreebititAddSpecResponse { export interface FreebititAddSpecResponse {
resultCode: string; resultCode: string;
status: { status: { message: string; statusCode: string | number };
message: string;
statusCode: string;
};
} }
export interface FreebititQuotaHistoryRequest { export interface FreebititQuotaHistoryRequest {
@ -155,25 +130,15 @@ export interface FreebititQuotaHistoryRequest {
account: string; account: string;
fromDate: string; // YYYYMMDD fromDate: string; // YYYYMMDD
toDate: string; // YYYYMMDD toDate: string; // YYYYMMDD
fromDate: string; // YYYYMMDD
toDate: string; // YYYYMMDD
} }
export interface FreebititQuotaHistoryResponse { export interface FreebititQuotaHistoryResponse {
resultCode: string; resultCode: string;
status: { status: { message: string; statusCode: string | number };
message: string;
statusCode: string;
};
account: string; account: string;
total: number; total: number;
count: number; count: number;
quotaHistory: Array<{ quotaHistory: Array<{ quota: string; expire: string; date: string; quotaCode: string }>;
quota: string;
expire: string;
date: string;
quotaCode: string;
}>;
} }
export interface FreebititPlanChangeRequest { export interface FreebititPlanChangeRequest {
@ -181,46 +146,16 @@ export interface FreebititPlanChangeRequest {
account: string; account: string;
plancode: string; plancode: string;
globalip?: "0" | "1"; // 0=no IP, 1=assign global IP globalip?: "0" | "1"; // 0=no IP, 1=assign global IP
runTime?: string; // YYYYMMDD - optional, immediate if omitted runTime?: string; // YYYYMMDD - optional
} }
export interface FreebititPlanChangeResponse { export interface FreebititPlanChangeResponse {
resultCode: string; resultCode: string;
status: { status: { message: string; statusCode: string | number };
message: string;
statusCode: string;
};
ipv4?: string; ipv4?: string;
ipv6?: string; ipv6?: string;
} }
// PA05-06: MVNO Voice Option Change
export interface FreebititVoiceOptionChangeRequest {
authKey: string;
account: string;
userConfirmed: "10" | "20";
aladinOperated: "10" | "20";
talkOption: {
voiceMail?: "10" | "20";
callWaiting?: "10" | "20";
worldWing?: "10" | "20";
worldCall?: "10" | "20";
callTransfer?: "10" | "20";
callTransferNoId?: "10" | "20";
worldCallCreditLimit?: string;
worldWingCreditLimit?: string;
};
}
export interface FreebititVoiceOptionChangeResponse {
resultCode: string;
status: {
message: string;
statusCode: string;
};
}
// PA05-38: MVNO Contract Change (4G/5G)
export interface FreebititContractLineChangeRequest { export interface FreebititContractLineChangeRequest {
authKey: string; authKey: string;
account: string; account: string;
@ -239,16 +174,12 @@ export interface FreebititContractLineChangeResponse {
export interface FreebititCancelPlanRequest { export interface FreebititCancelPlanRequest {
authKey: string; authKey: string;
account: string; account: string;
runTime?: string; // YYYYMMDD - optional, immediate if omitted runTime?: string; // YYYYMMDD - optional
runTime?: string; // YYYYMMDD - optional, immediate if omitted
} }
export interface FreebititCancelPlanResponse { export interface FreebititCancelPlanResponse {
resultCode: string; resultCode: string;
status: { status: { message: string; statusCode: string | number };
message: string;
statusCode: string;
};
} }
// PA02-04: Account Cancellation (master/cnclAcnt) // PA02-04: Account Cancellation (master/cnclAcnt)
@ -261,10 +192,7 @@ export interface FreebititCancelAccountRequest {
export interface FreebititCancelAccountResponse { export interface FreebititCancelAccountResponse {
resultCode: string; resultCode: string;
status: { status: { message: string; statusCode: string | number };
message: string;
statusCode: string;
};
} }
export interface FreebititEsimReissueRequest { export interface FreebititEsimReissueRequest {
@ -274,10 +202,7 @@ export interface FreebititEsimReissueRequest {
export interface FreebititEsimReissueResponse { export interface FreebititEsimReissueResponse {
resultCode: string; resultCode: string;
status: { status: { message: string; statusCode: string | number };
message: string;
statusCode: string;
};
} }
export interface FreebititEsimAddAccountRequest { export interface FreebititEsimAddAccountRequest {
@ -286,23 +211,16 @@ export interface FreebititEsimAddAccountRequest {
account: string; account: string;
eid: string; eid: string;
addKind: "N" | "R"; // N = new, R = reissue addKind: "N" | "R"; // N = new, R = reissue
addKind: "N" | "R"; // N = new, R = reissue
createType?: string; createType?: string;
simKind?: string; simKind?: string;
planCode?: string; planCode?: string;
contractLine?: string; contractLine?: string;
reissue?: { reissue?: { oldProductNumber?: string; oldEid?: string };
oldProductNumber?: string;
oldEid?: string;
};
} }
export interface FreebititEsimAddAccountResponse { export interface FreebititEsimAddAccountResponse {
resultCode: string; resultCode: string;
status: { status: { message: string; statusCode: string | number };
message: string;
statusCode: string;
};
} }
// PA05-41 eSIM Account Activation (addAcct) // PA05-41 eSIM Account Activation (addAcct)
@ -320,10 +238,7 @@ export interface FreebititEsimAccountActivationRequest {
addKind?: string; // e.g., 'R' for reissue addKind?: string; // e.g., 'R' for reissue
oldEid?: string; oldEid?: string;
oldProductNumber?: string; oldProductNumber?: string;
mnp?: { mnp?: { reserveNumber: string; reserveExpireDate: string };
reserveNumber: string;
reserveExpireDate: string; // YYYYMMDD
};
firstnameKanji?: string; firstnameKanji?: string;
lastnameKanji?: string; lastnameKanji?: string;
firstnameZenKana?: string; firstnameZenKana?: string;
@ -340,7 +255,7 @@ export interface FreebititEsimAccountActivationRequest {
export interface FreebititEsimAccountActivationResponse { export interface FreebititEsimAccountActivationResponse {
resultCode: number | string; resultCode: number | string;
status?: unknown; status?: unknown;
statusCode?: string; statusCode?: string | number;
message?: string; message?: string;
} }
@ -355,9 +270,6 @@ export interface SimDetails {
status: "active" | "suspended" | "cancelled" | "pending"; status: "active" | "suspended" | "cancelled" | "pending";
simType: "physical" | "esim"; simType: "physical" | "esim";
size: "standard" | "nano" | "micro" | "esim"; size: "standard" | "nano" | "micro" | "esim";
status: "active" | "suspended" | "cancelled" | "pending";
simType: "physical" | "esim";
size: "standard" | "nano" | "micro" | "esim";
hasVoice: boolean; hasVoice: boolean;
hasSms: boolean; hasSms: boolean;
remainingQuotaKb: number; remainingQuotaKb: number;
@ -365,26 +277,18 @@ export interface SimDetails {
startDate?: string; startDate?: string;
ipv4?: string; ipv4?: string;
ipv6?: string; ipv6?: string;
// Optional extended service features
voiceMailEnabled?: boolean; voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean; callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean; internationalRoamingEnabled?: boolean;
networkType?: string; // e.g., '4G' or '5G' networkType?: string; // e.g., '4G' or '5G'
pendingOperations?: Array<{ pendingOperations?: Array<{ operation: string; scheduledDate: string }>;
operation: string;
scheduledDate: string;
}>;
} }
export interface SimUsage { export interface SimUsage {
account: string; account: string;
todayUsageKb: number; todayUsageKb: number;
todayUsageMb: number; todayUsageMb: number;
recentDaysUsage: Array<{ recentDaysUsage: Array<{ date: string; usageKb: number; usageMb: number }>;
date: string;
usageKb: number;
usageMb: number;
}>;
isBlacklisted: boolean; isBlacklisted: boolean;
} }
@ -404,7 +308,7 @@ export interface SimTopUpHistory {
// Error handling // Error handling
export interface FreebititError extends Error { export interface FreebititError extends Error {
resultCode: string; resultCode: string;
statusCode: string; statusCode: string | number;
freebititMessage: string; freebititMessage: string;
} }

View File

@ -1,10 +1,11 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { WhmcsModule } from "./whmcs/whmcs.module"; import { WhmcsModule } from "./whmcs/whmcs.module";
import { SalesforceModule } from "./salesforce/salesforce.module"; import { SalesforceModule } from "./salesforce/salesforce.module";
import { FreebititModule } from "./freebit/freebit.module";
@Module({ @Module({
imports: [WhmcsModule, SalesforceModule], imports: [WhmcsModule, SalesforceModule, FreebititModule],
providers: [], providers: [],
exports: [WhmcsModule, SalesforceModule], exports: [WhmcsModule, SalesforceModule, FreebititModule],
}) })
export class VendorsModule {} export class VendorsModule {}

View File

@ -1,14 +1,32 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useState } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { authenticatedApi } from "@/lib/api"; import { authenticatedApi } from "@/lib/api";
import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard"; import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard";
import { formatPlanShort } from "@/lib/plan"; import { formatPlanShort } from "@/lib/plan";
type Step = 1 | 2 | 3; type Step = 1 | 2 | 3;
function Notice({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="bg-yellow-50 border border-yellow-200 rounded p-3">
<div className="text-sm font-medium text-yellow-900 mb-1">{title}</div>
<div className="text-sm text-yellow-800">{children}</div>
</div>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-xs text-gray-500">{label}</div>
<div className="text-sm font-medium text-gray-900">{value}</div>
</div>
);
}
export default function SimCancelPage() { export default function SimCancelPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
@ -162,7 +180,6 @@ export default function SimCancelPage() {
requested from this online form. Please contact Assist Solutions Customer Support requested from this online form. Please contact Assist Solutions Customer Support
(info@asolutions.co.jp) for more information. (info@asolutions.co.jp) for more information.
</Notice> </Notice>
4
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<InfoRow label="SIM" value={details?.msisdn || "—"} /> <InfoRow label="SIM" value={details?.msisdn || "—"} />

View File

@ -2,7 +2,6 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { authenticatedApi } from "@/lib/api"; import { authenticatedApi } from "@/lib/api";
@ -55,8 +54,7 @@ export default function SimChangePlanPage() {
}; };
return ( return (
<DashboardLayout> <div className="max-w-3xl mx-auto p-6">
<div className="max-w-3xl mx-auto p-6">
<div className="mb-4"> <div className="mb-4">
<Link <Link
href={`/subscriptions/${subscriptionId}#sim-management`} href={`/subscriptions/${subscriptionId}#sim-management`}
@ -143,6 +141,5 @@ export default function SimChangePlanPage() {
</form> </form>
</div> </div>
</div> </div>
</DashboardLayout>
); );
} }

View File

@ -1,506 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import Link from "next/link";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import {
ArrowLeftIcon,
ServerIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
ClockIcon,
XCircleIcon,
CalendarIcon,
DocumentTextIcon,
ArrowTopRightOnSquareIcon,
} from "@heroicons/react/24/outline";
import { format } from "date-fns";
import { useSubscription, useSubscriptionInvoices } from "@/hooks/useSubscriptions";
import { formatCurrency as sharedFormatCurrency, getCurrencyLocale } from "@/utils/currency";
import { SimManagementSection } from "@/features/sim-management";
export default function SubscriptionDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [showInvoices, setShowInvoices] = useState(true);
const [showSimManagement, setShowSimManagement] = useState(false);
const subscriptionId = parseInt(params.id as string);
const { data: subscription, isLoading, error } = useSubscription(subscriptionId);
const {
data: invoiceData,
isLoading: invoicesLoading,
error: invoicesError,
} = useSubscriptionInvoices(subscriptionId, { page: currentPage, limit: itemsPerPage });
const invoices = invoiceData?.invoices || [];
const pagination = invoiceData?.pagination;
// Control what sections to show based on URL hash
useEffect(() => {
const updateVisibility = () => {
const hash = typeof window !== "undefined" ? window.location.hash : "";
const service = (searchParams.get("service") || "").toLowerCase();
const isSimContext = hash.includes("sim-management") || service === "sim";
if (isSimContext) {
// Show only SIM management, hide invoices
setShowInvoices(false);
setShowSimManagement(true);
} else {
// Show only invoices, hide SIM management
setShowInvoices(true);
setShowSimManagement(false);
}
};
updateVisibility();
if (typeof window !== "undefined") {
window.addEventListener("hashchange", updateVisibility);
return () => window.removeEventListener("hashchange", updateVisibility);
}
return;
}, [searchParams]);
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 "Terminated":
return <XCircleIcon className="h-6 w-6 text-red-500" />;
case "Cancelled":
return <XCircleIcon className="h-6 w-6 text-gray-500" />;
case "Pending":
return <ClockIcon className="h-6 w-6 text-blue-500" />;
default:
return <ServerIcon 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 "Terminated":
return "bg-red-100 text-red-800";
case "Cancelled":
return "bg-gray-100 text-gray-800";
case "Pending":
return "bg-blue-100 text-blue-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const getInvoiceStatusIcon = (status: string) => {
switch (status) {
case "Paid":
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
case "Overdue":
return <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />;
case "Unpaid":
return <ClockIcon className="h-5 w-5 text-yellow-500" />;
default:
return <DocumentTextIcon className="h-5 w-5 text-gray-500" />;
}
};
const getInvoiceStatusColor = (status: string) => {
switch (status) {
case "Paid":
return "bg-green-100 text-green-800";
case "Overdue":
return "bg-red-100 text-red-800";
case "Unpaid":
return "bg-yellow-100 text-yellow-800";
case "Cancelled":
return "bg-gray-100 text-gray-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const formatDate = (dateString: string | undefined) => {
if (!dateString) return "N/A";
try {
return format(new Date(dateString), "MMM d, yyyy");
} catch {
return "Invalid date";
}
};
const formatCurrency = (amount: number) =>
sharedFormatCurrency(amount || 0, { currency: "JPY", locale: getCurrencyLocale("JPY") });
const formatBillingLabel = (cycle: string) => {
switch (cycle) {
case "Monthly":
return "Monthly Billing";
case "Annually":
return "Annual Billing";
case "Quarterly":
return "Quarterly Billing";
case "Semi-Annually":
return "Semi-Annual Billing";
case "Biennially":
return "Biennial Billing";
case "Triennially":
return "Triennial Billing";
case "One-time":
return "One-time Payment";
default:
return "One-time Payment";
}
};
if (isLoading) {
return (
<DashboardLayout>
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading subscription...</p>
</div>
</div>
</DashboardLayout>
);
}
if (error || !subscription) {
return (
<DashboardLayout>
<div className="space-y-6">
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="flex">
<div className="flex-shrink-0">
<ExclamationTriangleIcon className="h-5 w-5 text-red-400" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error loading subscription</h3>
<div className="mt-2 text-sm text-red-700">
{error instanceof Error ? error.message : "Subscription not found"}
</div>
<div className="mt-4">
<Link
href="/subscriptions"
className="text-red-700 hover:text-red-600 font-medium"
>
Back to subscriptions
</Link>
</div>
</div>
</div>
</div>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div className="flex items-center">
<Link href="/subscriptions" className="mr-4 text-gray-600 hover:text-gray-900">
<ArrowLeftIcon className="h-6 w-6" />
</Link>
<div className="flex items-center">
<ServerIcon className="h-8 w-8 text-blue-600 mr-3" />
<div>
<h1 className="text-2xl font-bold text-gray-900">{subscription.productName}</h1>
<p className="text-gray-600">Service ID: {subscription.serviceId}</p>
</div>
</div>
</div>
</div>
</div>
{/* Subscription Summary Card */}
<div className="bg-white shadow rounded-lg mb-6">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div className="flex items-center">
{getStatusIcon(subscription.status)}
<div className="ml-3">
<h3 className="text-lg font-medium text-gray-900">Subscription Details</h3>
<p className="text-sm text-gray-500">Service subscription information</p>
</div>
</div>
<span
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(subscription.status)}`}
>
{subscription.status}
</span>
</div>
</div>
<div className="px-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider">
Billing Amount
</h4>
<p className="mt-2 text-2xl font-bold text-gray-900">
{formatCurrency(subscription.amount)}
</p>
<p className="text-sm text-gray-500">{formatBillingLabel(subscription.cycle)}</p>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider">
Next Due Date
</h4>
<p className="mt-2 text-lg text-gray-900">{formatDate(subscription.nextDue)}</p>
<div className="flex items-center mt-1">
<CalendarIcon className="h-4 w-4 text-gray-400 mr-1" />
<span className="text-sm text-gray-500">Due date</span>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider">
Registration Date
</h4>
<p className="mt-2 text-lg text-gray-900">
{formatDate(subscription.registrationDate)}
</p>
<span className="text-sm text-gray-500">Service created</span>
</div>
</div>
</div>
</div>
{/* Navigation tabs for SIM services - More visible and mobile-friendly */}
{subscription.productName.toLowerCase().includes("sim") && (
<div className="mb-8">
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
<div>
<h3 className="text-xl font-semibold text-gray-900">Service Management</h3>
<p className="text-sm text-gray-600 mt-1">
Switch between billing and SIM management views
</p>
</div>
<div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 bg-gray-100 rounded-xl p-2">
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[140px] text-center ${
showSimManagement
? "bg-white text-blue-600 shadow-md hover:shadow-lg"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-200"
}`}
>
<ServerIcon className="h-4 w-4 inline mr-2" />
SIM Management
</Link>
<Link
href={`/subscriptions/${subscriptionId}`}
className={`px-6 py-3 text-sm font-semibold rounded-lg transition-all duration-200 min-w-[120px] text-center ${
showInvoices
? "bg-white text-blue-600 shadow-md hover:shadow-lg"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-200"
}`}
>
<DocumentTextIcon className="h-4 w-4 inline mr-2" />
Billing
</Link>
</div>
</div>
</div>
</div>
)}
{/* SIM Management Section - Only show when in SIM context and for SIM services */}
{showSimManagement && subscription.productName.toLowerCase().includes("sim") && (
<SimManagementSection subscriptionId={subscriptionId} />
)}
{/* Related Invoices (hidden when viewing SIM management directly) */}
{showInvoices && (
<div className="bg-white shadow rounded-lg">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center">
<DocumentTextIcon className="h-6 w-6 text-blue-600 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Related Invoices</h3>
</div>
<p className="text-sm text-gray-500 mt-1">
Invoices containing charges for this subscription
</p>
</div>
{invoicesLoading ? (
<div className="px-6 py-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-gray-600">Loading invoices...</p>
</div>
) : invoicesError ? (
<div className="text-center py-12">
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-400" />
<h3 className="mt-2 text-sm font-medium text-red-800">Error loading invoices</h3>
<p className="mt-1 text-sm text-red-600">
{invoicesError instanceof Error
? invoicesError.message
: "Failed to load related invoices"}
</p>
</div>
) : invoices.length === 0 ? (
<div className="text-center py-12">
<DocumentTextIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900">No invoices found</h3>
<p className="mt-1 text-sm text-gray-500">
No invoices have been generated for this subscription yet.
</p>
</div>
) : (
<>
<div className="p-6">
<div className="space-y-4">
{invoices.map(invoice => (
<div
key={invoice.id}
className="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-lg hover:border-blue-200 transition-all duration-200 group"
>
<div className="flex items-start justify-between">
<div className="flex items-center flex-1">
<div className="flex-shrink-0">
{getInvoiceStatusIcon(invoice.status)}
</div>
<div className="ml-3 flex-1">
<h4 className="text-base font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
Invoice {invoice.number}
</h4>
<p className="text-sm text-gray-500 mt-1">
Issued{" "}
{invoice.issuedAt &&
format(new Date(invoice.issuedAt), "MMM d, yyyy")}
</p>
</div>
</div>
<div className="flex flex-col items-end space-y-2">
<span
className={`inline-flex px-3 py-1 text-sm font-medium rounded-full ${getInvoiceStatusColor(invoice.status)}`}
>
{invoice.status}
</span>
<span className="text-lg font-bold text-gray-900">
{formatCurrency(invoice.total)}
</span>
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-500">
<span className="block">
Due:{" "}
{invoice.dueDate
? format(new Date(invoice.dueDate), "MMM d, yyyy")
: "N/A"}
</span>
</div>
<button
onClick={() =>
(window.location.href = `/billing/invoices/${invoice.id}`)
}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200 shadow-sm hover:shadow-md"
>
<DocumentTextIcon className="h-4 w-4 mr-2" />
View Invoice
<ArrowTopRightOnSquareIcon className="h-4 w-4 ml-2" />
</button>
</div>
</div>
))}
</div>
</div>
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Previous
</button>
<button
onClick={() =>
setCurrentPage(prev => Math.min(prev + 1, pagination.totalPages))
}
disabled={currentPage === pagination.totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
Next
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing{" "}
<span className="font-medium">
{(currentPage - 1) * itemsPerPage + 1}
</span>{" "}
to{" "}
<span className="font-medium">
{Math.min(currentPage * itemsPerPage, pagination.totalItems)}
</span>{" "}
of <span className="font-medium">{pagination.totalItems}</span> results
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
Previous
</button>
{Array.from({ length: Math.min(pagination.totalPages, 5) }, (_, i) => {
const startPage = Math.max(1, currentPage - 2);
const page = startPage + i;
if (page > pagination.totalPages) return null;
return (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
page === currentPage
? "z-10 bg-blue-50 border-blue-500 text-blue-600"
: "bg-white border-gray-300 text-gray-500 hover:bg-gray-50"
}`}
>
{page}
</button>
);
})}
<button
onClick={() =>
setCurrentPage(prev => Math.min(prev + 1, pagination.totalPages))
}
disabled={currentPage === pagination.totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
Next
</button>
</nav>
</div>
</div>
</div>
)}
</>
)}
</div>
)}
</div>
</div>
</DashboardLayout>
);
}

View File

@ -1,133 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { authenticatedApi } from "@/lib/api";
export default function EsimReissuePage() {
const params = useParams();
const router = useRouter();
const subscriptionId = parseInt(params.id as string);
const [loading, setLoading] = useState(false);
const [detailsLoading, setDetailsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [oldEid, setOldEid] = useState<string | null>(null);
const [newEid, setNewEid] = useState<string>("");
useEffect(() => {
const fetchDetails = async () => {
try {
setDetailsLoading(true);
const data = await authenticatedApi.get<{ eid?: string }>(
`/subscriptions/${subscriptionId}/sim/details`
);
setOldEid(data?.eid || null);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to load SIM details");
} finally {
setDetailsLoading(false);
}
};
void fetchDetails();
}, [subscriptionId]);
const validEid = (val: string) => /^\d{32}$/.test(val);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setMessage(null);
if (!validEid(newEid)) {
setError("Please enter a valid 32-digit EID");
return;
}
setLoading(true);
try {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`, { newEid });
setMessage("eSIM reissue requested successfully. You will receive the new profile shortly.");
setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 1500);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Failed to submit eSIM reissue");
} finally {
setLoading(false);
}
};
return (
<DashboardLayout>
<div className="max-w-2xl mx-auto p-6">
<div className="mb-4">
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="text-blue-600 hover:text-blue-700"
>
Back to SIM Management
</Link>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h1 className="text-xl font-semibold text-gray-900 mb-1">Reissue eSIM</h1>
<p className="text-sm text-gray-600 mb-6">
Enter the new EID to transfer this eSIM to. We will show your current EID for
confirmation.
</p>
{detailsLoading ? (
<div className="text-gray-600">Loading current eSIM details</div>
) : (
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700">Current EID</label>
<div className="mt-1 text-sm text-gray-900 font-mono bg-gray-50 rounded-md border border-gray-200 p-2">
{oldEid || "—"}
</div>
</div>
)}
{message && (
<div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">
{message}
</div>
)}
{error && (
<div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">
{error}
</div>
)}
<form onSubmit={e => void submit(e)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">New EID</label>
<input
type="text"
inputMode="numeric"
value={newEid}
onChange={e => setNewEid(e.target.value.trim())}
placeholder="32-digit EID (e.g., 8904….)"
className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono"
maxLength={32}
/>
<p className="text-xs text-gray-500 mt-1">Must be exactly 32 digits.</p>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={loading || !validEid(newEid)}
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50"
>
{loading ? "Processing…" : "Submit Reissue"}
</button>
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50"
>
Cancel
</Link>
</div>
</form>
</div>
</div>
</DashboardLayout>
);
}

View File

@ -413,15 +413,10 @@ const NavigationItem = memo(function NavigationItem({
const hasChildren = item.children && item.children.length > 0; const hasChildren = item.children && item.children.length > 0;
const isActive = hasChildren const isActive = hasChildren
? item.children?.some((child: NavigationChild) => ? (item.children?.some((child: NavigationChild) =>
pathname.startsWith((child.href || "").split(/[?#]/)[0]) pathname.startsWith((child.href || "").split(/[?#]/)[0])
) || false ) || false)
? item.children?.some((child: NavigationChild) => : (item.href ? pathname === item.href : false);
pathname.startsWith((child.href || "").split(/[?#]/)[0])
) || false
: item.href
? pathname === item.href
: false;
const handleLogout = () => { const handleLogout = () => {
void logout().then(() => { void logout().then(() => {

View File

@ -30,7 +30,7 @@ apps/portal/src/features/service-management/
## Integration ## Integration
- Entry point: `apps/portal/src/app/subscriptions/[id]/page.tsx` renders `ServiceManagementSection` - Entry point: `apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx` renders `ServiceManagementSection`
- Detection: SIM availability is inferred from `subscription.productName` including `sim` (case-insensitive) - Detection: SIM availability is inferred from `subscription.productName` including `sim` (case-insensitive)
## Future Expansion ## Future Expansion