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
|
// Extract configurations from the original payload
|
||||||
const configurations = payload.configurations || {};
|
const configurations: Record<string, unknown> =
|
||||||
|
(payload.configurations as Record<string, unknown> | undefined) ?? {};
|
||||||
|
|
||||||
await this.simFulfillmentService.fulfillSimOrder({
|
await this.simFulfillmentService.fulfillSimOrder({
|
||||||
orderDetails: context.orderDetails,
|
orderDetails: context.orderDetails,
|
||||||
|
|||||||
@ -89,8 +89,8 @@ export class SimFulfillmentService {
|
|||||||
activationType: "Immediate" | "Scheduled";
|
activationType: "Immediate" | "Scheduled";
|
||||||
scheduledAt?: string;
|
scheduledAt?: string;
|
||||||
mnp?: {
|
mnp?: {
|
||||||
reserveNumber: string;
|
reserveNumber?: string;
|
||||||
reserveExpireDate: string;
|
reserveExpireDate?: string;
|
||||||
account?: string;
|
account?: string;
|
||||||
firstnameKanji?: string;
|
firstnameKanji?: string;
|
||||||
lastnameKanji?: string;
|
lastnameKanji?: string;
|
||||||
@ -124,10 +124,13 @@ export class SimFulfillmentService {
|
|||||||
planCode: planSku,
|
planCode: planSku,
|
||||||
contractLine: "5G",
|
contractLine: "5G",
|
||||||
shipDate: activationType === "Scheduled" ? scheduledAt : undefined,
|
shipDate: activationType === "Scheduled" ? scheduledAt : undefined,
|
||||||
mnp: mnp ? {
|
mnp:
|
||||||
reserveNumber: mnp.reserveNumber,
|
mnp && mnp.reserveNumber && mnp.reserveExpireDate
|
||||||
reserveExpireDate: mnp.reserveExpireDate,
|
? {
|
||||||
} : undefined,
|
reserveNumber: mnp.reserveNumber,
|
||||||
|
reserveExpireDate: mnp.reserveExpireDate,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
identity: mnp ? {
|
identity: mnp ? {
|
||||||
firstnameKanji: mnp.firstnameKanji,
|
firstnameKanji: mnp.firstnameKanji,
|
||||||
lastnameKanji: mnp.lastnameKanji,
|
lastnameKanji: mnp.lastnameKanji,
|
||||||
|
|||||||
565
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
565
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
@ -1,17 +1,4 @@
|
|||||||
import {
|
import { Inject, Injectable, BadRequestException, InternalServerErrorException } from "@nestjs/common";
|
||||||
Injectable,
|
|
||||||
Inject,
|
|
||||||
BadRequestException,
|
|
||||||
InternalServerErrorException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { Logger } from "nestjs-pino";
|
|
||||||
import {
|
|
||||||
Injectable,
|
|
||||||
Inject,
|
|
||||||
BadRequestException,
|
|
||||||
InternalServerErrorException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import type {
|
import type {
|
||||||
@ -30,46 +17,41 @@ import type {
|
|||||||
FreebititPlanChangeResponse,
|
FreebititPlanChangeResponse,
|
||||||
FreebititCancelPlanRequest,
|
FreebititCancelPlanRequest,
|
||||||
FreebititCancelPlanResponse,
|
FreebititCancelPlanResponse,
|
||||||
|
FreebititEsimReissueRequest,
|
||||||
|
FreebititEsimReissueResponse,
|
||||||
FreebititEsimAddAccountRequest,
|
FreebititEsimAddAccountRequest,
|
||||||
FreebititEsimAddAccountResponse,
|
FreebititEsimAddAccountResponse,
|
||||||
|
FreebititEsimAccountActivationRequest,
|
||||||
|
FreebititEsimAccountActivationResponse,
|
||||||
|
FreebititAddSpecRequest,
|
||||||
|
FreebititAddSpecResponse,
|
||||||
SimDetails,
|
SimDetails,
|
||||||
SimUsage,
|
SimUsage,
|
||||||
SimTopUpHistory,
|
SimTopUpHistory,
|
||||||
FreebititAddSpecRequest,
|
|
||||||
FreebititAddSpecResponse,
|
|
||||||
} from "./interfaces/freebit.types";
|
} from "./interfaces/freebit.types";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FreebititService {
|
export class FreebititService {
|
||||||
private readonly config: FreebititConfig;
|
private readonly config: FreebititConfig;
|
||||||
private authKeyCache: {
|
private authKeyCache: { token: string; expiresAt: number } | null = null;
|
||||||
token: string;
|
|
||||||
expiresAt: number;
|
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
@Inject(Logger) private readonly logger: Logger
|
|
||||||
) {
|
) {
|
||||||
this.config = {
|
this.config = {
|
||||||
baseUrl:
|
baseUrl: this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api",
|
||||||
this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api",
|
|
||||||
oemId: this.configService.get<string>("FREEBIT_OEM_ID") || "PASI",
|
oemId: this.configService.get<string>("FREEBIT_OEM_ID") || "PASI",
|
||||||
oemKey: this.configService.get<string>("FREEBIT_OEM_KEY") || "",
|
oemKey: this.configService.get<string>("FREEBIT_OEM_KEY") || "",
|
||||||
timeout: this.configService.get<number>("FREEBIT_TIMEOUT") || 30000,
|
timeout: this.configService.get<number>("FREEBIT_TIMEOUT") || 30000,
|
||||||
retryAttempts: this.configService.get<number>("FREEBIT_RETRY_ATTEMPTS") || 3,
|
retryAttempts: this.configService.get<number>("FREEBIT_RETRY_ATTEMPTS") || 3,
|
||||||
detailsEndpoint:
|
detailsEndpoint: this.configService.get<string>("FREEBIT_DETAILS_ENDPOINT") || "/master/getAcnt/",
|
||||||
this.configService.get<string>("FREEBIT_DETAILS_ENDPOINT") || "/master/getAcnt/",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Warn if critical configuration is missing
|
|
||||||
if (!this.config.oemKey) {
|
if (!this.config.oemKey) {
|
||||||
this.logger.warn("FREEBIT_OEM_KEY is not configured. SIM management features will not work.");
|
this.logger.warn("FREEBIT_OEM_KEY is not configured. SIM management features will not work.");
|
||||||
this.logger.warn("FREEBIT_OEM_KEY is not configured. SIM management features will not work.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug("Freebit service initialized", {
|
|
||||||
this.logger.debug("Freebit service initialized", {
|
this.logger.debug("Freebit service initialized", {
|
||||||
baseUrl: this.config.baseUrl,
|
baseUrl: this.config.baseUrl,
|
||||||
oemId: this.config.oemId,
|
oemId: this.config.oemId,
|
||||||
@ -77,21 +59,8 @@ export class FreebititService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private mapSimStatus(status: string): "active" | "suspended" | "cancelled" | "pending" {
|
||||||
* Map Freebit SIM status to portal status
|
switch (status) {
|
||||||
*/
|
|
||||||
private mapSimStatus(freebititStatus: string): "active" | "suspended" | "cancelled" | "pending" {
|
|
||||||
private mapSimStatus(freebititStatus: string): "active" | "suspended" | "cancelled" | "pending" {
|
|
||||||
switch (freebititStatus) {
|
|
||||||
case "active":
|
|
||||||
return "active";
|
|
||||||
case "suspended":
|
|
||||||
return "suspended";
|
|
||||||
case "temporary":
|
|
||||||
case "waiting":
|
|
||||||
return "pending";
|
|
||||||
case "obsolete":
|
|
||||||
return "cancelled";
|
|
||||||
case "active":
|
case "active":
|
||||||
return "active";
|
return "active";
|
||||||
case "suspended":
|
case "suspended":
|
||||||
@ -103,38 +72,24 @@ export class FreebititService {
|
|||||||
return "cancelled";
|
return "cancelled";
|
||||||
default:
|
default:
|
||||||
return "pending";
|
return "pending";
|
||||||
return "pending";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or refresh authentication token
|
|
||||||
*/
|
|
||||||
private async getAuthKey(): Promise<string> {
|
private async getAuthKey(): Promise<string> {
|
||||||
// Check if we have a valid cached token
|
|
||||||
if (this.authKeyCache && this.authKeyCache.expiresAt > Date.now()) {
|
if (this.authKeyCache && this.authKeyCache.expiresAt > Date.now()) {
|
||||||
return this.authKeyCache.token;
|
return this.authKeyCache.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if configuration is available
|
|
||||||
if (!this.config.oemKey) {
|
if (!this.config.oemKey) {
|
||||||
throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing");
|
throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing");
|
||||||
throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const request: FreebititAuthRequest = {
|
const request: FreebititAuthRequest = { oemId: this.config.oemId, oemKey: this.config.oemKey };
|
||||||
oemId: this.config.oemId,
|
|
||||||
oemKey: this.config.oemKey,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await fetch(`${this.config.baseUrl}/authOem/`, {
|
const response = await fetch(`${this.config.baseUrl}/authOem/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
method: "POST",
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
body: `json=${JSON.stringify(request)}`,
|
body: `json=${JSON.stringify(request)}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -143,9 +98,6 @@ export class FreebititService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as FreebititAuthResponse;
|
const data = (await response.json()) as FreebititAuthResponse;
|
||||||
const data = (await response.json()) as FreebititAuthResponse;
|
|
||||||
|
|
||||||
if (data.resultCode !== "100") {
|
|
||||||
if (data.resultCode !== "100") {
|
if (data.resultCode !== "100") {
|
||||||
throw new FreebititErrorImpl(
|
throw new FreebititErrorImpl(
|
||||||
`Authentication failed: ${data.status.message}`,
|
`Authentication failed: ${data.status.message}`,
|
||||||
@ -155,13 +107,7 @@ export class FreebititService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the token for 50 minutes (assuming 60min expiry)
|
this.authKeyCache = { token: data.authKey, expiresAt: Date.now() + 50 * 60 * 1000 };
|
||||||
this.authKeyCache = {
|
|
||||||
token: data.authKey,
|
|
||||||
expiresAt: Date.now() + 50 * 60 * 1000,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.logger.log("Successfully authenticated with Freebit API");
|
|
||||||
this.logger.log("Successfully authenticated with Freebit API");
|
this.logger.log("Successfully authenticated with Freebit API");
|
||||||
return data.authKey;
|
return data.authKey;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -170,22 +116,15 @@ export class FreebititService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async makeAuthenticatedRequest<T>(endpoint: string, data: Record<string, unknown>): Promise<T> {
|
||||||
* Make authenticated API request with error handling
|
|
||||||
*/
|
|
||||||
private async makeAuthenticatedRequest<T>(endpoint: string, data: any): Promise<T> {
|
|
||||||
const authKey = await this.getAuthKey();
|
const authKey = await this.getAuthKey();
|
||||||
const requestData = { ...(data as Record<string, unknown>), authKey };
|
const requestData = { ...data, authKey };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${this.config.baseUrl}${endpoint}`;
|
const url = `${this.config.baseUrl}${endpoint}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
method: "POST",
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
body: `json=${JSON.stringify(requestData)}`,
|
body: `json=${JSON.stringify(requestData)}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -205,50 +144,57 @@ export class FreebititService {
|
|||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseData = (await response.json()) as T;
|
const responseData = (await response.json()) as any;
|
||||||
|
if (responseData && responseData.resultCode && responseData.resultCode !== "100") {
|
||||||
// Check for API-level errors
|
|
||||||
if (responseData && (responseData as any).resultCode !== "100") {
|
|
||||||
const errorData = responseData as any;
|
|
||||||
throw new FreebititErrorImpl(
|
throw new FreebititErrorImpl(
|
||||||
`API Error: ${errorData.status?.message || "Unknown error"}`,
|
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
||||||
errorData.resultCode,
|
responseData.resultCode,
|
||||||
errorData.status?.statusCode,
|
responseData.status?.statusCode,
|
||||||
errorData.status?.message
|
responseData.status?.message
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug("Freebit API Request Success", {
|
this.logger.debug("Freebit API Request Success", { endpoint });
|
||||||
this.logger.debug("Freebit API Request Success", {
|
return responseData as T;
|
||||||
endpoint,
|
|
||||||
resultCode: rc,
|
|
||||||
});
|
|
||||||
|
|
||||||
return responseData;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof FreebititErrorImpl) {
|
if (error instanceof FreebititErrorImpl) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
this.logger.error(`Freebit API request failed: ${endpoint}`, { error: (error as any).message });
|
||||||
this.logger.error(`Freebit API request failed: ${endpoint}`, {
|
throw new InternalServerErrorException(`Freebit API request failed: ${(error as any).message}`);
|
||||||
error: (error as any).message,
|
}
|
||||||
});
|
}
|
||||||
throw new InternalServerErrorException(
|
|
||||||
`Freebit API request failed: ${(error as any).message}`
|
private async makeAuthenticatedJsonRequest<T>(endpoint: string, payload: Record<string, unknown>): Promise<T> {
|
||||||
);
|
const url = `${this.config.baseUrl}${endpoint}`;
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const responseData = (await response.json()) as any;
|
||||||
|
if (responseData && responseData.resultCode && responseData.resultCode !== "100") {
|
||||||
|
throw new FreebititErrorImpl(
|
||||||
|
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
||||||
|
responseData.resultCode,
|
||||||
|
responseData.status?.statusCode,
|
||||||
|
responseData.status?.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.logger.debug("Freebit JSON API Request Success", { endpoint });
|
||||||
|
return responseData as T;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Freebit JSON API request failed: ${endpoint}`, { error: (error as any).message });
|
||||||
|
throw new InternalServerErrorException(`Freebit JSON API request failed: ${(error as any).message}`);
|
||||||
}
|
}
|
||||||
this.logger.debug("Freebit JSON API Request Success", { endpoint, resultCode: rc });
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get detailed SIM account information
|
|
||||||
*/
|
|
||||||
async getSimDetails(account: string): Promise<SimDetails> {
|
async getSimDetails(account: string): Promise<SimDetails> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititAccountDetailsRequest, "authKey"> = {
|
|
||||||
version: "2",
|
|
||||||
requestDatas: [{ kind: "MVNO", account }],
|
|
||||||
const request: Omit<FreebititAccountDetailsRequest, "authKey"> = {
|
const request: Omit<FreebititAccountDetailsRequest, "authKey"> = {
|
||||||
version: "2",
|
version: "2",
|
||||||
requestDatas: [{ kind: "MVNO", account }],
|
requestDatas: [{ kind: "MVNO", account }],
|
||||||
@ -276,28 +222,6 @@ export class FreebititService {
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
const configured = this.config.detailsEndpoint || "/master/getAcnt/";
|
|
||||||
const candidates = Array.from(
|
|
||||||
new Set([
|
|
||||||
configured,
|
|
||||||
configured.replace(/\/$/, ""),
|
|
||||||
"/master/getAcnt/",
|
|
||||||
"/master/getAcnt",
|
|
||||||
"/mvno/getAccountDetail/",
|
|
||||||
"/mvno/getAccountDetail",
|
|
||||||
"/mvno/getAcntDetail/",
|
|
||||||
"/mvno/getAcntDetail",
|
|
||||||
"/mvno/getAccountInfo/",
|
|
||||||
"/mvno/getAccountInfo",
|
|
||||||
"/mvno/getSubscriberInfo/",
|
|
||||||
"/mvno/getSubscriberInfo",
|
|
||||||
"/mvno/getInfo/",
|
|
||||||
"/mvno/getInfo",
|
|
||||||
"/master/getDetail/",
|
|
||||||
"/master/getDetail",
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
let response: FreebititAccountDetailsResponse | undefined;
|
let response: FreebititAccountDetailsResponse | undefined;
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
for (const ep of candidates) {
|
for (const ep of candidates) {
|
||||||
@ -305,132 +229,87 @@ export class FreebititService {
|
|||||||
if (ep !== candidates[0]) {
|
if (ep !== candidates[0]) {
|
||||||
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
|
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
|
||||||
}
|
}
|
||||||
response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(
|
response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(ep, request as any);
|
||||||
ep,
|
break;
|
||||||
request
|
} catch (err: any) {
|
||||||
);
|
|
||||||
response = await this.makeAuthenticatedRequest<FreebititAccountDetailsResponse>(
|
|
||||||
ep,
|
|
||||||
request
|
|
||||||
);
|
|
||||||
break; // success
|
|
||||||
} catch (err: unknown) {
|
|
||||||
lastError = err;
|
lastError = err;
|
||||||
if (typeof err?.message === "string" && err.message.includes("HTTP 404")) {
|
if (typeof err?.message === "string" && err.message.includes("HTTP 404")) {
|
||||||
// try next candidate
|
continue; // try next
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
// non-404 error, rethrow
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response) {
|
if (!response) {
|
||||||
throw (
|
throw lastError || new Error("Failed to fetch account details");
|
||||||
lastError ||
|
|
||||||
new InternalServerErrorException("Failed to fetch SIM details: all endpoints failed")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const datas = (response as any).responseDatas;
|
const resp = response.responseDatas as any;
|
||||||
const list = Array.isArray(datas) ? datas : datas ? [datas] : [];
|
const simData = Array.isArray(resp)
|
||||||
if (!list.length) {
|
? (resp.find((d) => String(d.kind).toUpperCase() === "MVNO") || resp[0])
|
||||||
throw new BadRequestException("No SIM details found for this account");
|
: resp;
|
||||||
throw new BadRequestException("No SIM details found for this account");
|
|
||||||
}
|
|
||||||
// Prefer the MVNO entry if present
|
|
||||||
const mvno =
|
|
||||||
list.find((d: any) => (d.kind || "").toString().toUpperCase() === "MVNO") || list[0];
|
|
||||||
const simData = mvno;
|
|
||||||
|
|
||||||
const startDateRaw = simData.startDate ? String(simData.startDate) : undefined;
|
const size = String(simData.size || "").toLowerCase();
|
||||||
const startDate =
|
const isEsim = size === "esim" || !!simData.eid;
|
||||||
startDateRaw && /^\d{8}$/.test(startDateRaw)
|
const planCode = String(simData.planCode || "");
|
||||||
? `${startDateRaw.slice(0, 4)}-${startDateRaw.slice(4, 6)}-${startDateRaw.slice(6, 8)}`
|
const status = this.mapSimStatus(String(simData.state || ""));
|
||||||
: startDateRaw;
|
|
||||||
const startDate =
|
|
||||||
startDateRaw && /^\d{8}$/.test(startDateRaw)
|
|
||||||
? `${startDateRaw.slice(0, 4)}-${startDateRaw.slice(4, 6)}-${startDateRaw.slice(6, 8)}`
|
|
||||||
: startDateRaw;
|
|
||||||
|
|
||||||
const simDetails: SimDetails = {
|
const remainingKb = Number(simData.quota) || 0;
|
||||||
account: String(simData.account ?? account),
|
const details: SimDetails = {
|
||||||
msisdn: String(simData.account ?? account),
|
account: String(simData.account || account),
|
||||||
|
msisdn: String(simData.account || account),
|
||||||
iccid: simData.iccid ? String(simData.iccid) : undefined,
|
iccid: simData.iccid ? String(simData.iccid) : undefined,
|
||||||
imsi: simData.imsi ? String(simData.imsi) : undefined,
|
imsi: simData.imsi ? String(simData.imsi) : undefined,
|
||||||
eid: simData.eid,
|
eid: simData.eid ? String(simData.eid) : undefined,
|
||||||
planCode: simData.planCode,
|
planCode,
|
||||||
status: this.mapSimStatus(String(simData.state || "pending")),
|
status,
|
||||||
simType: simData.eid ? "esim" : "physical",
|
simType: isEsim ? "esim" : "physical",
|
||||||
size: simData.size,
|
size: (size as any) || (isEsim ? "esim" : "nano"),
|
||||||
hasVoice: simData.talk === 10,
|
hasVoice: Number(simData.talk) === 10,
|
||||||
hasSms: simData.sms === 10,
|
hasSms: Number(simData.sms) === 10,
|
||||||
remainingQuotaKb: typeof simData.quota === "number" ? simData.quota : 0,
|
remainingQuotaKb: remainingKb,
|
||||||
remainingQuotaMb:
|
remainingQuotaMb: Math.round((remainingKb / 1024) * 100) / 100,
|
||||||
typeof simData.quota === "number" ? Math.round((simData.quota / 1024) * 100) / 100 : 0,
|
startDate: simData.startDate ? String(simData.startDate) : undefined,
|
||||||
startDate,
|
|
||||||
ipv4: simData.ipv4,
|
ipv4: simData.ipv4,
|
||||||
ipv6: simData.ipv6,
|
ipv6: simData.ipv6,
|
||||||
voiceMailEnabled: simData.voicemail === 10 || simData.voiceMail === 10,
|
voiceMailEnabled: Number(simData.voicemail ?? simData.voiceMail) === 10,
|
||||||
callWaitingEnabled: simData.callwaiting === 10 || simData.callWaiting === 10,
|
callWaitingEnabled: Number(simData.callwaiting ?? simData.callWaiting) === 10,
|
||||||
internationalRoamingEnabled: simData.worldwing === 10 || simData.worldWing === 10,
|
internationalRoamingEnabled: Number(simData.worldwing ?? simData.worldWing) === 10,
|
||||||
networkType: simData.contractLine || undefined,
|
networkType: simData.contractLine || undefined,
|
||||||
pendingOperations: simData.async
|
pendingOperations: simData.async
|
||||||
? [
|
? [{ operation: String(simData.async.func), scheduledDate: String(simData.async.date) }]
|
||||||
{
|
|
||||||
operation: simData.async.func,
|
|
||||||
scheduledDate: String(simData.async.date),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: undefined,
|
|
||||||
pendingOperations: simData.async
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
operation: simData.async.func,
|
|
||||||
scheduledDate: String(simData.async.date),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`Retrieved SIM details for account ${account}`, {
|
this.logger.log(`Retrieved SIM details for account ${account}`, {
|
||||||
account,
|
account,
|
||||||
status: simDetails.status,
|
status: details.status,
|
||||||
planCode: simDetails.planCode,
|
planCode: details.planCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
return simDetails;
|
return details;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to get SIM details for account ${account}`, {
|
this.logger.error(`Failed to get SIM details for account ${account}`, { error: error.message });
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get SIM data usage information
|
|
||||||
*/
|
|
||||||
async getSimUsage(account: string): Promise<SimUsage> {
|
async getSimUsage(account: string): Promise<SimUsage> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account };
|
const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account };
|
||||||
|
|
||||||
const request: Omit<FreebititTrafficInfoRequest, "authKey"> = { account };
|
|
||||||
|
|
||||||
const response = await this.makeAuthenticatedRequest<FreebititTrafficInfoResponse>(
|
const response = await this.makeAuthenticatedRequest<FreebititTrafficInfoResponse>(
|
||||||
"/mvno/getTrafficInfo/",
|
"/mvno/getTrafficInfo/",
|
||||||
"/mvno/getTrafficInfo/",
|
request as any
|
||||||
request
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
|
const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
|
||||||
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
|
const recentDaysData = response.traffic.inRecentDays
|
||||||
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
.split(",")
|
||||||
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
|
.map((usage, index) => ({
|
||||||
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000)
|
||||||
usageKb: parseInt(usage, 10) || 0,
|
.toISOString()
|
||||||
usageMb: Math.round((parseInt(usage, 10) / 1024) * 100) / 100,
|
.split("T")[0],
|
||||||
}));
|
usageKb: parseInt(usage, 10) || 0,
|
||||||
|
usageMb: Math.round(((parseInt(usage, 10) || 0) / 1024) * 100) / 100,
|
||||||
|
}));
|
||||||
|
|
||||||
const simUsage: SimUsage = {
|
const simUsage: SimUsage = {
|
||||||
account,
|
account,
|
||||||
@ -438,7 +317,6 @@ export class FreebititService {
|
|||||||
todayUsageMb: Math.round((todayUsageKb / 1024) * 100) / 100,
|
todayUsageMb: Math.round((todayUsageKb / 1024) * 100) / 100,
|
||||||
recentDaysUsage: recentDaysData,
|
recentDaysUsage: recentDaysData,
|
||||||
isBlacklisted: response.traffic.blackList === "10",
|
isBlacklisted: response.traffic.blackList === "10",
|
||||||
isBlacklisted: response.traffic.blackList === "10",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(`Retrieved SIM usage for account ${account}`, {
|
this.logger.log(`Retrieved SIM usage for account ${account}`, {
|
||||||
@ -455,30 +333,13 @@ export class FreebititService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Top up SIM data quota
|
|
||||||
*/
|
|
||||||
async topUpSim(
|
async topUpSim(
|
||||||
account: string,
|
account: string,
|
||||||
quotaMb: number,
|
quotaMb: number,
|
||||||
options: {
|
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
|
||||||
campaignCode?: string;
|
|
||||||
expiryDate?: string;
|
|
||||||
scheduledAt?: string;
|
|
||||||
} = {}
|
|
||||||
): Promise<void> {
|
|
||||||
async topUpSim(
|
|
||||||
account: string,
|
|
||||||
quotaMb: number,
|
|
||||||
options: {
|
|
||||||
campaignCode?: string;
|
|
||||||
expiryDate?: string;
|
|
||||||
scheduledAt?: string;
|
|
||||||
} = {}
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const quotaKb = quotaMb * 1024;
|
const quotaKb = Math.round(quotaMb * 1024);
|
||||||
|
|
||||||
const request: Omit<FreebititTopUpRequest, "authKey"> = {
|
const request: Omit<FreebititTopUpRequest, "authKey"> = {
|
||||||
account,
|
account,
|
||||||
quota: quotaKb,
|
quota: quotaKb,
|
||||||
@ -486,23 +347,19 @@ export class FreebititService {
|
|||||||
expire: options.expiryDate,
|
expire: options.expiryDate,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use PA05-22 for scheduled top-ups, PA04-04 for immediate
|
const scheduled = !!options.scheduledAt;
|
||||||
const endpoint = options.scheduledAt ? "/mvno/eachQuota/" : "/master/addSpec/";
|
const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
|
||||||
|
if (scheduled) {
|
||||||
if (options.scheduledAt && endpoint === "/mvno/eachQuota/") {
|
|
||||||
(request as any).runTime = options.scheduledAt;
|
(request as any).runTime = options.scheduledAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.makeAuthenticatedRequest<FreebititTopUpResponse>(endpoint, request);
|
await this.makeAuthenticatedRequest<FreebititTopUpResponse>(endpoint, request as any);
|
||||||
|
|
||||||
this.logger.log(`Successfully topped up SIM ${account}`, {
|
this.logger.log(`Successfully topped up SIM ${account}`, {
|
||||||
account,
|
account,
|
||||||
endpoint,
|
endpoint,
|
||||||
quotaMb,
|
quotaMb,
|
||||||
quotaKb,
|
quotaKb,
|
||||||
units: isScheduled ? "KB (PA05-22)" : "MB (PA04-04)",
|
scheduled,
|
||||||
campaignCode: options.campaignCode,
|
|
||||||
scheduled: isScheduled,
|
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to top up SIM ${account}`, {
|
this.logger.error(`Failed to top up SIM ${account}`, {
|
||||||
@ -514,38 +371,19 @@ export class FreebititService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getSimTopUpHistory(account: string, fromDate: string, toDate: string): Promise<SimTopUpHistory> {
|
||||||
* Get SIM top-up history
|
|
||||||
*/
|
|
||||||
async getSimTopUpHistory(
|
|
||||||
account: string,
|
|
||||||
fromDate: string,
|
|
||||||
toDate: string
|
|
||||||
): Promise<SimTopUpHistory> {
|
|
||||||
async getSimTopUpHistory(
|
|
||||||
account: string,
|
|
||||||
fromDate: string,
|
|
||||||
toDate: string
|
|
||||||
): Promise<SimTopUpHistory> {
|
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititQuotaHistoryRequest, "authKey"> = {
|
const request: Omit<FreebititQuotaHistoryRequest, "authKey"> = { account, fromDate, toDate };
|
||||||
const request: Omit<FreebititQuotaHistoryRequest, "authKey"> = {
|
|
||||||
account,
|
|
||||||
fromDate,
|
|
||||||
toDate,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await this.makeAuthenticatedRequest<FreebititQuotaHistoryResponse>(
|
const response = await this.makeAuthenticatedRequest<FreebititQuotaHistoryResponse>(
|
||||||
"/mvno/getQuotaHistory/",
|
"/mvno/getQuotaHistory/",
|
||||||
"/mvno/getQuotaHistory/",
|
request as any
|
||||||
request
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const history: SimTopUpHistory = {
|
const history: SimTopUpHistory = {
|
||||||
account,
|
account,
|
||||||
totalAdditions: response.total,
|
totalAdditions: Number(response.total) || 0,
|
||||||
additionCount: response.count,
|
additionCount: Number(response.count) || 0,
|
||||||
history: response.quotaHistory.map(item => ({
|
history: response.quotaHistory.map((item) => ({
|
||||||
quotaKb: parseInt(item.quota, 10),
|
quotaKb: parseInt(item.quota, 10),
|
||||||
quotaMb: Math.round((parseInt(item.quota, 10) / 1024) * 100) / 100,
|
quotaMb: Math.round((parseInt(item.quota, 10) / 1024) * 100) / 100,
|
||||||
addedDate: item.date,
|
addedDate: item.date,
|
||||||
@ -569,27 +407,12 @@ export class FreebititService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Change SIM plan
|
|
||||||
*/
|
|
||||||
async changeSimPlan(
|
async changeSimPlan(
|
||||||
account: string,
|
account: string,
|
||||||
newPlanCode: string,
|
newPlanCode: string,
|
||||||
options: {
|
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
||||||
assignGlobalIp?: boolean;
|
|
||||||
scheduledAt?: string;
|
|
||||||
} = {}
|
|
||||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
|
||||||
async changeSimPlan(
|
|
||||||
account: string,
|
|
||||||
newPlanCode: string,
|
|
||||||
options: {
|
|
||||||
assignGlobalIp?: boolean;
|
|
||||||
scheduledAt?: string;
|
|
||||||
} = {}
|
|
||||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititPlanChangeRequest, "authKey"> = {
|
|
||||||
const request: Omit<FreebititPlanChangeRequest, "authKey"> = {
|
const request: Omit<FreebititPlanChangeRequest, "authKey"> = {
|
||||||
account,
|
account,
|
||||||
plancode: newPlanCode,
|
plancode: newPlanCode,
|
||||||
@ -599,8 +422,7 @@ export class FreebititService {
|
|||||||
|
|
||||||
const response = await this.makeAuthenticatedRequest<FreebititPlanChangeResponse>(
|
const response = await this.makeAuthenticatedRequest<FreebititPlanChangeResponse>(
|
||||||
"/mvno/changePlan/",
|
"/mvno/changePlan/",
|
||||||
"/mvno/changePlan/",
|
request as any
|
||||||
request
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Successfully changed SIM plan for account ${account}`, {
|
this.logger.log(`Successfully changed SIM plan for account ${account}`, {
|
||||||
@ -610,10 +432,7 @@ export class FreebititService {
|
|||||||
scheduled: !!options.scheduledAt,
|
scheduled: !!options.scheduledAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { ipv4: response.ipv4, ipv6: response.ipv6 };
|
||||||
ipv4: response.ipv4,
|
|
||||||
ipv6: response.ipv6,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to change SIM plan for account ${account}`, {
|
this.logger.error(`Failed to change SIM plan for account ${account}`, {
|
||||||
error: error.message,
|
error: error.message,
|
||||||
@ -624,53 +443,30 @@ export class FreebititService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update SIM optional features (voicemail, call waiting, international roaming, network type)
|
|
||||||
* Uses AddSpec endpoint for immediate changes
|
|
||||||
*/
|
|
||||||
async updateSimFeatures(
|
async updateSimFeatures(
|
||||||
account: string,
|
account: string,
|
||||||
features: {
|
features: { voiceMailEnabled?: boolean; callWaitingEnabled?: boolean; internationalRoamingEnabled?: boolean; networkType?: string }
|
||||||
voiceMailEnabled?: boolean;
|
|
||||||
callWaitingEnabled?: boolean;
|
|
||||||
internationalRoamingEnabled?: boolean;
|
|
||||||
networkType?: string; // '4G' | '5G'
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
|
||||||
async updateSimFeatures(
|
|
||||||
account: string,
|
|
||||||
features: {
|
|
||||||
voiceMailEnabled?: boolean;
|
|
||||||
callWaitingEnabled?: boolean;
|
|
||||||
internationalRoamingEnabled?: boolean;
|
|
||||||
networkType?: string; // '4G' | '5G'
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititAddSpecRequest, "authKey"> = {
|
const request: Omit<FreebititAddSpecRequest, "authKey"> = { account };
|
||||||
account,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof features.voiceMailEnabled === "boolean") {
|
if (typeof features.voiceMailEnabled === "boolean") {
|
||||||
request.voiceMail = features.voiceMailEnabled ? ("10" as const) : ("20" as const);
|
request.voiceMail = features.voiceMailEnabled ? "10" : "20";
|
||||||
request.voicemail = request.voiceMail; // include alternate casing for compatibility
|
request.voicemail = request.voiceMail;
|
||||||
}
|
}
|
||||||
if (typeof features.callWaitingEnabled === "boolean") {
|
if (typeof features.callWaitingEnabled === "boolean") {
|
||||||
request.callWaiting = features.callWaitingEnabled ? ("10" as const) : ("20" as const);
|
request.callWaiting = features.callWaitingEnabled ? "10" : "20";
|
||||||
request.callwaiting = request.callWaiting;
|
request.callwaiting = request.callWaiting;
|
||||||
}
|
}
|
||||||
if (typeof features.internationalRoamingEnabled === "boolean") {
|
if (typeof features.internationalRoamingEnabled === "boolean") {
|
||||||
request.worldWing = features.internationalRoamingEnabled
|
request.worldWing = features.internationalRoamingEnabled ? "10" : "20";
|
||||||
? ("10" as const)
|
|
||||||
: ("20" as const);
|
|
||||||
request.worldwing = request.worldWing;
|
request.worldwing = request.worldWing;
|
||||||
}
|
}
|
||||||
if (features.networkType) {
|
if (features.networkType) {
|
||||||
request.contractLine = features.networkType;
|
request.contractLine = features.networkType;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.makeAuthenticatedRequest<FreebititAddSpecResponse>("/master/addSpec/", request);
|
await this.makeAuthenticatedRequest<FreebititAddSpecResponse>("/master/addSpec/", request as any);
|
||||||
|
|
||||||
this.logger.log(`Updated SIM features for account ${account}`, {
|
this.logger.log(`Updated SIM features for account ${account}`, {
|
||||||
account,
|
account,
|
||||||
voiceMailEnabled: features.voiceMailEnabled,
|
voiceMailEnabled: features.voiceMailEnabled,
|
||||||
@ -680,104 +476,53 @@ export class FreebititService {
|
|||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
this.logger.error(`Failed to update SIM features for account ${account}`, {
|
this.logger.error(`Failed to update SIM features for account ${account}`, { error: message, account });
|
||||||
error: message,
|
|
||||||
account,
|
|
||||||
});
|
|
||||||
throw error as Error;
|
throw error as Error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel SIM service via PA02-04 (master/cnclAcnt)
|
|
||||||
*/
|
|
||||||
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititCancelPlanRequest, "authKey"> = {
|
const request: Omit<FreebititCancelPlanRequest, "authKey"> = { account, runTime: scheduledAt };
|
||||||
account,
|
await this.makeAuthenticatedRequest<FreebititCancelPlanResponse>("/mvno/releasePlan/", request as any);
|
||||||
runDate: scheduledAt,
|
this.logger.log(`Successfully cancelled SIM for account ${account}`, { account, runTime: scheduledAt });
|
||||||
};
|
|
||||||
|
|
||||||
await this.makeAuthenticatedRequest<FreebititCancelPlanResponse>(
|
|
||||||
"/mvno/releasePlan/",
|
|
||||||
request
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
|
|
||||||
account,
|
|
||||||
runDate: scheduledAt,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to cancel SIM for account ${account}`, {
|
this.logger.error(`Failed to cancel SIM for account ${account}`, { error: error.message, account });
|
||||||
error: error.message,
|
|
||||||
account,
|
|
||||||
});
|
|
||||||
throw error as Error;
|
throw error as Error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reissue eSIM profile using reissueProfile endpoint
|
|
||||||
*/
|
|
||||||
async reissueEsimProfile(account: string): Promise<void> {
|
async reissueEsimProfile(account: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititEsimReissueRequest, "authKey"> = { account };
|
const request: Omit<FreebititEsimReissueRequest, "authKey"> = { account };
|
||||||
|
await this.makeAuthenticatedRequest<FreebititEsimReissueResponse>("/esim/reissueProfile/", request as any);
|
||||||
await this.makeAuthenticatedRequest<FreebititEsimReissueResponse>(
|
this.logger.log(`Successfully requested eSIM reissue for account ${account}`);
|
||||||
"/esim/reissueProfile/",
|
|
||||||
request
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(`Successfully reissued eSIM profile for account ${account}`, { account });
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to reissue eSIM profile for account ${account}`, {
|
this.logger.error(`Failed to reissue eSIM profile for account ${account}`, { error: error.message, account });
|
||||||
error: error.message,
|
|
||||||
account,
|
|
||||||
});
|
|
||||||
throw error as Error;
|
throw error as Error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reissue eSIM profile using addAcnt endpoint (enhanced method based on Salesforce implementation)
|
|
||||||
*/
|
|
||||||
async reissueEsimProfileEnhanced(
|
async reissueEsimProfileEnhanced(
|
||||||
account: string,
|
|
||||||
account: string,
|
account: string,
|
||||||
newEid: string,
|
newEid: string,
|
||||||
options: {
|
options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {}
|
||||||
oldProductNumber?: string;
|
|
||||||
oldEid?: string;
|
|
||||||
planCode?: string;
|
|
||||||
} = {}
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const request: Omit<FreebititEsimAddAccountRequest, "authKey"> = {
|
|
||||||
aladinOperated: "20",
|
|
||||||
const request: Omit<FreebititEsimAddAccountRequest, "authKey"> = {
|
const request: Omit<FreebititEsimAddAccountRequest, "authKey"> = {
|
||||||
aladinOperated: "20",
|
aladinOperated: "20",
|
||||||
account,
|
account,
|
||||||
eid: newEid,
|
eid: newEid,
|
||||||
addKind: "R", // R = reissue
|
addKind: "R",
|
||||||
addKind: "R", // R = reissue
|
reissue: { oldProductNumber: options.oldProductNumber, oldEid: options.oldEid },
|
||||||
reissue: {
|
planCode: options.planCode,
|
||||||
oldProductNumber: options.oldProductNumber,
|
|
||||||
oldEid: options.oldEid,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add optional fields
|
|
||||||
if (options.planCode) {
|
|
||||||
request.planCode = options.planCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse>(
|
await this.makeAuthenticatedRequest<FreebititEsimAddAccountResponse>(
|
||||||
"/mvno/esim/addAcnt/",
|
"/mvno/esim/addAcnt/",
|
||||||
"/mvno/esim/addAcnt/",
|
request as any
|
||||||
request
|
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
|
|
||||||
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
|
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
|
||||||
account,
|
account,
|
||||||
newEid,
|
newEid,
|
||||||
@ -794,17 +539,13 @@ export class FreebititService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Activate a new eSIM account via PA05-41 addAcct (JSON API)
|
|
||||||
* This supports optional scheduling (shipDate) and MNP payload.
|
|
||||||
*/
|
|
||||||
async activateEsimAccountNew(params: {
|
async activateEsimAccountNew(params: {
|
||||||
account: string; // MSISDN to be activated (required by Freebit)
|
account: string;
|
||||||
eid: string; // 32-digit EID
|
eid: string;
|
||||||
planCode?: string;
|
planCode?: string;
|
||||||
contractLine?: "4G" | "5G";
|
contractLine?: "4G" | "5G";
|
||||||
aladinOperated?: "10" | "20";
|
aladinOperated?: "10" | "20";
|
||||||
shipDate?: string; // YYYYMMDD; if provided we send as scheduled activation date
|
shipDate?: string;
|
||||||
mnp?: { reserveNumber: string; reserveExpireDate: string };
|
mnp?: { reserveNumber: string; reserveExpireDate: string };
|
||||||
identity?: {
|
identity?: {
|
||||||
firstnameKanji?: string;
|
firstnameKanji?: string;
|
||||||
@ -815,16 +556,7 @@ export class FreebititService {
|
|||||||
birthday?: string;
|
birthday?: string;
|
||||||
};
|
};
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const {
|
const { account, eid, planCode, contractLine, aladinOperated = "10", shipDate, mnp, identity } = params;
|
||||||
account,
|
|
||||||
eid,
|
|
||||||
planCode,
|
|
||||||
contractLine,
|
|
||||||
aladinOperated = "10",
|
|
||||||
shipDate,
|
|
||||||
mnp,
|
|
||||||
identity,
|
|
||||||
} = params;
|
|
||||||
|
|
||||||
if (!account || !eid) {
|
if (!account || !eid) {
|
||||||
throw new BadRequestException("activateEsimAccountNew requires account and eid");
|
throw new BadRequestException("activateEsimAccountNew requires account and eid");
|
||||||
@ -858,9 +590,6 @@ export class FreebititService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Health check for Freebit API
|
|
||||||
*/
|
|
||||||
async healthCheck(): Promise<boolean> {
|
async healthCheck(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await this.getAuthKey();
|
await this.getAuthKey();
|
||||||
@ -872,19 +601,17 @@ export class FreebititService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom error class for Freebit API errors
|
|
||||||
class FreebititErrorImpl extends Error {
|
class FreebititErrorImpl extends Error {
|
||||||
public readonly resultCode: string;
|
public readonly resultCode: string;
|
||||||
public readonly statusCode: string;
|
public readonly statusCode: string | number;
|
||||||
public readonly freebititMessage: string;
|
public readonly freebititMessage: string;
|
||||||
|
|
||||||
constructor(message: string, resultCode: string, statusCode: string, freebititMessage: string) {
|
constructor(message: string, resultCode: string | number, statusCode: string | number, freebititMessage: string) {
|
||||||
constructor(message: string, resultCode: string, statusCode: string, freebititMessage: string) {
|
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "FreebititError";
|
this.name = "FreebititError";
|
||||||
this.name = "FreebititError";
|
this.resultCode = String(resultCode);
|
||||||
this.resultCode = resultCode;
|
|
||||||
this.statusCode = statusCode;
|
this.statusCode = statusCode;
|
||||||
this.freebititMessage = freebititMessage;
|
this.freebititMessage = String(freebititMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,20 +1,17 @@
|
|||||||
// Freebit API Type Definitions
|
// Freebit API Type Definitions (cleaned)
|
||||||
|
|
||||||
export interface FreebititAuthRequest {
|
export interface FreebititAuthRequest {
|
||||||
oemId: string; // 4-char alphanumeric ISP identifier
|
oemId: string; // 4-char alphanumeric ISP identifier
|
||||||
oemKey: string; // 32-char auth key
|
oemKey: string; // 32-char auth key
|
||||||
oemId: string; // 4-char alphanumeric ISP identifier
|
|
||||||
oemKey: string; // 32-char auth key
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititAuthResponse {
|
export interface FreebititAuthResponse {
|
||||||
resultCode: string;
|
resultCode: string;
|
||||||
status: {
|
status: {
|
||||||
message: string;
|
message: string;
|
||||||
statusCode: string;
|
statusCode: string | number;
|
||||||
};
|
};
|
||||||
authKey: string; // Token for subsequent API calls
|
authKey: string; // Token for subsequent API calls
|
||||||
authKey: string; // Token for subsequent API calls
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititAccountDetailsRequest {
|
export interface FreebititAccountDetailsRequest {
|
||||||
@ -33,7 +30,6 @@ export interface FreebititAccountDetailsResponse {
|
|||||||
statusCode: string | number;
|
statusCode: string | number;
|
||||||
};
|
};
|
||||||
masterAccount?: string;
|
masterAccount?: string;
|
||||||
// Docs show this can be an array (MASTER + MVNO) or a single object for MVNO
|
|
||||||
responseDatas:
|
responseDatas:
|
||||||
| {
|
| {
|
||||||
kind: "MASTER" | "MVNO" | string;
|
kind: "MASTER" | "MVNO" | string;
|
||||||
@ -52,11 +48,8 @@ export interface FreebititAccountDetailsResponse {
|
|||||||
talk?: number; // 10=active, 20=inactive
|
talk?: number; // 10=active, 20=inactive
|
||||||
ipv4?: string;
|
ipv4?: string;
|
||||||
ipv6?: string;
|
ipv6?: string;
|
||||||
quota?: number; // Remaining quota (units vary by env)
|
quota?: number; // Remaining quota
|
||||||
async?: {
|
async?: { func: string; date: string | number };
|
||||||
func: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string;
|
|
||||||
date: string | number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
| Array<{
|
| Array<{
|
||||||
kind: "MASTER" | "MVNO" | string;
|
kind: "MASTER" | "MVNO" | string;
|
||||||
@ -76,11 +69,7 @@ export interface FreebititAccountDetailsResponse {
|
|||||||
ipv4?: string;
|
ipv4?: string;
|
||||||
ipv6?: string;
|
ipv6?: string;
|
||||||
quota?: number;
|
quota?: number;
|
||||||
async?: {
|
async?: { func: string; date: string | number };
|
||||||
func: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string;
|
|
||||||
date: string | number;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,15 +82,13 @@ export interface FreebititTrafficInfoResponse {
|
|||||||
resultCode: string;
|
resultCode: string;
|
||||||
status: {
|
status: {
|
||||||
message: string;
|
message: string;
|
||||||
statusCode: string;
|
statusCode: string | number;
|
||||||
};
|
};
|
||||||
account: string;
|
account: string;
|
||||||
traffic: {
|
traffic: {
|
||||||
today: string; // Today's usage in KB
|
|
||||||
today: string; // Today's usage in KB
|
today: string; // Today's usage in KB
|
||||||
inRecentDays: string; // Comma-separated recent days usage
|
inRecentDays: string; // Comma-separated recent days usage
|
||||||
blackList: string; // 10=blacklisted, 20=not blacklisted
|
blackList: string; // 10=blacklisted, 20=not blacklisted
|
||||||
blackList: string; // 10=blacklisted, 20=not blacklisted
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,17 +102,14 @@ export interface FreebititTopUpRequest {
|
|||||||
|
|
||||||
export interface FreebititTopUpResponse {
|
export interface FreebititTopUpResponse {
|
||||||
resultCode: string;
|
resultCode: string;
|
||||||
status: {
|
status: { message: string; statusCode: string | number };
|
||||||
message: string;
|
|
||||||
statusCode: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddSpec request for updating SIM options/features immediately
|
// AddSpec request for updating SIM options/features immediately
|
||||||
export interface FreebititAddSpecRequest {
|
export interface FreebititAddSpecRequest {
|
||||||
authKey: string;
|
authKey: string;
|
||||||
account: string;
|
account: string;
|
||||||
kind?: string; // Required by PA04-04 (/master/addSpec/), e.g. 'MVNO'
|
kind?: string; // e.g. 'MVNO'
|
||||||
// Feature flags: 10 = enabled, 20 = disabled
|
// Feature flags: 10 = enabled, 20 = disabled
|
||||||
voiceMail?: "10" | "20";
|
voiceMail?: "10" | "20";
|
||||||
voicemail?: "10" | "20";
|
voicemail?: "10" | "20";
|
||||||
@ -133,21 +117,12 @@ export interface FreebititAddSpecRequest {
|
|||||||
callwaiting?: "10" | "20";
|
callwaiting?: "10" | "20";
|
||||||
worldWing?: "10" | "20";
|
worldWing?: "10" | "20";
|
||||||
worldwing?: "10" | "20";
|
worldwing?: "10" | "20";
|
||||||
voiceMail?: "10" | "20";
|
|
||||||
voicemail?: "10" | "20";
|
|
||||||
callWaiting?: "10" | "20";
|
|
||||||
callwaiting?: "10" | "20";
|
|
||||||
worldWing?: "10" | "20";
|
|
||||||
worldwing?: "10" | "20";
|
|
||||||
contractLine?: string; // '4G' or '5G'
|
contractLine?: string; // '4G' or '5G'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititAddSpecResponse {
|
export interface FreebititAddSpecResponse {
|
||||||
resultCode: string;
|
resultCode: string;
|
||||||
status: {
|
status: { message: string; statusCode: string | number };
|
||||||
message: string;
|
|
||||||
statusCode: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititQuotaHistoryRequest {
|
export interface FreebititQuotaHistoryRequest {
|
||||||
@ -155,25 +130,15 @@ export interface FreebititQuotaHistoryRequest {
|
|||||||
account: string;
|
account: string;
|
||||||
fromDate: string; // YYYYMMDD
|
fromDate: string; // YYYYMMDD
|
||||||
toDate: string; // YYYYMMDD
|
toDate: string; // YYYYMMDD
|
||||||
fromDate: string; // YYYYMMDD
|
|
||||||
toDate: string; // YYYYMMDD
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititQuotaHistoryResponse {
|
export interface FreebititQuotaHistoryResponse {
|
||||||
resultCode: string;
|
resultCode: string;
|
||||||
status: {
|
status: { message: string; statusCode: string | number };
|
||||||
message: string;
|
|
||||||
statusCode: string;
|
|
||||||
};
|
|
||||||
account: string;
|
account: string;
|
||||||
total: number;
|
total: number;
|
||||||
count: number;
|
count: number;
|
||||||
quotaHistory: Array<{
|
quotaHistory: Array<{ quota: string; expire: string; date: string; quotaCode: string }>;
|
||||||
quota: string;
|
|
||||||
expire: string;
|
|
||||||
date: string;
|
|
||||||
quotaCode: string;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititPlanChangeRequest {
|
export interface FreebititPlanChangeRequest {
|
||||||
@ -181,46 +146,16 @@ export interface FreebititPlanChangeRequest {
|
|||||||
account: string;
|
account: string;
|
||||||
plancode: string;
|
plancode: string;
|
||||||
globalip?: "0" | "1"; // 0=no IP, 1=assign global IP
|
globalip?: "0" | "1"; // 0=no IP, 1=assign global IP
|
||||||
runTime?: string; // YYYYMMDD - optional, immediate if omitted
|
runTime?: string; // YYYYMMDD - optional
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititPlanChangeResponse {
|
export interface FreebititPlanChangeResponse {
|
||||||
resultCode: string;
|
resultCode: string;
|
||||||
status: {
|
status: { message: string; statusCode: string | number };
|
||||||
message: string;
|
|
||||||
statusCode: string;
|
|
||||||
};
|
|
||||||
ipv4?: string;
|
ipv4?: string;
|
||||||
ipv6?: string;
|
ipv6?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PA05-06: MVNO Voice Option Change
|
|
||||||
export interface FreebititVoiceOptionChangeRequest {
|
|
||||||
authKey: string;
|
|
||||||
account: string;
|
|
||||||
userConfirmed: "10" | "20";
|
|
||||||
aladinOperated: "10" | "20";
|
|
||||||
talkOption: {
|
|
||||||
voiceMail?: "10" | "20";
|
|
||||||
callWaiting?: "10" | "20";
|
|
||||||
worldWing?: "10" | "20";
|
|
||||||
worldCall?: "10" | "20";
|
|
||||||
callTransfer?: "10" | "20";
|
|
||||||
callTransferNoId?: "10" | "20";
|
|
||||||
worldCallCreditLimit?: string;
|
|
||||||
worldWingCreditLimit?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FreebititVoiceOptionChangeResponse {
|
|
||||||
resultCode: string;
|
|
||||||
status: {
|
|
||||||
message: string;
|
|
||||||
statusCode: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// PA05-38: MVNO Contract Change (4G/5G)
|
|
||||||
export interface FreebititContractLineChangeRequest {
|
export interface FreebititContractLineChangeRequest {
|
||||||
authKey: string;
|
authKey: string;
|
||||||
account: string;
|
account: string;
|
||||||
@ -239,16 +174,12 @@ export interface FreebititContractLineChangeResponse {
|
|||||||
export interface FreebititCancelPlanRequest {
|
export interface FreebititCancelPlanRequest {
|
||||||
authKey: string;
|
authKey: string;
|
||||||
account: string;
|
account: string;
|
||||||
runTime?: string; // YYYYMMDD - optional, immediate if omitted
|
runTime?: string; // YYYYMMDD - optional
|
||||||
runTime?: string; // YYYYMMDD - optional, immediate if omitted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititCancelPlanResponse {
|
export interface FreebititCancelPlanResponse {
|
||||||
resultCode: string;
|
resultCode: string;
|
||||||
status: {
|
status: { message: string; statusCode: string | number };
|
||||||
message: string;
|
|
||||||
statusCode: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PA02-04: Account Cancellation (master/cnclAcnt)
|
// PA02-04: Account Cancellation (master/cnclAcnt)
|
||||||
@ -261,10 +192,7 @@ export interface FreebititCancelAccountRequest {
|
|||||||
|
|
||||||
export interface FreebititCancelAccountResponse {
|
export interface FreebititCancelAccountResponse {
|
||||||
resultCode: string;
|
resultCode: string;
|
||||||
status: {
|
status: { message: string; statusCode: string | number };
|
||||||
message: string;
|
|
||||||
statusCode: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititEsimReissueRequest {
|
export interface FreebititEsimReissueRequest {
|
||||||
@ -274,10 +202,7 @@ export interface FreebititEsimReissueRequest {
|
|||||||
|
|
||||||
export interface FreebititEsimReissueResponse {
|
export interface FreebititEsimReissueResponse {
|
||||||
resultCode: string;
|
resultCode: string;
|
||||||
status: {
|
status: { message: string; statusCode: string | number };
|
||||||
message: string;
|
|
||||||
statusCode: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititEsimAddAccountRequest {
|
export interface FreebititEsimAddAccountRequest {
|
||||||
@ -286,23 +211,16 @@ export interface FreebititEsimAddAccountRequest {
|
|||||||
account: string;
|
account: string;
|
||||||
eid: string;
|
eid: string;
|
||||||
addKind: "N" | "R"; // N = new, R = reissue
|
addKind: "N" | "R"; // N = new, R = reissue
|
||||||
addKind: "N" | "R"; // N = new, R = reissue
|
|
||||||
createType?: string;
|
createType?: string;
|
||||||
simKind?: string;
|
simKind?: string;
|
||||||
planCode?: string;
|
planCode?: string;
|
||||||
contractLine?: string;
|
contractLine?: string;
|
||||||
reissue?: {
|
reissue?: { oldProductNumber?: string; oldEid?: string };
|
||||||
oldProductNumber?: string;
|
|
||||||
oldEid?: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreebititEsimAddAccountResponse {
|
export interface FreebititEsimAddAccountResponse {
|
||||||
resultCode: string;
|
resultCode: string;
|
||||||
status: {
|
status: { message: string; statusCode: string | number };
|
||||||
message: string;
|
|
||||||
statusCode: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PA05-41 eSIM Account Activation (addAcct)
|
// PA05-41 eSIM Account Activation (addAcct)
|
||||||
@ -320,10 +238,7 @@ export interface FreebititEsimAccountActivationRequest {
|
|||||||
addKind?: string; // e.g., 'R' for reissue
|
addKind?: string; // e.g., 'R' for reissue
|
||||||
oldEid?: string;
|
oldEid?: string;
|
||||||
oldProductNumber?: string;
|
oldProductNumber?: string;
|
||||||
mnp?: {
|
mnp?: { reserveNumber: string; reserveExpireDate: string };
|
||||||
reserveNumber: string;
|
|
||||||
reserveExpireDate: string; // YYYYMMDD
|
|
||||||
};
|
|
||||||
firstnameKanji?: string;
|
firstnameKanji?: string;
|
||||||
lastnameKanji?: string;
|
lastnameKanji?: string;
|
||||||
firstnameZenKana?: string;
|
firstnameZenKana?: string;
|
||||||
@ -340,7 +255,7 @@ export interface FreebititEsimAccountActivationRequest {
|
|||||||
export interface FreebititEsimAccountActivationResponse {
|
export interface FreebititEsimAccountActivationResponse {
|
||||||
resultCode: number | string;
|
resultCode: number | string;
|
||||||
status?: unknown;
|
status?: unknown;
|
||||||
statusCode?: string;
|
statusCode?: string | number;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -355,9 +270,6 @@ export interface SimDetails {
|
|||||||
status: "active" | "suspended" | "cancelled" | "pending";
|
status: "active" | "suspended" | "cancelled" | "pending";
|
||||||
simType: "physical" | "esim";
|
simType: "physical" | "esim";
|
||||||
size: "standard" | "nano" | "micro" | "esim";
|
size: "standard" | "nano" | "micro" | "esim";
|
||||||
status: "active" | "suspended" | "cancelled" | "pending";
|
|
||||||
simType: "physical" | "esim";
|
|
||||||
size: "standard" | "nano" | "micro" | "esim";
|
|
||||||
hasVoice: boolean;
|
hasVoice: boolean;
|
||||||
hasSms: boolean;
|
hasSms: boolean;
|
||||||
remainingQuotaKb: number;
|
remainingQuotaKb: number;
|
||||||
@ -365,26 +277,18 @@ export interface SimDetails {
|
|||||||
startDate?: string;
|
startDate?: string;
|
||||||
ipv4?: string;
|
ipv4?: string;
|
||||||
ipv6?: string;
|
ipv6?: string;
|
||||||
// Optional extended service features
|
|
||||||
voiceMailEnabled?: boolean;
|
voiceMailEnabled?: boolean;
|
||||||
callWaitingEnabled?: boolean;
|
callWaitingEnabled?: boolean;
|
||||||
internationalRoamingEnabled?: boolean;
|
internationalRoamingEnabled?: boolean;
|
||||||
networkType?: string; // e.g., '4G' or '5G'
|
networkType?: string; // e.g., '4G' or '5G'
|
||||||
pendingOperations?: Array<{
|
pendingOperations?: Array<{ operation: string; scheduledDate: string }>;
|
||||||
operation: string;
|
|
||||||
scheduledDate: string;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimUsage {
|
export interface SimUsage {
|
||||||
account: string;
|
account: string;
|
||||||
todayUsageKb: number;
|
todayUsageKb: number;
|
||||||
todayUsageMb: number;
|
todayUsageMb: number;
|
||||||
recentDaysUsage: Array<{
|
recentDaysUsage: Array<{ date: string; usageKb: number; usageMb: number }>;
|
||||||
date: string;
|
|
||||||
usageKb: number;
|
|
||||||
usageMb: number;
|
|
||||||
}>;
|
|
||||||
isBlacklisted: boolean;
|
isBlacklisted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -404,7 +308,7 @@ export interface SimTopUpHistory {
|
|||||||
// Error handling
|
// Error handling
|
||||||
export interface FreebititError extends Error {
|
export interface FreebititError extends Error {
|
||||||
resultCode: string;
|
resultCode: string;
|
||||||
statusCode: string;
|
statusCode: string | number;
|
||||||
freebititMessage: string;
|
freebititMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 { Module } from "@nestjs/common";
|
||||||
import { WhmcsModule } from "./whmcs/whmcs.module";
|
import { WhmcsModule } from "./whmcs/whmcs.module";
|
||||||
import { SalesforceModule } from "./salesforce/salesforce.module";
|
import { SalesforceModule } from "./salesforce/salesforce.module";
|
||||||
|
import { FreebititModule } from "./freebit/freebit.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WhmcsModule, SalesforceModule],
|
imports: [WhmcsModule, SalesforceModule, FreebititModule],
|
||||||
providers: [],
|
providers: [],
|
||||||
exports: [WhmcsModule, SalesforceModule],
|
exports: [WhmcsModule, SalesforceModule, FreebititModule],
|
||||||
})
|
})
|
||||||
export class VendorsModule {}
|
export class VendorsModule {}
|
||||||
|
|||||||
@ -1,14 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { authenticatedApi } from "@/lib/api";
|
import { authenticatedApi } from "@/lib/api";
|
||||||
import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard";
|
import type { SimDetails } from "@/features/sim-management/components/SimDetailsCard";
|
||||||
import { formatPlanShort } from "@/lib/plan";
|
import { formatPlanShort } from "@/lib/plan";
|
||||||
|
|
||||||
type Step = 1 | 2 | 3;
|
type Step = 1 | 2 | 3;
|
||||||
|
|
||||||
|
function Notice({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded p-3">
|
||||||
|
<div className="text-sm font-medium text-yellow-900 mb-1">{title}</div>
|
||||||
|
<div className="text-sm text-yellow-800">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500">{label}</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SimCancelPage() {
|
export default function SimCancelPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -162,7 +180,6 @@ export default function SimCancelPage() {
|
|||||||
requested from this online form. Please contact Assist Solutions Customer Support
|
requested from this online form. Please contact Assist Solutions Customer Support
|
||||||
(info@asolutions.co.jp) for more information.
|
(info@asolutions.co.jp) for more information.
|
||||||
</Notice>
|
</Notice>
|
||||||
4
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||||
<InfoRow label="SIM" value={details?.msisdn || "—"} />
|
<InfoRow label="SIM" value={details?.msisdn || "—"} />
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { authenticatedApi } from "@/lib/api";
|
import { authenticatedApi } from "@/lib/api";
|
||||||
|
|
||||||
@ -55,8 +54,7 @@ export default function SimChangePlanPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<div className="max-w-3xl mx-auto p-6">
|
||||||
<div className="max-w-3xl mx-auto p-6">
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Link
|
<Link
|
||||||
href={`/subscriptions/${subscriptionId}#sim-management`}
|
href={`/subscriptions/${subscriptionId}#sim-management`}
|
||||||
@ -143,6 +141,5 @@ export default function SimChangePlanPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 hasChildren = item.children && item.children.length > 0;
|
||||||
const isActive = hasChildren
|
const isActive = hasChildren
|
||||||
? item.children?.some((child: NavigationChild) =>
|
? (item.children?.some((child: NavigationChild) =>
|
||||||
pathname.startsWith((child.href || "").split(/[?#]/)[0])
|
pathname.startsWith((child.href || "").split(/[?#]/)[0])
|
||||||
) || false
|
) || false)
|
||||||
? item.children?.some((child: NavigationChild) =>
|
: (item.href ? pathname === item.href : false);
|
||||||
pathname.startsWith((child.href || "").split(/[?#]/)[0])
|
|
||||||
) || false
|
|
||||||
: item.href
|
|
||||||
? pathname === item.href
|
|
||||||
: false;
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
void logout().then(() => {
|
void logout().then(() => {
|
||||||
|
|||||||
@ -30,7 +30,7 @@ apps/portal/src/features/service-management/
|
|||||||
|
|
||||||
## Integration
|
## Integration
|
||||||
|
|
||||||
- Entry point: `apps/portal/src/app/subscriptions/[id]/page.tsx` renders `ServiceManagementSection`
|
- Entry point: `apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx` renders `ServiceManagementSection`
|
||||||
- Detection: SIM availability is inferred from `subscription.productName` including `sim` (case-insensitive)
|
- Detection: SIM availability is inferred from `subscription.productName` including `sim` (case-insensitive)
|
||||||
|
|
||||||
## Future Expansion
|
## Future Expansion
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user