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
const configurations = payload.configurations || {};
const configurations: Record<string, unknown> =
(payload.configurations as Record<string, unknown> | undefined) ?? {};
await this.simFulfillmentService.fulfillSimOrder({
orderDetails: context.orderDetails,

View File

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

View File

@ -1,17 +1,4 @@
import {
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 { Inject, Injectable, BadRequestException, InternalServerErrorException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import type {
@ -30,46 +17,41 @@ import type {
FreebititPlanChangeResponse,
FreebititCancelPlanRequest,
FreebititCancelPlanResponse,
FreebititEsimReissueRequest,
FreebititEsimReissueResponse,
FreebititEsimAddAccountRequest,
FreebititEsimAddAccountResponse,
FreebititEsimAccountActivationRequest,
FreebititEsimAccountActivationResponse,
FreebititAddSpecRequest,
FreebititAddSpecResponse,
SimDetails,
SimUsage,
SimTopUpHistory,
FreebititAddSpecRequest,
FreebititAddSpecResponse,
} from "./interfaces/freebit.types";
@Injectable()
export class FreebititService {
private readonly config: FreebititConfig;
private authKeyCache: {
token: string;
expiresAt: number;
} | null = null;
private authKeyCache: { token: string; expiresAt: number } | null = null;
constructor(
private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger
@Inject(Logger) private readonly logger: Logger
) {
this.config = {
baseUrl:
this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api",
baseUrl: this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api",
oemId: this.configService.get<string>("FREEBIT_OEM_ID") || "PASI",
oemKey: this.configService.get<string>("FREEBIT_OEM_KEY") || "",
timeout: this.configService.get<number>("FREEBIT_TIMEOUT") || 30000,
retryAttempts: this.configService.get<number>("FREEBIT_RETRY_ATTEMPTS") || 3,
detailsEndpoint:
this.configService.get<string>("FREEBIT_DETAILS_ENDPOINT") || "/master/getAcnt/",
detailsEndpoint: this.configService.get<string>("FREEBIT_DETAILS_ENDPOINT") || "/master/getAcnt/",
};
// Warn if critical configuration is missing
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.debug("Freebit service initialized", {
this.logger.debug("Freebit service initialized", {
baseUrl: this.config.baseUrl,
oemId: this.config.oemId,
@ -77,21 +59,8 @@ export class FreebititService {
});
}
/**
* Map Freebit SIM status to portal 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";
private mapSimStatus(status: string): "active" | "suspended" | "cancelled" | "pending" {
switch (status) {
case "active":
return "active";
case "suspended":
@ -103,38 +72,24 @@ export class FreebititService {
return "cancelled";
default:
return "pending";
return "pending";
}
}
/**
* Get or refresh authentication token
*/
private async getAuthKey(): Promise<string> {
// Check if we have a valid cached token
if (this.authKeyCache && this.authKeyCache.expiresAt > Date.now()) {
return this.authKeyCache.token;
}
try {
// Check if configuration is available
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");
}
const request: FreebititAuthRequest = {
oemId: this.config.oemId,
oemKey: this.config.oemKey,
};
const request: FreebititAuthRequest = { oemId: this.config.oemId, oemKey: this.config.oemKey };
const response = await fetch(`${this.config.baseUrl}/authOem/`, {
method: "POST",
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Type": "application/x-www-form-urlencoded",
},
headers: { "Content-Type": "application/x-www-form-urlencoded" },
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;
if (data.resultCode !== "100") {
if (data.resultCode !== "100") {
throw new FreebititErrorImpl(
`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.logger.log("Successfully authenticated with Freebit API");
this.authKeyCache = { token: data.authKey, expiresAt: Date.now() + 50 * 60 * 1000 };
this.logger.log("Successfully authenticated with Freebit API");
return data.authKey;
} catch (error: any) {
@ -170,22 +116,15 @@ export class FreebititService {
}
}
/**
* Make authenticated API request with error handling
*/
private async makeAuthenticatedRequest<T>(endpoint: string, data: any): Promise<T> {
private async makeAuthenticatedRequest<T>(endpoint: string, data: Record<string, unknown>): Promise<T> {
const authKey = await this.getAuthKey();
const requestData = { ...(data as Record<string, unknown>), authKey };
const requestData = { ...data, authKey };
try {
const url = `${this.config.baseUrl}${endpoint}`;
const response = await fetch(url, {
method: "POST",
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Type": "application/x-www-form-urlencoded",
},
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `json=${JSON.stringify(requestData)}`,
});
@ -205,50 +144,57 @@ export class FreebititService {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const responseData = (await response.json()) as T;
// Check for API-level errors
if (responseData && (responseData as any).resultCode !== "100") {
const errorData = responseData as any;
const responseData = (await response.json()) as any;
if (responseData && responseData.resultCode && responseData.resultCode !== "100") {
throw new FreebititErrorImpl(
`API Error: ${errorData.status?.message || "Unknown error"}`,
errorData.resultCode,
errorData.status?.statusCode,
errorData.status?.message
`API Error: ${responseData.status?.message || "Unknown error"}`,
responseData.resultCode,
responseData.status?.statusCode,
responseData.status?.message
);
}
this.logger.debug("Freebit API Request Success", {
this.logger.debug("Freebit API Request Success", {
endpoint,
resultCode: rc,
});
return responseData;
this.logger.debug("Freebit API Request Success", { endpoint });
return responseData as T;
} catch (error) {
if (error instanceof FreebititErrorImpl) {
throw error;
}
this.logger.error(`Freebit API request failed: ${endpoint}`, { error: (error as any).message });
throw new InternalServerErrorException(`Freebit API request failed: ${(error as any).message}`);
}
}
this.logger.error(`Freebit API request failed: ${endpoint}`, {
error: (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),
});
throw new InternalServerErrorException(
`Freebit API request failed: ${(error as any).message}`
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, resultCode: rc });
return data;
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}`);
}
}
/**
* Get detailed SIM account information
*/
async getSimDetails(account: string): Promise<SimDetails> {
try {
const request: Omit<FreebititAccountDetailsRequest, "authKey"> = {
version: "2",
requestDatas: [{ kind: "MVNO", account }],
const request: Omit<FreebititAccountDetailsRequest, "authKey"> = {
version: "2",
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 lastError: unknown;
for (const ep of candidates) {
@ -305,131 +229,86 @@ export class FreebititService {
if (ep !== candidates[0]) {
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
}
response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(
ep,
request
);
response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(
ep,
request
);
break; // success
} catch (err: unknown) {
response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(ep, request as any);
break;
} catch (err: any) {
lastError = err;
if (typeof err?.message === "string" && err.message.includes("HTTP 404")) {
// try next candidate
continue;
}
// non-404 error, rethrow
throw err;
continue; // try next
}
}
}
if (!response) {
throw (
lastError ||
new InternalServerErrorException("Failed to fetch SIM details: all endpoints failed")
);
throw lastError || new Error("Failed to fetch account details");
}
const datas = (response as any).responseDatas;
const list = Array.isArray(datas) ? datas : datas ? [datas] : [];
if (!list.length) {
throw new BadRequestException("No SIM details found for this account");
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 resp = response.responseDatas as any;
const simData = Array.isArray(resp)
? (resp.find((d) => String(d.kind).toUpperCase() === "MVNO") || resp[0])
: resp;
const startDateRaw = simData.startDate ? String(simData.startDate) : undefined;
const startDate =
startDateRaw && /^\d{8}$/.test(startDateRaw)
? `${startDateRaw.slice(0, 4)}-${startDateRaw.slice(4, 6)}-${startDateRaw.slice(6, 8)}`
: startDateRaw;
const startDate =
startDateRaw && /^\d{8}$/.test(startDateRaw)
? `${startDateRaw.slice(0, 4)}-${startDateRaw.slice(4, 6)}-${startDateRaw.slice(6, 8)}`
: startDateRaw;
const size = String(simData.size || "").toLowerCase();
const isEsim = size === "esim" || !!simData.eid;
const planCode = String(simData.planCode || "");
const status = this.mapSimStatus(String(simData.state || ""));
const simDetails: SimDetails = {
account: String(simData.account ?? account),
msisdn: String(simData.account ?? account),
const remainingKb = Number(simData.quota) || 0;
const details: SimDetails = {
account: String(simData.account || account),
msisdn: String(simData.account || account),
iccid: simData.iccid ? String(simData.iccid) : undefined,
imsi: simData.imsi ? String(simData.imsi) : undefined,
eid: simData.eid,
planCode: simData.planCode,
status: this.mapSimStatus(String(simData.state || "pending")),
simType: simData.eid ? "esim" : "physical",
size: simData.size,
hasVoice: simData.talk === 10,
hasSms: simData.sms === 10,
remainingQuotaKb: typeof simData.quota === "number" ? simData.quota : 0,
remainingQuotaMb:
typeof simData.quota === "number" ? Math.round((simData.quota / 1024) * 100) / 100 : 0,
startDate,
eid: simData.eid ? String(simData.eid) : undefined,
planCode,
status,
simType: isEsim ? "esim" : "physical",
size: (size as any) || (isEsim ? "esim" : "nano"),
hasVoice: Number(simData.talk) === 10,
hasSms: Number(simData.sms) === 10,
remainingQuotaKb: remainingKb,
remainingQuotaMb: Math.round((remainingKb / 1024) * 100) / 100,
startDate: simData.startDate ? String(simData.startDate) : undefined,
ipv4: simData.ipv4,
ipv6: simData.ipv6,
voiceMailEnabled: simData.voicemail === 10 || simData.voiceMail === 10,
callWaitingEnabled: simData.callwaiting === 10 || simData.callWaiting === 10,
internationalRoamingEnabled: simData.worldwing === 10 || simData.worldWing === 10,
voiceMailEnabled: Number(simData.voicemail ?? simData.voiceMail) === 10,
callWaitingEnabled: Number(simData.callwaiting ?? simData.callWaiting) === 10,
internationalRoamingEnabled: Number(simData.worldwing ?? simData.worldWing) === 10,
networkType: simData.contractLine || undefined,
pendingOperations: simData.async
? [
{
operation: simData.async.func,
scheduledDate: String(simData.async.date),
},
]
: undefined,
pendingOperations: simData.async
? [
{
operation: simData.async.func,
scheduledDate: String(simData.async.date),
},
]
? [{ operation: String(simData.async.func), scheduledDate: String(simData.async.date) }]
: undefined,
};
this.logger.log(`Retrieved SIM details for account ${account}`, {
account,
status: simDetails.status,
planCode: simDetails.planCode,
status: details.status,
planCode: details.planCode,
});
return simDetails;
return details;
} catch (error: any) {
this.logger.error(`Failed to get SIM details for account ${account}`, {
error: error.message,
});
this.logger.error(`Failed to get SIM details for account ${account}`, { error: error.message });
throw error;
}
}
/**
* Get SIM data usage information
*/
async getSimUsage(account: string): Promise<SimUsage> {
try {
const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account };
const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account };
const response = await this.makeAuthenticatedRequest<FreebititTrafficInfoResponse>(
"/mvno/getTrafficInfo/",
"/mvno/getTrafficInfo/",
request
request as any
);
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],
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[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) / 1024) * 100) / 100,
usageMb: Math.round(((parseInt(usage, 10) || 0) / 1024) * 100) / 100,
}));
const simUsage: SimUsage = {
@ -438,7 +317,6 @@ export class FreebititService {
todayUsageMb: Math.round((todayUsageKb / 1024) * 100) / 100,
recentDaysUsage: recentDaysData,
isBlacklisted: response.traffic.blackList === "10",
isBlacklisted: response.traffic.blackList === "10",
};
this.logger.log(`Retrieved SIM usage for account ${account}`, {
@ -455,30 +333,13 @@ export class FreebititService {
}
}
/**
* Top up SIM data quota
*/
async topUpSim(
account: string,
quotaMb: number,
options: {
campaignCode?: string;
expiryDate?: string;
scheduledAt?: string;
} = {}
): Promise<void> {
async topUpSim(
account: string,
quotaMb: number,
options: {
campaignCode?: string;
expiryDate?: string;
scheduledAt?: string;
} = {}
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
): Promise<void> {
try {
const quotaKb = quotaMb * 1024;
const quotaKb = Math.round(quotaMb * 1024);
const request: Omit<FreebititTopUpRequest, "authKey"> = {
account,
quota: quotaKb,
@ -486,23 +347,19 @@ export class FreebititService {
expire: options.expiryDate,
};
// Use PA05-22 for scheduled top-ups, PA04-04 for immediate
const endpoint = options.scheduledAt ? "/mvno/eachQuota/" : "/master/addSpec/";
if (options.scheduledAt && endpoint === "/mvno/eachQuota/") {
const scheduled = !!options.scheduledAt;
const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
if (scheduled) {
(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}`, {
account,
endpoint,
quotaMb,
quotaKb,
units: isScheduled ? "KB (PA05-22)" : "MB (PA04-04)",
campaignCode: options.campaignCode,
scheduled: isScheduled,
scheduled,
});
} catch (error: any) {
this.logger.error(`Failed to top up SIM ${account}`, {
@ -514,38 +371,19 @@ export class FreebititService {
}
}
/**
* 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> {
async getSimTopUpHistory(account: string, fromDate: string, toDate: string): Promise<SimTopUpHistory> {
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>(
"/mvno/getQuotaHistory/",
"/mvno/getQuotaHistory/",
request
request as any
);
const history: SimTopUpHistory = {
account,
totalAdditions: response.total,
additionCount: response.count,
history: response.quotaHistory.map(item => ({
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,
@ -569,27 +407,12 @@ export class FreebititService {
}
}
/**
* Change SIM plan
*/
async changeSimPlan(
account: string,
newPlanCode: string,
options: {
assignGlobalIp?: boolean;
scheduledAt?: string;
} = {}
): Promise<{ ipv4?: string; ipv6?: string }> {
async changeSimPlan(
account: string,
newPlanCode: string,
options: {
assignGlobalIp?: boolean;
scheduledAt?: string;
} = {}
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
): Promise<{ ipv4?: string; ipv6?: string }> {
try {
const request: Omit<FreebititPlanChangeRequest, "authKey"> = {
const request: Omit<FreebititPlanChangeRequest, "authKey"> = {
account,
plancode: newPlanCode,
@ -599,8 +422,7 @@ export class FreebititService {
const response = await this.makeAuthenticatedRequest<FreebititPlanChangeResponse>(
"/mvno/changePlan/",
"/mvno/changePlan/",
request
request as any
);
this.logger.log(`Successfully changed SIM plan for account ${account}`, {
@ -610,10 +432,7 @@ export class FreebititService {
scheduled: !!options.scheduledAt,
});
return {
ipv4: response.ipv4,
ipv6: response.ipv6,
};
return { ipv4: response.ipv4, ipv6: response.ipv6 };
} catch (error: any) {
this.logger.error(`Failed to change SIM plan for account ${account}`, {
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(
account: string,
features: {
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'
}
features: { voiceMailEnabled?: boolean; callWaitingEnabled?: boolean; internationalRoamingEnabled?: boolean; networkType?: string }
): Promise<void> {
try {
const request: Omit<FreebititAddSpecRequest, "authKey"> = {
account,
};
const request: Omit<FreebititAddSpecRequest, "authKey"> = { account };
if (typeof features.voiceMailEnabled === "boolean") {
request.voiceMail = features.voiceMailEnabled ? ("10" as const) : ("20" as const);
request.voicemail = request.voiceMail; // include alternate casing for compatibility
request.voiceMail = features.voiceMailEnabled ? "10" : "20";
request.voicemail = request.voiceMail;
}
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;
}
if (typeof features.internationalRoamingEnabled === "boolean") {
request.worldWing = features.internationalRoamingEnabled
? ("10" as const)
: ("20" as const);
request.worldWing = features.internationalRoamingEnabled ? "10" : "20";
request.worldwing = request.worldWing;
}
if (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}`, {
account,
voiceMailEnabled: features.voiceMailEnabled,
@ -680,104 +476,53 @@ export class FreebititService {
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to update SIM features for account ${account}`, {
error: message,
account,
});
this.logger.error(`Failed to update SIM features for account ${account}`, { error: message, account });
throw error as Error;
}
}
/**
* Cancel SIM service via PA02-04 (master/cnclAcnt)
*/
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
try {
const request: Omit<FreebititCancelPlanRequest, "authKey"> = {
account,
runDate: scheduledAt,
};
await this.makeAuthenticatedRequest<FreebititCancelPlanResponse>(
"/mvno/releasePlan/",
request
);
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
account,
runDate: scheduledAt,
});
const request: Omit<FreebititCancelPlanRequest, "authKey"> = { account, runTime: scheduledAt };
await this.makeAuthenticatedRequest<FreebititCancelPlanResponse>("/mvno/releasePlan/", request as any);
this.logger.log(`Successfully cancelled SIM for account ${account}`, { account, runTime: scheduledAt });
} catch (error: any) {
this.logger.error(`Failed to cancel SIM for account ${account}`, {
error: error.message,
account,
});
this.logger.error(`Failed to cancel SIM for account ${account}`, { error: error.message, account });
throw error as Error;
}
}
/**
* Reissue eSIM profile using reissueProfile endpoint
*/
async reissueEsimProfile(account: string): Promise<void> {
try {
const request: Omit<FreebititEsimReissueRequest, "authKey"> = { account };
await this.makeAuthenticatedRequest<FreebititEsimReissueResponse>(
"/esim/reissueProfile/",
request
);
this.logger.log(`Successfully reissued eSIM profile for account ${account}`, { account });
await this.makeAuthenticatedRequest<FreebititEsimReissueResponse>("/esim/reissueProfile/", request as any);
this.logger.log(`Successfully requested eSIM reissue for account ${account}`);
} catch (error: any) {
this.logger.error(`Failed to reissue eSIM profile for account ${account}`, {
error: error.message,
account,
});
this.logger.error(`Failed to reissue eSIM profile for account ${account}`, { error: error.message, account });
throw error as Error;
}
}
/**
* Reissue eSIM profile using addAcnt endpoint (enhanced method based on Salesforce implementation)
*/
async reissueEsimProfileEnhanced(
account: string,
account: string,
newEid: string,
options: {
oldProductNumber?: string;
oldEid?: string;
planCode?: string;
} = {}
options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {}
): Promise<void> {
try {
const request: Omit<FreebititEsimAddAccountRequest, "authKey"> = {
aladinOperated: "20",
const request: Omit<FreebititEsimAddAccountRequest, "authKey"> = {
aladinOperated: "20",
account,
eid: newEid,
addKind: "R", // R = reissue
addKind: "R", // R = reissue
reissue: {
oldProductNumber: options.oldProductNumber,
oldEid: options.oldEid,
},
addKind: "R",
reissue: { oldProductNumber: options.oldProductNumber, oldEid: options.oldEid },
planCode: options.planCode,
};
// Add optional fields
if (options.planCode) {
request.planCode = options.planCode;
}
await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse>(
"/mvno/esim/addAcnt/",
"/mvno/esim/addAcnt/",
request
request as any
);
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,
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: {
account: string; // MSISDN to be activated (required by Freebit)
eid: string; // 32-digit EID
account: string;
eid: string;
planCode?: string;
contractLine?: "4G" | "5G";
aladinOperated?: "10" | "20";
shipDate?: string; // YYYYMMDD; if provided we send as scheduled activation date
shipDate?: string;
mnp?: { reserveNumber: string; reserveExpireDate: string };
identity?: {
firstnameKanji?: string;
@ -815,16 +556,7 @@ export class FreebititService {
birthday?: string;
};
}): Promise<void> {
const {
account,
eid,
planCode,
contractLine,
aladinOperated = "10",
shipDate,
mnp,
identity,
} = params;
const { account, eid, planCode, contractLine, aladinOperated = "10", shipDate, mnp, identity } = params;
if (!account || !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> {
try {
await this.getAuthKey();
@ -872,19 +601,17 @@ export class FreebititService {
}
}
// Custom error class for Freebit API errors
class FreebititErrorImpl extends Error {
public readonly resultCode: string;
public readonly statusCode: string;
public readonly statusCode: string | number;
public readonly freebititMessage: string;
constructor(message: string, resultCode: string, statusCode: string, freebititMessage: string) {
constructor(message: string, resultCode: string, statusCode: string, freebititMessage: string) {
constructor(message: string, resultCode: string | number, statusCode: string | number, freebititMessage: string) {
super(message);
this.name = "FreebititError";
this.name = "FreebititError";
this.resultCode = resultCode;
this.resultCode = String(resultCode);
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 {
oemId: string; // 4-char alphanumeric ISP identifier
oemKey: string; // 32-char auth key
oemId: string; // 4-char alphanumeric ISP identifier
oemKey: string; // 32-char auth key
}
export interface FreebititAuthResponse {
resultCode: string;
status: {
message: string;
statusCode: string;
statusCode: string | number;
};
authKey: string; // Token for subsequent API calls
authKey: string; // Token for subsequent API calls
}
export interface FreebititAccountDetailsRequest {
@ -33,7 +30,6 @@ export interface FreebititAccountDetailsResponse {
statusCode: string | number;
};
masterAccount?: string;
// Docs show this can be an array (MASTER + MVNO) or a single object for MVNO
responseDatas:
| {
kind: "MASTER" | "MVNO" | string;
@ -52,11 +48,8 @@ export interface FreebititAccountDetailsResponse {
talk?: number; // 10=active, 20=inactive
ipv4?: string;
ipv6?: string;
quota?: number; // Remaining quota (units vary by env)
async?: {
func: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string;
date: string | number;
};
quota?: number; // Remaining quota
async?: { func: string; date: string | number };
}
| Array<{
kind: "MASTER" | "MVNO" | string;
@ -76,11 +69,7 @@ export interface FreebititAccountDetailsResponse {
ipv4?: string;
ipv6?: string;
quota?: number;
async?: {
func: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string;
date: string | number;
};
}>;
async?: { func: string; date: string | number };
}>;
}
@ -93,15 +82,13 @@ export interface FreebititTrafficInfoResponse {
resultCode: string;
status: {
message: string;
statusCode: string;
statusCode: string | number;
};
account: string;
traffic: {
today: string; // Today's usage in KB
today: string; // Today's usage in KB
inRecentDays: string; // Comma-separated recent days usage
blackList: string; // 10=blacklisted, 20=not blacklisted
blackList: string; // 10=blacklisted, 20=not blacklisted
};
}
@ -115,17 +102,14 @@ export interface FreebititTopUpRequest {
export interface FreebititTopUpResponse {
resultCode: string;
status: {
message: string;
statusCode: string;
};
status: { message: string; statusCode: string | number };
}
// AddSpec request for updating SIM options/features immediately
export interface FreebititAddSpecRequest {
authKey: 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
voiceMail?: "10" | "20";
voicemail?: "10" | "20";
@ -133,21 +117,12 @@ export interface FreebititAddSpecRequest {
callwaiting?: "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'
}
export interface FreebititAddSpecResponse {
resultCode: string;
status: {
message: string;
statusCode: string;
};
status: { message: string; statusCode: string | number };
}
export interface FreebititQuotaHistoryRequest {
@ -155,25 +130,15 @@ export interface FreebititQuotaHistoryRequest {
account: string;
fromDate: string; // YYYYMMDD
toDate: string; // YYYYMMDD
fromDate: string; // YYYYMMDD
toDate: string; // YYYYMMDD
}
export interface FreebititQuotaHistoryResponse {
resultCode: string;
status: {
message: string;
statusCode: string;
};
status: { message: string; statusCode: string | number };
account: string;
total: number;
count: number;
quotaHistory: Array<{
quota: string;
expire: string;
date: string;
quotaCode: string;
}>;
quotaHistory: Array<{ quota: string; expire: string; date: string; quotaCode: string }>;
}
export interface FreebititPlanChangeRequest {
@ -181,46 +146,16 @@ export interface FreebititPlanChangeRequest {
account: string;
plancode: string;
globalip?: "0" | "1"; // 0=no IP, 1=assign global IP
runTime?: string; // YYYYMMDD - optional, immediate if omitted
runTime?: string; // YYYYMMDD - optional
}
export interface FreebititPlanChangeResponse {
resultCode: string;
status: {
message: string;
statusCode: string;
};
status: { message: string; statusCode: string | number };
ipv4?: 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 {
authKey: string;
account: string;
@ -239,16 +174,12 @@ export interface FreebititContractLineChangeResponse {
export interface FreebititCancelPlanRequest {
authKey: string;
account: string;
runTime?: string; // YYYYMMDD - optional, immediate if omitted
runTime?: string; // YYYYMMDD - optional, immediate if omitted
runTime?: string; // YYYYMMDD - optional
}
export interface FreebititCancelPlanResponse {
resultCode: string;
status: {
message: string;
statusCode: string;
};
status: { message: string; statusCode: string | number };
}
// PA02-04: Account Cancellation (master/cnclAcnt)
@ -261,10 +192,7 @@ export interface FreebititCancelAccountRequest {
export interface FreebititCancelAccountResponse {
resultCode: string;
status: {
message: string;
statusCode: string;
};
status: { message: string; statusCode: string | number };
}
export interface FreebititEsimReissueRequest {
@ -274,10 +202,7 @@ export interface FreebititEsimReissueRequest {
export interface FreebititEsimReissueResponse {
resultCode: string;
status: {
message: string;
statusCode: string;
};
status: { message: string; statusCode: string | number };
}
export interface FreebititEsimAddAccountRequest {
@ -286,23 +211,16 @@ export interface FreebititEsimAddAccountRequest {
account: string;
eid: string;
addKind: "N" | "R"; // N = new, R = reissue
addKind: "N" | "R"; // N = new, R = reissue
createType?: string;
simKind?: string;
planCode?: string;
contractLine?: string;
reissue?: {
oldProductNumber?: string;
oldEid?: string;
};
reissue?: { oldProductNumber?: string; oldEid?: string };
}
export interface FreebititEsimAddAccountResponse {
resultCode: string;
status: {
message: string;
statusCode: string;
};
status: { message: string; statusCode: string | number };
}
// PA05-41 eSIM Account Activation (addAcct)
@ -320,10 +238,7 @@ export interface FreebititEsimAccountActivationRequest {
addKind?: string; // e.g., 'R' for reissue
oldEid?: string;
oldProductNumber?: string;
mnp?: {
reserveNumber: string;
reserveExpireDate: string; // YYYYMMDD
};
mnp?: { reserveNumber: string; reserveExpireDate: string };
firstnameKanji?: string;
lastnameKanji?: string;
firstnameZenKana?: string;
@ -340,7 +255,7 @@ export interface FreebititEsimAccountActivationRequest {
export interface FreebititEsimAccountActivationResponse {
resultCode: number | string;
status?: unknown;
statusCode?: string;
statusCode?: string | number;
message?: string;
}
@ -355,9 +270,6 @@ export interface SimDetails {
status: "active" | "suspended" | "cancelled" | "pending";
simType: "physical" | "esim";
size: "standard" | "nano" | "micro" | "esim";
status: "active" | "suspended" | "cancelled" | "pending";
simType: "physical" | "esim";
size: "standard" | "nano" | "micro" | "esim";
hasVoice: boolean;
hasSms: boolean;
remainingQuotaKb: number;
@ -365,26 +277,18 @@ export interface SimDetails {
startDate?: string;
ipv4?: string;
ipv6?: string;
// Optional extended service features
voiceMailEnabled?: boolean;
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: string; // e.g., '4G' or '5G'
pendingOperations?: Array<{
operation: string;
scheduledDate: string;
}>;
pendingOperations?: Array<{ operation: string; scheduledDate: string }>;
}
export interface SimUsage {
account: string;
todayUsageKb: number;
todayUsageMb: number;
recentDaysUsage: Array<{
date: string;
usageKb: number;
usageMb: number;
}>;
recentDaysUsage: Array<{ date: string; usageKb: number; usageMb: number }>;
isBlacklisted: boolean;
}
@ -404,7 +308,7 @@ export interface SimTopUpHistory {
// Error handling
export interface FreebititError extends Error {
resultCode: string;
statusCode: string;
statusCode: string | number;
freebititMessage: string;
}

View File

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

View File

@ -1,14 +1,32 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { authenticatedApi } from "@/lib/api";
import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard";
import { formatPlanShort } from "@/lib/plan";
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() {
const params = useParams();
const router = useRouter();
@ -162,7 +180,6 @@ export default function SimCancelPage() {
requested from this online form. Please contact Assist Solutions Customer Support
(info@asolutions.co.jp) for more information.
</Notice>
4
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<InfoRow label="SIM" value={details?.msisdn || "—"} />

View File

@ -2,7 +2,6 @@
import { useState, useMemo } from "react";
import Link from "next/link";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { useParams } from "next/navigation";
import { authenticatedApi } from "@/lib/api";
@ -55,7 +54,6 @@ export default function SimChangePlanPage() {
};
return (
<DashboardLayout>
<div className="max-w-3xl mx-auto p-6">
<div className="mb-4">
<Link
@ -143,6 +141,5 @@ export default function SimChangePlanPage() {
</form>
</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 isActive = hasChildren
? item.children?.some((child: NavigationChild) =>
? (item.children?.some((child: NavigationChild) =>
pathname.startsWith((child.href || "").split(/[?#]/)[0])
) || false
? item.children?.some((child: NavigationChild) =>
pathname.startsWith((child.href || "").split(/[?#]/)[0])
) || false
: item.href
? pathname === item.href
: false;
) || false)
: (item.href ? pathname === item.href : false);
const handleLogout = () => {
void logout().then(() => {

View File

@ -30,7 +30,7 @@ apps/portal/src/features/service-management/
## 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)
## Future Expansion