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:
parent
86cd636b87
commit
bef5abcbda
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
557
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
557
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
5
apps/bff/src/vendors/vendors.module.ts
vendored
5
apps/bff/src/vendors/vendors.module.ts
vendored
@ -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 {}
|
||||
|
||||
@ -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 || "—"} />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user