Remove cached profile fields migration and update CSRF middleware for new public auth endpoints
- Deleted migration file that removed cached profile fields from the users table, centralizing profile data retrieval from WHMCS. - Updated CsrfMiddleware to include new public authentication endpoints for password reset, setting password, and WHMCS account linking. - Enhanced error handling in password and WHMCS linking workflows to provide clearer feedback on missing mappings and improve user experience. - Adjusted user creation and update methods in UsersFacade to handle cases where WHMCS mappings are not yet available, ensuring smoother account setup.
This commit is contained in:
parent
833ff24645
commit
675f7d5cfd
@ -1,12 +0,0 @@
|
|||||||
-- Remove cached profile fields from users table
|
|
||||||
-- Profile data will be fetched from WHMCS (single source of truth)
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "users" DROP COLUMN IF EXISTS "first_name";
|
|
||||||
ALTER TABLE "users" DROP COLUMN IF EXISTS "last_name";
|
|
||||||
ALTER TABLE "users" DROP COLUMN IF EXISTS "company";
|
|
||||||
ALTER TABLE "users" DROP COLUMN IF EXISTS "phone";
|
|
||||||
|
|
||||||
-- Add comment to document this architectural decision
|
|
||||||
COMMENT ON TABLE "users" IS 'Portal authentication only. Profile data fetched from WHMCS via IdMapping.';
|
|
||||||
|
|
||||||
@ -55,6 +55,9 @@ export class CsrfMiddleware implements NestMiddleware {
|
|||||||
"/api/auth/refresh",
|
"/api/auth/refresh",
|
||||||
"/api/auth/check-password-needed",
|
"/api/auth/check-password-needed",
|
||||||
"/api/auth/request-password-reset",
|
"/api/auth/request-password-reset",
|
||||||
|
"/api/auth/reset-password", // Public auth endpoint for password reset
|
||||||
|
"/api/auth/set-password", // Public auth endpoint for setting password after WHMCS link
|
||||||
|
"/api/auth/link-whmcs", // Public auth endpoint for WHMCS account linking
|
||||||
"/api/health",
|
"/api/health",
|
||||||
"/docs",
|
"/docs",
|
||||||
"/api/webhooks", // Webhooks typically don't use CSRF
|
"/api/webhooks", // Webhooks typically don't use CSRF
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import { BadRequestException, Inject, Injectable, UnauthorizedException } from "@nestjs/common";
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
@ -57,7 +63,23 @@ export class PasswordWorkflowService {
|
|||||||
const saltRounds =
|
const saltRounds =
|
||||||
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
||||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||||
await this.usersFacade.update(user.id, { passwordHash });
|
try {
|
||||||
|
await this.usersFacade.update(user.id, { passwordHash });
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
// Avoid surfacing downstream WHMCS mapping lookups as system errors during setup
|
||||||
|
if (
|
||||||
|
error instanceof NotFoundException &&
|
||||||
|
/whmcs client mapping not found/i.test(message)
|
||||||
|
) {
|
||||||
|
this.logger.warn("Password set succeeded but WHMCS mapping missing; returning auth user", {
|
||||||
|
userId: user.id,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
const prismaUser = await this.usersFacade.findByIdInternal(user.id);
|
const prismaUser = await this.usersFacade.findByIdInternal(user.id);
|
||||||
if (!prismaUser) {
|
if (!prismaUser) {
|
||||||
throw new Error("Failed to load user after password setup");
|
throw new Error("Failed to load user after password setup");
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { UsersFacade } from "@bff/modules/users/application/users.facade";
|
import { UsersFacade } from "@bff/modules/users/application/users.facade";
|
||||||
@ -141,11 +142,14 @@ export class WhmcsLinkWorkflowService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdUser = await this.usersFacade.create({
|
const createdUser = await this.usersFacade.create(
|
||||||
email,
|
{
|
||||||
passwordHash: null,
|
email,
|
||||||
emailVerified: true,
|
passwordHash: null,
|
||||||
});
|
emailVerified: true,
|
||||||
|
},
|
||||||
|
{ includeProfile: false }
|
||||||
|
);
|
||||||
|
|
||||||
await this.mappingsService.createMapping({
|
await this.mappingsService.createMapping({
|
||||||
userId: createdUser.id,
|
userId: createdUser.id,
|
||||||
@ -178,10 +182,25 @@ export class WhmcsLinkWorkflowService {
|
|||||||
needsPasswordSet: true,
|
needsPasswordSet: true,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("WHMCS linking error", { error: getErrorMessage(error) });
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error("WHMCS linking error", { error: message });
|
||||||
|
|
||||||
|
// Preserve known auth/validation errors so the client gets actionable feedback
|
||||||
if (error instanceof BadRequestException || error instanceof UnauthorizedException) {
|
if (error instanceof BadRequestException || error instanceof UnauthorizedException) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Treat missing WHMCS mappings/records as an auth-style failure rather than a system error
|
||||||
|
if (
|
||||||
|
error instanceof NotFoundException ||
|
||||||
|
/whmcs client mapping not found/i.test(message) ||
|
||||||
|
/whmcs.*not found/i.test(message)
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
"No billing account found with this email address. Please check your email or contact support."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
throw new BadRequestException("Failed to link WHMCS account");
|
throw new BadRequestException("Failed to link WHMCS account");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
import {
|
||||||
|
Injectable,
|
||||||
|
Inject,
|
||||||
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import type { User as PrismaUser } from "@prisma/client";
|
import type { User as PrismaUser } from "@prisma/client";
|
||||||
import type { User } from "@customer-portal/domain/customer";
|
import type { User } from "@customer-portal/domain/customer";
|
||||||
@ -8,6 +13,7 @@ import type { UpdateCustomerProfileRequest } from "@customer-portal/domain/auth"
|
|||||||
import { UserAuthRepository } from "../infra/user-auth.repository";
|
import { UserAuthRepository } from "../infra/user-auth.repository";
|
||||||
import { UserProfileService } from "../infra/user-profile.service";
|
import { UserProfileService } from "../infra/user-profile.service";
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
||||||
|
|
||||||
type AuthUpdateData = Partial<
|
type AuthUpdateData = Partial<
|
||||||
Pick<PrismaUser, "passwordHash" | "failedLoginAttempts" | "lastLoginAt" | "lockedUntil">
|
Pick<PrismaUser, "passwordHash" | "failedLoginAttempts" | "lastLoginAt" | "lockedUntil">
|
||||||
@ -70,10 +76,17 @@ export class UsersFacade {
|
|||||||
return this.profileService.getUserSummary(userId);
|
return this.profileService.getUserSummary(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(userData: Partial<PrismaUser>): Promise<User> {
|
async create(
|
||||||
|
userData: Partial<PrismaUser>,
|
||||||
|
options?: { includeProfile?: boolean }
|
||||||
|
): Promise<User | ReturnType<typeof mapPrismaUserToDomain>> {
|
||||||
try {
|
try {
|
||||||
const createdUser = await this.authRepository.create(userData);
|
const createdUser = await this.authRepository.create(userData);
|
||||||
return this.profileService.getProfile(createdUser.id);
|
const includeProfile = options?.includeProfile ?? true;
|
||||||
|
if (includeProfile) {
|
||||||
|
return this.profileService.getProfile(createdUser.id);
|
||||||
|
}
|
||||||
|
return mapPrismaUserToDomain(createdUser);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error("Failed to create user", {
|
this.logger.error("Failed to create user", {
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
@ -87,11 +100,24 @@ export class UsersFacade {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await this.authRepository.updateAuthState(id, sanitized);
|
await this.authRepository.updateAuthState(id, sanitized);
|
||||||
return this.profileService.getProfile(id);
|
return await this.profileService.getProfile(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
// If the profile lookup fails because the WHMCS mapping isn't created yet (e.g., during
|
||||||
|
// initial account setup), fall back to returning the auth user to avoid bubbling a system error.
|
||||||
|
if (error instanceof NotFoundException && /whmcs client mapping not found/i.test(message)) {
|
||||||
|
this.logger.warn("Profile not available after auth state update; returning auth user only", {
|
||||||
|
userId: id,
|
||||||
|
});
|
||||||
|
const authUser = await this.authRepository.findById(id);
|
||||||
|
if (authUser) {
|
||||||
|
return mapPrismaUserToDomain(authUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.error("Failed to update user auth state", {
|
this.logger.error("Failed to update user auth state", {
|
||||||
userId: id,
|
userId: id,
|
||||||
error: getErrorMessage(error),
|
error: message,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -134,7 +134,10 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
|||||||
login: async credentials => {
|
login: async credentials => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.POST("/api/auth/login", { body: credentials });
|
const response = await apiClient.POST("/api/auth/login", {
|
||||||
|
body: credentials,
|
||||||
|
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
||||||
|
});
|
||||||
const parsed = authResponseSchema.safeParse(response.data);
|
const parsed = authResponseSchema.safeParse(response.data);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new Error(parsed.error.issues?.[0]?.message ?? "Login failed");
|
throw new Error(parsed.error.issues?.[0]?.message ?? "Login failed");
|
||||||
@ -150,7 +153,10 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
|||||||
signup: async data => {
|
signup: async data => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.POST("/api/auth/signup", { body: data });
|
const response = await apiClient.POST("/api/auth/signup", {
|
||||||
|
body: data,
|
||||||
|
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
||||||
|
});
|
||||||
const parsed = authResponseSchema.safeParse(response.data);
|
const parsed = authResponseSchema.safeParse(response.data);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new Error(parsed.error.issues?.[0]?.message ?? "Signup failed");
|
throw new Error(parsed.error.issues?.[0]?.message ?? "Signup failed");
|
||||||
@ -189,7 +195,10 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
|||||||
requestPasswordReset: async (email: string) => {
|
requestPasswordReset: async (email: string) => {
|
||||||
set({ loading: true, error: null });
|
set({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
await apiClient.POST("/api/auth/request-password-reset", { body: { email } });
|
await apiClient.POST("/api/auth/request-password-reset", {
|
||||||
|
body: { email },
|
||||||
|
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
||||||
|
});
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
@ -205,6 +214,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.POST("/api/auth/reset-password", {
|
const response = await apiClient.POST("/api/auth/reset-password", {
|
||||||
body: { token, password },
|
body: { token, password },
|
||||||
|
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
||||||
});
|
});
|
||||||
const parsed = authResponseSchema.safeParse(response.data);
|
const parsed = authResponseSchema.safeParse(response.data);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@ -245,6 +255,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.POST("/api/auth/check-password-needed", {
|
const response = await apiClient.POST("/api/auth/check-password-needed", {
|
||||||
body: { email },
|
body: { email },
|
||||||
|
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = checkPasswordNeededResponseSchema.safeParse(response.data);
|
const parsed = checkPasswordNeededResponseSchema.safeParse(response.data);
|
||||||
@ -268,6 +279,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.POST("/api/auth/link-whmcs", {
|
const response = await apiClient.POST("/api/auth/link-whmcs", {
|
||||||
body: linkRequest,
|
body: linkRequest,
|
||||||
|
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = linkWhmcsResponseSchema.safeParse(response.data);
|
const parsed = linkWhmcsResponseSchema.safeParse(response.data);
|
||||||
@ -291,6 +303,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
|||||||
try {
|
try {
|
||||||
const response = await apiClient.POST("/api/auth/set-password", {
|
const response = await apiClient.POST("/api/auth/set-password", {
|
||||||
body: { email, password },
|
body: { email, password },
|
||||||
|
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
||||||
});
|
});
|
||||||
const parsed = authResponseSchema.safeParse(response.data);
|
const parsed = authResponseSchema.safeParse(response.data);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
|
|||||||
@ -36,6 +36,7 @@
|
|||||||
"./toolkit/*": "./dist/toolkit/*.js"
|
"./toolkit/*": "./dist/toolkit/*.js"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"prebuild": "pnpm run clean",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "tsc -w --preserveWatchOutput",
|
"dev": "tsc -w --preserveWatchOutput",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
|
|||||||
@ -215,8 +215,8 @@ export function useZodForm<TValues extends Record<string, unknown>>({
|
|||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
setSubmitError(message);
|
setSubmitError(message);
|
||||||
setErrors(prev => ({ ...prev, _form: message }));
|
setErrors(prev => ({ ...prev, _form: message }));
|
||||||
|
// Errors are captured in state so we avoid rethrowing to prevent unhandled rejections in callers
|
||||||
// Note: Logging should be handled by the consuming application
|
// Note: Logging should be handled by the consuming application
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
84
sim-manager-migration/FILE_INVENTORY.md
Normal file
84
sim-manager-migration/FILE_INVENTORY.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# SIM Manager Migration - File Inventory
|
||||||
|
|
||||||
|
This document lists all files included in the migration package.
|
||||||
|
|
||||||
|
## Backend - Freebit Integration
|
||||||
|
|
||||||
|
### Module
|
||||||
|
- `backend/freebit-integration/freebit.module.ts`
|
||||||
|
|
||||||
|
### Services
|
||||||
|
- `backend/freebit-integration/services/freebit-auth.service.ts` - Authentication service
|
||||||
|
- `backend/freebit-integration/services/freebit-client.service.ts` - HTTP client service
|
||||||
|
- `backend/freebit-integration/services/freebit-error.service.ts` - Error handling
|
||||||
|
- `backend/freebit-integration/services/freebit-mapper.service.ts` - Data mapping
|
||||||
|
- `backend/freebit-integration/services/freebit-operations.service.ts` - API operations
|
||||||
|
- `backend/freebit-integration/services/freebit-orchestrator.service.ts` - Orchestration layer
|
||||||
|
- `backend/freebit-integration/services/index.ts` - Service exports
|
||||||
|
|
||||||
|
### Interfaces & Types
|
||||||
|
- `backend/freebit-integration/interfaces/freebit.types.ts` - TypeScript interfaces
|
||||||
|
|
||||||
|
## Backend - SIM Management
|
||||||
|
|
||||||
|
### Module
|
||||||
|
- `backend/sim-management/sim-management.module.ts`
|
||||||
|
- `backend/sim-management/sim-management.service.ts` - Main service facade
|
||||||
|
- `backend/sim-management/index.ts` - Module exports
|
||||||
|
|
||||||
|
### Services
|
||||||
|
- `backend/sim-management/services/sim-cancellation.service.ts` - SIM cancellation logic
|
||||||
|
- `backend/sim-management/services/sim-details.service.ts` - SIM details retrieval
|
||||||
|
- `backend/sim-management/services/esim-management.service.ts` - eSIM operations
|
||||||
|
- `backend/sim-management/services/sim-notification.service.ts` - Notification handling
|
||||||
|
- `backend/sim-management/services/sim-orchestrator.service.ts` - Main orchestrator
|
||||||
|
- `backend/sim-management/services/sim-plan.service.ts` - Plan change logic
|
||||||
|
- `backend/sim-management/services/sim-topup.service.ts` - Top-up with payment flow
|
||||||
|
- `backend/sim-management/services/sim-usage.service.ts` - Usage data retrieval
|
||||||
|
- `backend/sim-management/services/sim-validation.service.ts` - Validation logic
|
||||||
|
- `backend/sim-management/services/sim-voice-options.service.ts` - Voice options management
|
||||||
|
|
||||||
|
### Types & Interfaces
|
||||||
|
- `backend/sim-management/types/sim-requests.types.ts` - Request/response types
|
||||||
|
- `backend/sim-management/interfaces/sim-base.interface.ts` - Base interfaces
|
||||||
|
|
||||||
|
## Backend - Controllers
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
- `backend/controllers/sim-endpoints.controller.ts` - All SIM management endpoints
|
||||||
|
|
||||||
|
## Frontend - Components
|
||||||
|
|
||||||
|
### Main Components
|
||||||
|
- `frontend/components/SimManagementSection.tsx` - Main container component
|
||||||
|
- `frontend/components/SimDetailsCard.tsx` - SIM details display
|
||||||
|
- `frontend/components/DataUsageChart.tsx` - Usage visualization
|
||||||
|
- `frontend/components/SimActions.tsx` - Action buttons
|
||||||
|
- `frontend/components/SimFeatureToggles.tsx` - Service options
|
||||||
|
- `frontend/components/TopUpModal.tsx` - Top-up interface
|
||||||
|
- `frontend/components/ChangePlanModal.tsx` - Plan change modal
|
||||||
|
- `frontend/components/ReissueSimModal.tsx` - eSIM reissue modal
|
||||||
|
|
||||||
|
### Utilities
|
||||||
|
- `frontend/utils/plan.ts` - Plan utilities
|
||||||
|
- `frontend/index.ts` - Component exports
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- `docs/FREEBIT-SIM-MANAGEMENT.md` - Complete implementation guide
|
||||||
|
- `docs/SIM-MANAGEMENT-API-DATA-FLOW.md` - API data flow documentation
|
||||||
|
|
||||||
|
## Migration Files
|
||||||
|
|
||||||
|
- `README.md` - Migration instructions
|
||||||
|
- `FILE_INVENTORY.md` - This file
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- **Backend Files**: ~25 TypeScript files
|
||||||
|
- **Frontend Files**: ~10 TypeScript/TSX files
|
||||||
|
- **Documentation**: 2 markdown files
|
||||||
|
- **Total**: ~37 files
|
||||||
|
|
||||||
|
All files are ready for migration to a new branch.
|
||||||
|
|
||||||
257
sim-manager-migration/README.md
Normal file
257
sim-manager-migration/README.md
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
# SIM Manager Migration Package
|
||||||
|
|
||||||
|
This folder contains all the code, logic, and rules for the SIM Manager feature and Freebit API integration. Use this package when migrating SIM Manager functionality to a different branch.
|
||||||
|
|
||||||
|
## 📁 Folder Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
sim-manager-migration/
|
||||||
|
├── backend/
|
||||||
|
│ ├── freebit-integration/ # Freebit API integration layer
|
||||||
|
│ │ ├── services/ # Freebit API service implementations
|
||||||
|
│ │ ├── interfaces/ # TypeScript interfaces and types
|
||||||
|
│ │ └── freebit.module.ts # NestJS module
|
||||||
|
│ ├── sim-management/ # SIM management business logic
|
||||||
|
│ │ ├── services/ # SIM management services
|
||||||
|
│ │ ├── types/ # Request/response types
|
||||||
|
│ │ ├── interfaces/ # Business logic interfaces
|
||||||
|
│ │ ├── sim-management.module.ts # NestJS module
|
||||||
|
│ │ └── sim-management.service.ts # Main service facade
|
||||||
|
│ └── controllers/ # API endpoint definitions
|
||||||
|
│ └── sim-endpoints.controller.ts
|
||||||
|
├── frontend/ # React/Next.js components
|
||||||
|
│ ├── components/ # SIM management UI components
|
||||||
|
│ ├── utils/ # Frontend utilities
|
||||||
|
│ └── index.ts # Exports
|
||||||
|
└── docs/ # Documentation
|
||||||
|
├── FREEBIT-SIM-MANAGEMENT.md # Complete implementation guide
|
||||||
|
└── SIM-MANAGEMENT-API-DATA-FLOW.md # API data flow documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Migration Steps
|
||||||
|
|
||||||
|
### 1. Backend Setup
|
||||||
|
|
||||||
|
#### Step 1.1: Copy Freebit Integration
|
||||||
|
Copy the entire `backend/freebit-integration/` folder to:
|
||||||
|
```
|
||||||
|
apps/bff/src/integrations/freebit/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 1.2: Copy SIM Management Module
|
||||||
|
Copy the entire `backend/sim-management/` folder to:
|
||||||
|
```
|
||||||
|
apps/bff/src/modules/subscriptions/sim-management/
|
||||||
|
```
|
||||||
|
|
||||||
|
Also copy:
|
||||||
|
```
|
||||||
|
backend/sim-management/sim-management.service.ts
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```
|
||||||
|
apps/bff/src/modules/subscriptions/sim-management.service.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 1.3: Add Controller Endpoints
|
||||||
|
The file `backend/controllers/sim-endpoints.controller.ts` contains all SIM-related endpoints. You need to:
|
||||||
|
|
||||||
|
1. **Option A**: Add these methods to your existing `SubscriptionsController`
|
||||||
|
2. **Option B**: Create a separate `SimManagementController` and register it
|
||||||
|
|
||||||
|
The endpoints are:
|
||||||
|
- `GET /api/subscriptions/:id/sim` - Get comprehensive SIM info
|
||||||
|
- `GET /api/subscriptions/:id/sim/details` - Get SIM details
|
||||||
|
- `GET /api/subscriptions/:id/sim/usage` - Get usage data
|
||||||
|
- `GET /api/subscriptions/:id/sim/top-up-history` - Get top-up history
|
||||||
|
- `POST /api/subscriptions/:id/sim/top-up` - Top up data quota
|
||||||
|
- `POST /api/subscriptions/:id/sim/change-plan` - Change plan
|
||||||
|
- `POST /api/subscriptions/:id/sim/cancel` - Cancel SIM
|
||||||
|
- `POST /api/subscriptions/:id/sim/reissue-esim` - Reissue eSIM
|
||||||
|
- `POST /api/subscriptions/:id/sim/features` - Update features
|
||||||
|
- `GET /api/subscriptions/:id/sim/debug` - Debug endpoint
|
||||||
|
|
||||||
|
#### Step 1.4: Register Modules
|
||||||
|
Ensure your main app module imports both modules:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/bff/src/app.module.ts or subscriptions.module.ts
|
||||||
|
import { FreebitModule } from "@bff/integrations/freebit/freebit.module";
|
||||||
|
import { SimManagementModule } from "@bff/modules/subscriptions/sim-management/sim-management.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
// ... other modules
|
||||||
|
FreebitModule,
|
||||||
|
SimManagementModule,
|
||||||
|
],
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Frontend Setup
|
||||||
|
|
||||||
|
#### Step 2.1: Copy Frontend Components
|
||||||
|
Copy the entire `frontend/` folder to:
|
||||||
|
```
|
||||||
|
apps/portal/src/features/sim-management/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2.2: Integrate into Subscription Page
|
||||||
|
The main component is `SimManagementSection.tsx`. Import and use it in your subscription detail page:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SimManagementSection } from "@/features/sim-management";
|
||||||
|
|
||||||
|
// In your subscription detail component:
|
||||||
|
<SimManagementSection subscriptionId={subscriptionId} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Environment Configuration
|
||||||
|
|
||||||
|
Add these environment variables to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Freebit API Configuration
|
||||||
|
FREEBIT_BASE_URL=https://i1-q.mvno.net/emptool/api/
|
||||||
|
# Production: FREEBIT_BASE_URL=https://i1.mvno.net/emptool/api
|
||||||
|
|
||||||
|
FREEBIT_OEM_ID=PASI
|
||||||
|
FREEBIT_OEM_KEY=your_oem_key_here
|
||||||
|
FREEBIT_TIMEOUT=30000
|
||||||
|
FREEBIT_RETRY_ATTEMPTS=3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Dependencies
|
||||||
|
|
||||||
|
#### Required NestJS Modules
|
||||||
|
- `@nestjs/common`
|
||||||
|
- `@nestjs/config`
|
||||||
|
- `nestjs-pino` (for logging)
|
||||||
|
|
||||||
|
#### Required Services
|
||||||
|
The SIM Manager depends on these services (ensure they exist in your branch):
|
||||||
|
|
||||||
|
1. **WHMCS Integration** (for top-up payments):
|
||||||
|
- `WhmcsService` with `createInvoice()` and `capturePayment()` methods
|
||||||
|
- Required for the top-up payment flow
|
||||||
|
|
||||||
|
2. **Subscriptions Service**:
|
||||||
|
- `SubscriptionsService` - for subscription data access
|
||||||
|
|
||||||
|
3. **Mappings Service**:
|
||||||
|
- `MappingsModule` - for ID mapping between systems
|
||||||
|
|
||||||
|
4. **Email Service**:
|
||||||
|
- `EmailModule` - for notifications
|
||||||
|
|
||||||
|
5. **Sim Usage Store**:
|
||||||
|
- `SimUsageStoreService` - for caching usage data
|
||||||
|
|
||||||
|
### 5. Type Definitions
|
||||||
|
|
||||||
|
The code uses types from `@customer-portal/domain`. Ensure these types are available:
|
||||||
|
|
||||||
|
- `SimTopupRequest`
|
||||||
|
- `SimChangePlanRequest`
|
||||||
|
- `SimCancelRequest`
|
||||||
|
- `SimFeaturesRequest`
|
||||||
|
- `SimDetails`
|
||||||
|
- `SimUsage`
|
||||||
|
- `SimTopUpHistory`
|
||||||
|
|
||||||
|
If these don't exist in your domain package, they are defined in:
|
||||||
|
- `backend/sim-management/types/sim-requests.types.ts`
|
||||||
|
- `backend/freebit-integration/interfaces/freebit.types.ts`
|
||||||
|
|
||||||
|
## 📋 Freebit API Endpoints Used
|
||||||
|
|
||||||
|
The implementation uses the following Freebit APIs:
|
||||||
|
|
||||||
|
1. **PA01-01**: OEM Authentication (`/authOem/`)
|
||||||
|
2. **PA03-02**: Get Account Details (`/mvno/getDetail/`)
|
||||||
|
3. **PA04-04**: Add Specs & Quota (`/master/addSpec/`)
|
||||||
|
4. **PA05-01**: MVNO Communication Information (`/mvno/getTrafficInfo/`)
|
||||||
|
5. **PA05-02**: MVNO Quota Addition History (`/mvno/getQuotaHistory/`)
|
||||||
|
6. **PA05-04**: MVNO Plan Cancellation (`/mvno/releasePlan/`)
|
||||||
|
7. **PA05-21**: MVNO Plan Change (`/mvno/changePlan/`)
|
||||||
|
8. **PA05-22**: MVNO Quota Settings (`/mvno/eachQuota/`)
|
||||||
|
9. **PA05-42**: eSIM Profile Reissue (`/esim/reissueProfile/`)
|
||||||
|
10. **Enhanced**: eSIM Add Account/Reissue (`/mvno/esim/addAcnt/`)
|
||||||
|
|
||||||
|
## 🔧 Key Features
|
||||||
|
|
||||||
|
### SIM Management Operations
|
||||||
|
- ✅ View SIM details (ICCID, MSISDN, plan, status)
|
||||||
|
- ✅ Real-time data usage monitoring
|
||||||
|
- ✅ Data quota top-up with payment processing (1GB = ¥500)
|
||||||
|
- ✅ eSIM profile reissue
|
||||||
|
- ✅ SIM service cancellation
|
||||||
|
- ✅ Plan change functionality
|
||||||
|
- ✅ Usage history tracking
|
||||||
|
- ✅ Service options management (Voice Mail, Call Waiting, Roaming)
|
||||||
|
|
||||||
|
### Payment Flow (Top-Up)
|
||||||
|
The top-up feature includes a complete payment flow:
|
||||||
|
1. User requests top-up amount
|
||||||
|
2. WHMCS invoice created
|
||||||
|
3. Payment captured automatically
|
||||||
|
4. Freebit API called to add quota
|
||||||
|
5. Success/error handling
|
||||||
|
|
||||||
|
## 📚 Documentation
|
||||||
|
|
||||||
|
- **FREEBIT-SIM-MANAGEMENT.md**: Complete implementation guide with all features, API endpoints, and configuration
|
||||||
|
- **SIM-MANAGEMENT-API-DATA-FLOW.md**: Detailed API data flow and system architecture documentation
|
||||||
|
|
||||||
|
## ⚠️ Important Notes
|
||||||
|
|
||||||
|
1. **WHMCS Integration Required**: The top-up feature requires WHMCS integration with `createInvoice()` and `capturePayment()` methods. If these don't exist in your branch, you'll need to implement them or modify the top-up service.
|
||||||
|
|
||||||
|
2. **Module Dependencies**: The `FreebitModule` and `SimManagementModule` have circular dependencies resolved with `forwardRef()`. Ensure this pattern is maintained.
|
||||||
|
|
||||||
|
3. **Authentication**: All endpoints require authentication. The controller uses `@ApiBearerAuth()` and expects a JWT token.
|
||||||
|
|
||||||
|
4. **Validation**: Request validation uses Zod schemas. Ensure `ZodValidationPipe` is available in your validation utilities.
|
||||||
|
|
||||||
|
5. **Error Handling**: The implementation includes comprehensive error handling and user-friendly error messages.
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
After migration, test the following:
|
||||||
|
|
||||||
|
1. **SIM Details**: Verify SIM information displays correctly
|
||||||
|
2. **Usage Data**: Check that usage charts and data are accurate
|
||||||
|
3. **Top-Up**: Test the complete payment flow
|
||||||
|
4. **Plan Change**: Verify plan changes work correctly
|
||||||
|
5. **eSIM Reissue**: Test eSIM profile reissue (if applicable)
|
||||||
|
6. **Error Handling**: Test error scenarios (invalid subscription, API failures, etc.)
|
||||||
|
|
||||||
|
## 🔍 Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **"Module not found" errors**: Ensure all modules are properly imported in your app module
|
||||||
|
2. **"FREEBIT_OEM_KEY is not configured"**: Add the environment variable
|
||||||
|
3. **Payment failures**: Verify WHMCS integration is working
|
||||||
|
4. **API timeouts**: Check Freebit API connectivity and increase timeout if needed
|
||||||
|
|
||||||
|
### Debug Endpoints
|
||||||
|
|
||||||
|
Use the debug endpoint to troubleshoot:
|
||||||
|
```
|
||||||
|
GET /api/subscriptions/:id/sim/debug
|
||||||
|
GET /api/debug/sim-details/:account
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
For detailed implementation information, refer to:
|
||||||
|
- `docs/FREEBIT-SIM-MANAGEMENT.md` - Complete feature documentation
|
||||||
|
- `docs/SIM-MANAGEMENT-API-DATA-FLOW.md` - API integration details
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: January 2025
|
||||||
|
**Migration Package Version**: 1.0
|
||||||
|
|
||||||
@ -0,0 +1,313 @@
|
|||||||
|
// SIM Management Controller Endpoints
|
||||||
|
// These endpoints should be added to your subscriptions controller
|
||||||
|
// Location: apps/bff/src/modules/subscriptions/subscriptions.controller.ts
|
||||||
|
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Request,
|
||||||
|
ParseIntPipe,
|
||||||
|
BadRequestException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiTags, ApiOperation, ApiParam, ApiQuery, ApiBody, ApiResponse, ApiBearerAuth } from "@nestjs/swagger";
|
||||||
|
import { ZodValidationPipe } from "@bff/core/validation";
|
||||||
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types";
|
||||||
|
import { SimManagementService } from "../sim-management.service";
|
||||||
|
import {
|
||||||
|
simTopupRequestSchema,
|
||||||
|
simChangePlanRequestSchema,
|
||||||
|
simCancelRequestSchema,
|
||||||
|
simFeaturesRequestSchema,
|
||||||
|
type SimTopupRequest,
|
||||||
|
type SimChangePlanRequest,
|
||||||
|
type SimCancelRequest,
|
||||||
|
type SimFeaturesRequest,
|
||||||
|
} from "../sim-management/types/sim-requests.types";
|
||||||
|
|
||||||
|
// ==================== SIM Management Endpoints ====================
|
||||||
|
// Add these methods to your SubscriptionsController class
|
||||||
|
|
||||||
|
@ApiTags("subscriptions")
|
||||||
|
@Controller("subscriptions")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
export class SimEndpointsController {
|
||||||
|
constructor(private readonly simManagementService: SimManagementService) {}
|
||||||
|
|
||||||
|
@Get(":id/sim/debug")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Debug SIM subscription data",
|
||||||
|
description: "Retrieves subscription data to help debug SIM management issues",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||||
|
@ApiResponse({ status: 200, description: "Subscription debug data" })
|
||||||
|
async debugSimSubscription(
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
@Param("id", ParseIntPipe) subscriptionId: number
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return this.simManagementService.debugSimSubscription(req.user.id, subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/sim")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get SIM details and usage",
|
||||||
|
description: "Retrieves comprehensive SIM information including details and current usage",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||||
|
@ApiResponse({ status: 200, description: "SIM information" })
|
||||||
|
@ApiResponse({ status: 400, description: "Not a SIM subscription" })
|
||||||
|
@ApiResponse({ status: 404, description: "Subscription not found" })
|
||||||
|
async getSimInfo(
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
@Param("id", ParseIntPipe) subscriptionId: number
|
||||||
|
) {
|
||||||
|
return this.simManagementService.getSimInfo(req.user.id, subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/sim/info")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get SIM information (alias for /sim)",
|
||||||
|
description: "Retrieves comprehensive SIM information including details and current usage",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||||
|
@ApiResponse({ status: 200, description: "SIM information" })
|
||||||
|
@ApiResponse({ status: 400, description: "Not a SIM subscription" })
|
||||||
|
@ApiResponse({ status: 404, description: "Subscription not found" })
|
||||||
|
async getSimInfoAlias(
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
@Param("id", ParseIntPipe) subscriptionId: number
|
||||||
|
) {
|
||||||
|
return this.simManagementService.getSimInfo(req.user.id, subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/sim/details")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get SIM details",
|
||||||
|
description: "Retrieves detailed SIM information including ICCID, plan, status, etc.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||||
|
@ApiResponse({ status: 200, description: "SIM details" })
|
||||||
|
async getSimDetails(
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
@Param("id", ParseIntPipe) subscriptionId: number
|
||||||
|
) {
|
||||||
|
return this.simManagementService.getSimDetails(req.user.id, subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/sim/usage")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get SIM data usage",
|
||||||
|
description: "Retrieves current data usage and recent usage history",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||||
|
@ApiResponse({ status: 200, description: "SIM usage data" })
|
||||||
|
async getSimUsage(
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
@Param("id", ParseIntPipe) subscriptionId: number
|
||||||
|
) {
|
||||||
|
return this.simManagementService.getSimUsage(req.user.id, subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/sim/top-up-history")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Get SIM top-up history",
|
||||||
|
description: "Retrieves data top-up history for the specified date range",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||||
|
@ApiQuery({ name: "fromDate", description: "Start date (YYYYMMDD)", example: "20240101" })
|
||||||
|
@ApiQuery({ name: "toDate", description: "End date (YYYYMMDD)", example: "20241231" })
|
||||||
|
@ApiResponse({ status: 200, description: "Top-up history" })
|
||||||
|
async getSimTopUpHistory(
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
|
@Query("fromDate") fromDate: string,
|
||||||
|
@Query("toDate") toDate: string
|
||||||
|
) {
|
||||||
|
if (!fromDate || !toDate) {
|
||||||
|
throw new BadRequestException("fromDate and toDate are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.simManagementService.getSimTopUpHistory(req.user.id, subscriptionId, {
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/sim/top-up")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Top up SIM data quota",
|
||||||
|
description: "Add data quota to the SIM service",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||||
|
@ApiBody({
|
||||||
|
description: "Top-up request",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
quotaMb: { type: "number", description: "Quota in MB", example: 1000 },
|
||||||
|
amount: {
|
||||||
|
type: "number",
|
||||||
|
description: "Amount to charge in JPY (optional, defaults to calculated amount)",
|
||||||
|
example: 500,
|
||||||
|
},
|
||||||
|
currency: {
|
||||||
|
type: "string",
|
||||||
|
description: "ISO currency code (optional, defaults to JPY)",
|
||||||
|
example: "JPY",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["quotaMb"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Top-up successful" })
|
||||||
|
async topUpSim(
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
|
@Body(new ZodValidationPipe(simTopupRequestSchema)) body: SimTopupRequest
|
||||||
|
) {
|
||||||
|
await this.simManagementService.topUpSim(req.user.id, subscriptionId, body);
|
||||||
|
return { success: true, message: "SIM top-up completed successfully" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/sim/change-plan")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Change SIM plan",
|
||||||
|
description:
|
||||||
|
"Change the SIM service plan. The change will be automatically scheduled for the 1st of the next month. Available plans: 5GB, 10GB, 25GB, 50GB.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||||
|
@ApiBody({
|
||||||
|
description: "Plan change request",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
newPlanCode: {
|
||||||
|
type: "string",
|
||||||
|
description: "New plan code",
|
||||||
|
enum: ["5GB", "10GB", "25GB", "50GB"],
|
||||||
|
example: "25GB"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["newPlanCode"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Plan change successful" })
|
||||||
|
async changeSimPlan(
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
|
@Body(new ZodValidationPipe(simChangePlanRequestSchema)) body: SimChangePlanRequest
|
||||||
|
) {
|
||||||
|
const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "SIM plan change completed successfully",
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/sim/cancel")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Cancel SIM service",
|
||||||
|
description: "Cancel the SIM service (immediate or scheduled)",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||||
|
@ApiBody({
|
||||||
|
description: "Cancellation request",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
scheduledAt: {
|
||||||
|
type: "string",
|
||||||
|
description: "Schedule cancellation (YYYYMMDD)",
|
||||||
|
example: "20241231",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Cancellation successful" })
|
||||||
|
async cancelSim(
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
|
@Body(new ZodValidationPipe(simCancelRequestSchema)) body: SimCancelRequest
|
||||||
|
) {
|
||||||
|
await this.simManagementService.cancelSim(req.user.id, subscriptionId, body);
|
||||||
|
return { success: true, message: "SIM cancellation completed successfully" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/sim/reissue-esim")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Reissue eSIM profile",
|
||||||
|
description:
|
||||||
|
"Reissue a downloadable eSIM profile (eSIM only). Optionally provide a new EID to transfer to.",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||||
|
@ApiBody({
|
||||||
|
description: "Optional new EID to transfer the eSIM to",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
newEid: {
|
||||||
|
type: "string",
|
||||||
|
description: "32-digit EID",
|
||||||
|
example: "89049032000001000000043598005455",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "eSIM reissue successful" })
|
||||||
|
@ApiResponse({ status: 400, description: "Not an eSIM subscription" })
|
||||||
|
async reissueEsimProfile(
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
|
@Body() body: { newEid?: string } = {}
|
||||||
|
) {
|
||||||
|
await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId, body.newEid);
|
||||||
|
return { success: true, message: "eSIM profile reissue completed successfully" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/sim/features")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Update SIM features",
|
||||||
|
description:
|
||||||
|
"Enable/disable voicemail, call waiting, international roaming, and switch network type (4G/5G)",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||||
|
@ApiBody({
|
||||||
|
description: "Features update request",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
voiceMailEnabled: { type: "boolean" },
|
||||||
|
callWaitingEnabled: { type: "boolean" },
|
||||||
|
internationalRoamingEnabled: { type: "boolean" },
|
||||||
|
networkType: { type: "string", enum: ["4G", "5G"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: "Features update successful" })
|
||||||
|
async updateSimFeatures(
|
||||||
|
@Request() req: RequestWithUser,
|
||||||
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
|
@Body(new ZodValidationPipe(simFeaturesRequestSchema)) body: SimFeaturesRequest
|
||||||
|
) {
|
||||||
|
await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body);
|
||||||
|
return { success: true, message: "SIM features updated successfully" };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("debug/sim-details/:account")
|
||||||
|
// @Public() // Uncomment if you have a Public decorator for debug endpoints
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "[DEBUG] Get SIM details from Freebit",
|
||||||
|
description: "Query Freebit API directly to see plan code and details for any account",
|
||||||
|
})
|
||||||
|
@ApiParam({ name: "account", description: "SIM account number (e.g., 02000215161147)" })
|
||||||
|
async debugSimDetails(@Param("account") account: string) {
|
||||||
|
return await this.simManagementService.getSimDetailsDebug(account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import { Module, forwardRef, Inject, Optional } from "@nestjs/common";
|
||||||
|
import { FreebitOrchestratorService } from "./services/freebit-orchestrator.service";
|
||||||
|
import { FreebitMapperService } from "./services/freebit-mapper.service";
|
||||||
|
import { FreebitOperationsService } from "./services/freebit-operations.service";
|
||||||
|
import { FreebitClientService } from "./services/freebit-client.service";
|
||||||
|
import { FreebitAuthService } from "./services/freebit-auth.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
forwardRef(() => {
|
||||||
|
const { SimManagementModule } = require("../../modules/subscriptions/sim-management/sim-management.module");
|
||||||
|
return SimManagementModule;
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
// Core services
|
||||||
|
FreebitClientService,
|
||||||
|
FreebitAuthService,
|
||||||
|
FreebitMapperService,
|
||||||
|
FreebitOperationsService,
|
||||||
|
FreebitOrchestratorService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
// Export orchestrator in case other services need direct access
|
||||||
|
FreebitOrchestratorService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class FreebitModule {}
|
||||||
@ -0,0 +1,405 @@
|
|||||||
|
// Freebit API Type Definitions (cleaned)
|
||||||
|
|
||||||
|
export interface FreebitAuthRequest {
|
||||||
|
oemId: string; // 4-char alphanumeric ISP identifier
|
||||||
|
oemKey: string; // 32-char auth key
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitAuthResponse {
|
||||||
|
resultCode: string;
|
||||||
|
status: {
|
||||||
|
message: string;
|
||||||
|
statusCode: string | number;
|
||||||
|
};
|
||||||
|
authKey: string; // Token for subsequent API calls
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitAccountDetailsRequest {
|
||||||
|
authKey: string;
|
||||||
|
version?: string | number; // Docs recommend "2"
|
||||||
|
requestDatas: Array<{
|
||||||
|
kind: "MASTER" | "MVNO";
|
||||||
|
account?: string | number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitAccountDetail {
|
||||||
|
kind: "MASTER" | "MVNO";
|
||||||
|
account: string | number;
|
||||||
|
state: "active" | "suspended" | "temporary" | "waiting" | "obsolete";
|
||||||
|
status?: "active" | "suspended" | "temporary" | "waiting" | "obsolete";
|
||||||
|
startDate?: string | number;
|
||||||
|
relationCode?: string;
|
||||||
|
resultCode?: string | number;
|
||||||
|
planCode?: string;
|
||||||
|
planName?: string;
|
||||||
|
iccid?: string | number;
|
||||||
|
imsi?: string | number;
|
||||||
|
eid?: string;
|
||||||
|
contractLine?: string;
|
||||||
|
size?: "standard" | "nano" | "micro" | "esim";
|
||||||
|
simSize?: "standard" | "nano" | "micro" | "esim";
|
||||||
|
msisdn?: string | number;
|
||||||
|
sms?: number; // 10=active, 20=inactive
|
||||||
|
talk?: number; // 10=active, 20=inactive
|
||||||
|
ipv4?: string;
|
||||||
|
ipv6?: string;
|
||||||
|
quota?: number; // Remaining quota
|
||||||
|
remainingQuotaMb?: string | number | null;
|
||||||
|
remainingQuotaKb?: string | number | null;
|
||||||
|
voicemail?: "10" | "20" | number | null;
|
||||||
|
voiceMail?: "10" | "20" | number | null;
|
||||||
|
callwaiting?: "10" | "20" | number | null;
|
||||||
|
callWaiting?: "10" | "20" | number | null;
|
||||||
|
worldwing?: "10" | "20" | number | null;
|
||||||
|
worldWing?: "10" | "20" | number | null;
|
||||||
|
networkType?: string;
|
||||||
|
async?: { func: string; date: string | number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitAccountDetailsResponse {
|
||||||
|
resultCode: string;
|
||||||
|
status: {
|
||||||
|
message: string;
|
||||||
|
statusCode: string | number;
|
||||||
|
};
|
||||||
|
masterAccount?: string;
|
||||||
|
responseDatas: FreebitAccountDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitTrafficInfoRequest {
|
||||||
|
authKey: string;
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitTrafficInfoResponse {
|
||||||
|
resultCode: string;
|
||||||
|
status: {
|
||||||
|
message: string;
|
||||||
|
statusCode: string | number;
|
||||||
|
};
|
||||||
|
account: string;
|
||||||
|
traffic: {
|
||||||
|
today: string; // Today's usage in KB
|
||||||
|
inRecentDays: string; // Comma-separated recent days usage
|
||||||
|
blackList: string; // 10=blacklisted, 20=not blacklisted
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitTopUpRequest {
|
||||||
|
authKey: string;
|
||||||
|
account: string;
|
||||||
|
quota: number; // KB units (e.g., 102400 for 100MB)
|
||||||
|
quotaCode?: string; // Campaign code
|
||||||
|
expire?: string; // YYYYMMDD format (8 digits)
|
||||||
|
runTime?: string; // Scheduled execution time (YYYYMMDD format, 8 digits)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitTopUpResponse {
|
||||||
|
resultCode: string;
|
||||||
|
status: { message: string; statusCode: string | number };
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSpec request for updating SIM options/features immediately
|
||||||
|
export interface FreebitAddSpecRequest {
|
||||||
|
authKey: string;
|
||||||
|
account: string;
|
||||||
|
kind?: string; // e.g. 'MVNO'
|
||||||
|
// Feature flags: 10 = enabled, 20 = disabled
|
||||||
|
voiceMail?: "10" | "20";
|
||||||
|
voicemail?: "10" | "20";
|
||||||
|
callWaiting?: "10" | "20";
|
||||||
|
callwaiting?: "10" | "20";
|
||||||
|
worldWing?: "10" | "20";
|
||||||
|
worldwing?: "10" | "20";
|
||||||
|
contractLine?: string; // '4G' or '5G'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitVoiceOptionSettings {
|
||||||
|
voiceMail?: "10" | "20";
|
||||||
|
callWaiting?: "10" | "20";
|
||||||
|
callTransfer?: "10" | "20";
|
||||||
|
callTransferWorld?: "10" | "20";
|
||||||
|
callTransferNoId?: "10" | "20";
|
||||||
|
worldCall?: "10" | "20";
|
||||||
|
worldCallCreditLimit?: string;
|
||||||
|
worldWing?: "10" | "20";
|
||||||
|
worldWingCreditLimit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitVoiceOptionRequest {
|
||||||
|
authKey: string;
|
||||||
|
account: string;
|
||||||
|
userConfirmed?: "10" | "20";
|
||||||
|
aladinOperated?: "10" | "20";
|
||||||
|
talkOption: FreebitVoiceOptionSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitVoiceOptionResponse {
|
||||||
|
resultCode: string;
|
||||||
|
status: { message: string; statusCode: string | number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitAddSpecResponse {
|
||||||
|
resultCode: string;
|
||||||
|
status: { message: string; statusCode: string | number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitQuotaHistoryRequest {
|
||||||
|
authKey: string;
|
||||||
|
account: string;
|
||||||
|
fromDate: string;
|
||||||
|
toDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitQuotaHistoryItem {
|
||||||
|
quota: string; // KB as string
|
||||||
|
date: string;
|
||||||
|
expire: string;
|
||||||
|
quotaCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitQuotaHistoryResponse {
|
||||||
|
resultCode: string;
|
||||||
|
status: { message: string; statusCode: string | number };
|
||||||
|
total: string | number;
|
||||||
|
count: string | number;
|
||||||
|
quotaHistory: FreebitQuotaHistoryItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitPlanChangeRequest {
|
||||||
|
authKey: string;
|
||||||
|
account: string;
|
||||||
|
planCode: string; // Note: API expects camelCase "planCode" not "plancode"
|
||||||
|
globalip?: "0" | "1"; // 0=disabled, 1=assign global IP (PA05-21 expects legacy flags)
|
||||||
|
runTime?: string; // YYYYMMDD format (8 digits, date only) - optional
|
||||||
|
contractLine?: "4G" | "5G"; // Network type for contract line changes
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitPlanChangeResponse {
|
||||||
|
resultCode: string;
|
||||||
|
status: { message: string; statusCode: string | number };
|
||||||
|
ipv4?: string;
|
||||||
|
ipv6?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitPlanChangePayload {
|
||||||
|
requestDatas: Array<{
|
||||||
|
kind: "MVNO";
|
||||||
|
account: string;
|
||||||
|
newPlanCode: string;
|
||||||
|
assignGlobalIp: boolean;
|
||||||
|
scheduledAt?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitAddSpecPayload {
|
||||||
|
requestDatas: Array<{
|
||||||
|
kind: "MVNO";
|
||||||
|
account: string;
|
||||||
|
specCode: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
networkType?: "4G" | "5G";
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitCancelPlanPayload {
|
||||||
|
requestDatas: Array<{
|
||||||
|
kind: "MVNO";
|
||||||
|
account: string;
|
||||||
|
runDate: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitEsimReissuePayload {
|
||||||
|
requestDatas: Array<{
|
||||||
|
kind: "MVNO";
|
||||||
|
account: string;
|
||||||
|
newEid: string;
|
||||||
|
oldEid?: string;
|
||||||
|
planCode?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitContractLineChangeRequest {
|
||||||
|
authKey: string;
|
||||||
|
account: string;
|
||||||
|
contractLine: "4G" | "5G";
|
||||||
|
productNumber?: string;
|
||||||
|
eid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitContractLineChangeResponse {
|
||||||
|
resultCode: string | number;
|
||||||
|
status?: { message?: string; statusCode?: string | number };
|
||||||
|
statusCode?: string | number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitCancelPlanRequest {
|
||||||
|
authKey: string;
|
||||||
|
account: string;
|
||||||
|
runTime?: string; // YYYYMMDD - optional
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitCancelPlanResponse {
|
||||||
|
resultCode: string;
|
||||||
|
status: { message: string; statusCode: string | number };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PA02-04: Account Cancellation (master/cnclAcnt)
|
||||||
|
export interface FreebitCancelAccountRequest {
|
||||||
|
authKey: string;
|
||||||
|
kind: string; // e.g., 'MVNO'
|
||||||
|
account: string;
|
||||||
|
runDate?: string; // YYYYMMDD
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitCancelAccountResponse {
|
||||||
|
resultCode: string;
|
||||||
|
status: { message: string; statusCode: string | number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitEsimReissueRequest {
|
||||||
|
authKey: string;
|
||||||
|
requestDatas: Array<{
|
||||||
|
kind: "MVNO";
|
||||||
|
account: string;
|
||||||
|
newEid?: string;
|
||||||
|
oldEid?: string;
|
||||||
|
planCode?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitEsimReissueResponse {
|
||||||
|
resultCode: string;
|
||||||
|
status: { message: string; statusCode: string | number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitEsimAddAccountRequest {
|
||||||
|
authKey: string;
|
||||||
|
aladinOperated: string; // '10' for issue, '20' for no-issue
|
||||||
|
account: string;
|
||||||
|
eid: string;
|
||||||
|
addKind: "N" | "R"; // N = new, R = reissue
|
||||||
|
shipDate?: string;
|
||||||
|
planCode?: string;
|
||||||
|
contractLine?: string;
|
||||||
|
mnp?: {
|
||||||
|
reserveNumber: string;
|
||||||
|
reserveExpireDate: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitEsimAddAccountResponse {
|
||||||
|
resultCode: string;
|
||||||
|
status: { message: string; statusCode: string | number };
|
||||||
|
}
|
||||||
|
|
||||||
|
// PA05-41 eSIM Account Activation (addAcct)
|
||||||
|
// Based on Freebit API specification - all parameters from JSON table
|
||||||
|
export interface FreebitEsimAccountActivationRequest {
|
||||||
|
authKey: string; // Row 1: 認証キー (Required)
|
||||||
|
aladinOperated: string; // Row 2: ALADIN帳票作成フラグ ('10':操作済, '20':未操作) (Required)
|
||||||
|
masterAccount?: string; // Row 3: マスタアカウント (Conditional - for service provider)
|
||||||
|
masterPassword?: string; // Row 4: マスタパスワード (Conditional - for service provider)
|
||||||
|
createType: string; // Row 5: 登録区分 ('new', 'reissue', 'add') (Required)
|
||||||
|
eid?: string; // Row 6: eSIM識別番号 (Conditional - required for reissue/exchange)
|
||||||
|
account: string; // Row 7: アカウント/MSISDN (Required)
|
||||||
|
simkind: string; // Row 8: SIM種別 (Conditional - Required except when addKind='R')
|
||||||
|
// eSIM: 'E0':音声あり, 'E2':SMSなし, 'E3':SMSあり
|
||||||
|
// Physical: '3MS', '3MR', etc
|
||||||
|
contractLine?: string; // Row 9: 契約回線種別 ('4G', '5G') (Conditional)
|
||||||
|
repAccount?: string; // Row 10: 代表番号 (Conditional)
|
||||||
|
addKind?: string; // Row 11: 開通種別 ('N':新規, 'M':MNP転入, 'R':再発行) (Required)
|
||||||
|
reissue?: string; // Row 12: 再発行情報 (Conditional)
|
||||||
|
oldProductNumber?: string; // Row 13: 元製造番号 (Conditional - for exchange)
|
||||||
|
oldEid?: string; // Row 14: 元eSIM識別番号 (Conditional - for exchange)
|
||||||
|
mnp?: { // Row 15: MNP情報 (Conditional)
|
||||||
|
reserveNumber: string; // Row 16: MNP予約番号 (Conditional)
|
||||||
|
reserveExpireDate?: string; // (Conditional) YYYYMMDD
|
||||||
|
};
|
||||||
|
firstnameKanji?: string; // Row 17: 由字(漢字) (Conditional)
|
||||||
|
lastnameKanji?: string; // Row 18: 名前(漢字) (Conditional)
|
||||||
|
firstnameZenKana?: string; // Row 19: 由字(全角カタカナ) (Conditional)
|
||||||
|
lastnameZenKana?: string; // Row 20: 名前(全角カタカナ) (Conditional)
|
||||||
|
gender?: string; // Row 21: 性別 ('M', 'F') (Required for identification)
|
||||||
|
birthday?: string; // Row 22: 生年月日 YYYYMMDD (Conditional)
|
||||||
|
shipDate?: string; // Row 23: 出荷日 YYYYMMDD (Conditional)
|
||||||
|
planCode?: string; // Row 24: プランコード (Max 32 chars) (Conditional)
|
||||||
|
deliveryCode?: string; // Row 25: 顧客コード (Max 10 chars) (Conditional - OEM specific)
|
||||||
|
globalIp?: string; // Additional: グローバルIP ('10': なし, '20': あり)
|
||||||
|
size?: string; // SIM physical size (for physical SIMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreebitEsimAccountActivationResponse {
|
||||||
|
resultCode: string;
|
||||||
|
status?: {
|
||||||
|
message?: string;
|
||||||
|
statusCode?: string | number;
|
||||||
|
};
|
||||||
|
statusCode?: string | number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portal-specific types for SIM management
|
||||||
|
export interface SimDetails {
|
||||||
|
account: string;
|
||||||
|
status: "active" | "suspended" | "cancelled" | "pending";
|
||||||
|
planCode: string;
|
||||||
|
planName: string;
|
||||||
|
simType: "standard" | "nano" | "micro" | "esim";
|
||||||
|
iccid: string;
|
||||||
|
eid: string;
|
||||||
|
msisdn: string;
|
||||||
|
imsi: string;
|
||||||
|
remainingQuotaMb: number;
|
||||||
|
remainingQuotaKb: number;
|
||||||
|
voiceMailEnabled: boolean;
|
||||||
|
callWaitingEnabled: boolean;
|
||||||
|
internationalRoamingEnabled: boolean;
|
||||||
|
networkType: string;
|
||||||
|
activatedAt?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimUsage {
|
||||||
|
account: string;
|
||||||
|
todayUsageMb: number;
|
||||||
|
todayUsageKb: number;
|
||||||
|
monthlyUsageMb?: number;
|
||||||
|
monthlyUsageKb?: number;
|
||||||
|
recentDaysUsage: Array<{ date: string; usageKb: number; usageMb: number }>;
|
||||||
|
isBlacklisted: boolean;
|
||||||
|
lastUpdated?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimTopUpHistory {
|
||||||
|
account: string;
|
||||||
|
totalAdditions: number;
|
||||||
|
additionCount: number;
|
||||||
|
history: Array<{
|
||||||
|
quotaKb: number;
|
||||||
|
quotaMb: number;
|
||||||
|
addedDate: string;
|
||||||
|
expiryDate: string;
|
||||||
|
campaignCode: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
export interface FreebitError extends Error {
|
||||||
|
resultCode: string;
|
||||||
|
statusCode: string | number;
|
||||||
|
freebititMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
export interface FreebitConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
oemId: string;
|
||||||
|
oemKey: string;
|
||||||
|
timeout: number;
|
||||||
|
retryAttempts: number;
|
||||||
|
detailsEndpoint?: string;
|
||||||
|
}
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
import { Injectable, Inject, InternalServerErrorException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import type {
|
||||||
|
FreebitConfig,
|
||||||
|
FreebitAuthRequest,
|
||||||
|
FreebitAuthResponse,
|
||||||
|
} from "../interfaces/freebit.types";
|
||||||
|
import { FreebitError } from "./freebit-error.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FreebitAuthService {
|
||||||
|
private readonly config: FreebitConfig;
|
||||||
|
private authKeyCache: { token: string; expiresAt: number } | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {
|
||||||
|
this.config = {
|
||||||
|
baseUrl:
|
||||||
|
this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api",
|
||||||
|
oemId: this.configService.get<string>("FREEBIT_OEM_ID") || "PASI",
|
||||||
|
oemKey: this.configService.get<string>("FREEBIT_OEM_KEY") || "",
|
||||||
|
timeout: this.configService.get<number>("FREEBIT_TIMEOUT") || 30000,
|
||||||
|
retryAttempts: this.configService.get<number>("FREEBIT_RETRY_ATTEMPTS") || 3,
|
||||||
|
detailsEndpoint:
|
||||||
|
this.configService.get<string>("FREEBIT_DETAILS_ENDPOINT") || "/master/getAcnt/",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.config.oemKey) {
|
||||||
|
this.logger.warn("FREEBIT_OEM_KEY is not configured. SIM management features will not work.");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug("Freebit auth service initialized", {
|
||||||
|
baseUrl: this.config.baseUrl,
|
||||||
|
oemId: this.config.oemId,
|
||||||
|
hasOemKey: !!this.config.oemKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current configuration
|
||||||
|
*/
|
||||||
|
getConfig(): FreebitConfig {
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get authentication key (cached or fetch new one)
|
||||||
|
*/
|
||||||
|
async getAuthKey(): Promise<string> {
|
||||||
|
if (this.authKeyCache && this.authKeyCache.expiresAt > Date.now()) {
|
||||||
|
return this.authKeyCache.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!this.config.oemKey) {
|
||||||
|
throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: FreebitAuthRequest = {
|
||||||
|
oemId: this.config.oemId,
|
||||||
|
oemKey: this.config.oemKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure proper URL construction - remove double slashes
|
||||||
|
const baseUrl = this.config.baseUrl.replace(/\/$/, '');
|
||||||
|
const authUrl = `${baseUrl}/authOem/`;
|
||||||
|
|
||||||
|
const response = await fetch(authUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: `json=${JSON.stringify(request)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as FreebitAuthResponse;
|
||||||
|
const resultCode = data?.resultCode != null ? String(data.resultCode).trim() : undefined;
|
||||||
|
const statusCode =
|
||||||
|
data?.status?.statusCode != null ? String(data.status.statusCode).trim() : undefined;
|
||||||
|
|
||||||
|
if (resultCode !== "100") {
|
||||||
|
throw new FreebitError(
|
||||||
|
`Authentication failed: ${data.status.message}`,
|
||||||
|
resultCode,
|
||||||
|
statusCode,
|
||||||
|
data.status.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.authKeyCache = { token: data.authKey, expiresAt: Date.now() + 50 * 60 * 1000 };
|
||||||
|
this.logger.log("Successfully authenticated with Freebit API");
|
||||||
|
return data.authKey;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error("Failed to authenticate with Freebit API", { error: message });
|
||||||
|
throw new InternalServerErrorException("Failed to authenticate with Freebit API");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cached authentication key
|
||||||
|
*/
|
||||||
|
clearAuthCache(): void {
|
||||||
|
this.authKeyCache = null;
|
||||||
|
this.logger.debug("Cleared Freebit auth cache");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we have a valid cached auth key
|
||||||
|
*/
|
||||||
|
hasValidAuthCache(): boolean {
|
||||||
|
return !!(this.authKeyCache && this.authKeyCache.expiresAt > Date.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,295 @@
|
|||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import { FreebitAuthService } from "./freebit-auth.service";
|
||||||
|
import { FreebitError } from "./freebit-error.service";
|
||||||
|
|
||||||
|
interface FreebitResponseBase {
|
||||||
|
resultCode?: string | number;
|
||||||
|
status?: {
|
||||||
|
message?: string;
|
||||||
|
statusCode?: string | number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FreebitClientService {
|
||||||
|
constructor(
|
||||||
|
private readonly authService: FreebitAuthService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an authenticated request to Freebit API with retry logic
|
||||||
|
*/
|
||||||
|
async makeAuthenticatedRequest<TResponse extends FreebitResponseBase, TPayload extends object>(
|
||||||
|
endpoint: string,
|
||||||
|
payload: TPayload
|
||||||
|
): Promise<TResponse> {
|
||||||
|
const authKey = await this.authService.getAuthKey();
|
||||||
|
const config = this.authService.getConfig();
|
||||||
|
|
||||||
|
const requestPayload = { ...payload, authKey };
|
||||||
|
// Ensure proper URL construction - remove double slashes
|
||||||
|
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
||||||
|
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||||
|
const url = `${baseUrl}${cleanEndpoint}`;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Freebit API request (attempt ${attempt}/${config.retryAttempts})`, {
|
||||||
|
url,
|
||||||
|
payload: this.sanitizePayload(requestPayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), config.timeout);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: `json=${JSON.stringify(requestPayload)}`,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => "Unable to read response body");
|
||||||
|
this.logger.error(`Freebit API HTTP error`, {
|
||||||
|
url,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
responseBody: errorText,
|
||||||
|
attempt,
|
||||||
|
payload: this.sanitizePayload(requestPayload),
|
||||||
|
});
|
||||||
|
throw new FreebitError(
|
||||||
|
`HTTP ${response.status}: ${response.statusText}`,
|
||||||
|
response.status.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = (await response.json()) as TResponse;
|
||||||
|
|
||||||
|
const resultCode = this.normalizeResultCode(responseData.resultCode);
|
||||||
|
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
||||||
|
|
||||||
|
if (resultCode && resultCode !== "100") {
|
||||||
|
this.logger.warn("Freebit API returned error response", {
|
||||||
|
url,
|
||||||
|
resultCode,
|
||||||
|
statusCode,
|
||||||
|
statusMessage: responseData.status?.message,
|
||||||
|
fullResponse: responseData,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new FreebitError(
|
||||||
|
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
||||||
|
resultCode,
|
||||||
|
statusCode,
|
||||||
|
responseData.status?.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug("Freebit API request successful", {
|
||||||
|
url,
|
||||||
|
resultCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
return responseData;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof FreebitError) {
|
||||||
|
if (error.isAuthError() && attempt === 1) {
|
||||||
|
this.logger.warn("Auth error detected, clearing cache and retrying");
|
||||||
|
this.authService.clearAuthCache();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!error.isRetryable() || attempt === config.retryAttempts) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt === config.retryAttempts) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error(`Freebit API request failed after ${config.retryAttempts} attempts`, {
|
||||||
|
url,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
throw new FreebitError(`Request failed: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
|
||||||
|
this.logger.warn(`Freebit API request failed, retrying in ${delay}ms`, {
|
||||||
|
url,
|
||||||
|
attempt,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FreebitError("Request failed after all retry attempts");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make an authenticated JSON request to Freebit API (for PA05-41)
|
||||||
|
*/
|
||||||
|
async makeAuthenticatedJsonRequest<
|
||||||
|
TResponse extends FreebitResponseBase,
|
||||||
|
TPayload extends object,
|
||||||
|
>(endpoint: string, payload: TPayload): Promise<TResponse> {
|
||||||
|
const config = this.authService.getConfig();
|
||||||
|
// Ensure proper URL construction - remove double slashes
|
||||||
|
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
||||||
|
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||||
|
const url = `${baseUrl}${cleanEndpoint}`;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= config.retryAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Freebit JSON API request (attempt ${attempt}/${config.retryAttempts})`, {
|
||||||
|
url,
|
||||||
|
payload: this.sanitizePayload(payload as Record<string, unknown>),
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), config.timeout);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new FreebitError(
|
||||||
|
`HTTP ${response.status}: ${response.statusText}`,
|
||||||
|
response.status.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = (await response.json()) as TResponse;
|
||||||
|
|
||||||
|
const resultCode = this.normalizeResultCode(responseData.resultCode);
|
||||||
|
const statusCode = this.normalizeResultCode(responseData.status?.statusCode);
|
||||||
|
|
||||||
|
if (resultCode && resultCode !== "100") {
|
||||||
|
this.logger.error(`Freebit API returned error result code`, {
|
||||||
|
url,
|
||||||
|
resultCode,
|
||||||
|
statusCode,
|
||||||
|
message: responseData.status?.message,
|
||||||
|
responseData: this.sanitizePayload(responseData as unknown as Record<string, unknown>),
|
||||||
|
attempt,
|
||||||
|
});
|
||||||
|
throw new FreebitError(
|
||||||
|
`API Error: ${responseData.status?.message || "Unknown error"}`,
|
||||||
|
resultCode,
|
||||||
|
statusCode,
|
||||||
|
responseData.status?.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug("Freebit JSON API request successful", {
|
||||||
|
url,
|
||||||
|
resultCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
return responseData;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof FreebitError) {
|
||||||
|
if (error.isAuthError() && attempt === 1) {
|
||||||
|
this.logger.warn("Auth error detected, clearing cache and retrying");
|
||||||
|
this.authService.clearAuthCache();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!error.isRetryable() || attempt === config.retryAttempts) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt === config.retryAttempts) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error(
|
||||||
|
`Freebit JSON API request failed after ${config.retryAttempts} attempts`,
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
error: message,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
throw new FreebitError(`Request failed: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
|
||||||
|
this.logger.warn(`Freebit JSON API request failed, retrying in ${delay}ms`, {
|
||||||
|
url,
|
||||||
|
attempt,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FreebitError("Request failed after all retry attempts");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a simple request without authentication (for health checks)
|
||||||
|
*/
|
||||||
|
async makeSimpleRequest(endpoint: string): Promise<boolean> {
|
||||||
|
const config = this.authService.getConfig();
|
||||||
|
// Ensure proper URL construction - remove double slashes
|
||||||
|
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
||||||
|
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||||
|
const url = `${baseUrl}${cleanEndpoint}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), config.timeout);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug("Simple request failed", {
|
||||||
|
url,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize payload for logging (remove sensitive data)
|
||||||
|
*/
|
||||||
|
private sanitizePayload(payload: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const sanitized = { ...payload };
|
||||||
|
|
||||||
|
// Remove sensitive fields
|
||||||
|
const sensitiveFields = ["authKey", "oemKey", "password", "secret"];
|
||||||
|
for (const field of sensitiveFields) {
|
||||||
|
if (sanitized[field]) {
|
||||||
|
sanitized[field] = "[REDACTED]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeResultCode(code?: string | number | null): string | undefined {
|
||||||
|
if (code === undefined || code === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = String(code).trim();
|
||||||
|
return normalized.length > 0 ? normalized : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Custom error class for Freebit API errors
|
||||||
|
*/
|
||||||
|
export class FreebitError extends Error {
|
||||||
|
public readonly resultCode?: string | number;
|
||||||
|
public readonly statusCode?: string | number;
|
||||||
|
public readonly statusMessage?: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
resultCode?: string | number,
|
||||||
|
statusCode?: string | number,
|
||||||
|
statusMessage?: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "FreebitError";
|
||||||
|
this.resultCode = resultCode;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.statusMessage = statusMessage;
|
||||||
|
|
||||||
|
// Maintains proper stack trace for where our error was thrown (only available on V8)
|
||||||
|
if (Error.captureStackTrace) {
|
||||||
|
Error.captureStackTrace(this, FreebitError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error indicates authentication failure
|
||||||
|
*/
|
||||||
|
isAuthError(): boolean {
|
||||||
|
return (
|
||||||
|
this.resultCode === "401" ||
|
||||||
|
this.statusCode === "401" ||
|
||||||
|
this.message.toLowerCase().includes("authentication") ||
|
||||||
|
this.message.toLowerCase().includes("unauthorized")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error indicates rate limiting
|
||||||
|
*/
|
||||||
|
isRateLimitError(): boolean {
|
||||||
|
return (
|
||||||
|
this.resultCode === "429" ||
|
||||||
|
this.statusCode === "429" ||
|
||||||
|
this.message.toLowerCase().includes("rate limit") ||
|
||||||
|
this.message.toLowerCase().includes("too many requests")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is retryable
|
||||||
|
*/
|
||||||
|
isRetryable(): boolean {
|
||||||
|
const retryableCodes = ["500", "502", "503", "504", "408", "429"];
|
||||||
|
return (
|
||||||
|
retryableCodes.includes(String(this.resultCode)) ||
|
||||||
|
retryableCodes.includes(String(this.statusCode)) ||
|
||||||
|
this.message.toLowerCase().includes("timeout") ||
|
||||||
|
this.message.toLowerCase().includes("network")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user-friendly error message
|
||||||
|
*/
|
||||||
|
getUserFriendlyMessage(): string {
|
||||||
|
if (this.isAuthError()) {
|
||||||
|
return "SIM service is temporarily unavailable. Please try again later.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isRateLimitError()) {
|
||||||
|
return "Service is busy. Please wait a moment and try again.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.message.toLowerCase().includes("account not found")) {
|
||||||
|
return "SIM account not found. Please contact support to verify your SIM configuration.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.message.toLowerCase().includes("timeout")) {
|
||||||
|
return "SIM service request timed out. Please try again.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific error codes
|
||||||
|
if (this.resultCode === "215" || this.statusCode === "215") {
|
||||||
|
return "Plan change failed. This may be due to: (1) Account has existing scheduled operations, (2) Invalid plan code for this account, (3) Account restrictions. Please check the Freebit Partner Tools for account status or contact support.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.resultCode === "381" || this.statusCode === "381") {
|
||||||
|
return "Network type change rejected. The current plan does not allow switching to the requested contract line. Adjust the plan first or contact support.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.resultCode === "382" || this.statusCode === "382") {
|
||||||
|
return "Network type change rejected because the contract line is not eligible for modification at this time. Please verify the SIM's status in Freebit before retrying.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "SIM operation failed. Please try again or contact support.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,242 @@
|
|||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import type {
|
||||||
|
FreebitAccountDetailsResponse,
|
||||||
|
FreebitTrafficInfoResponse,
|
||||||
|
FreebitQuotaHistoryResponse,
|
||||||
|
SimDetails,
|
||||||
|
SimUsage,
|
||||||
|
SimTopUpHistory,
|
||||||
|
} from "../interfaces/freebit.types";
|
||||||
|
import type { SimVoiceOptionsService } from "@bff/modules/subscriptions/sim-management/services/sim-voice-options.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FreebitMapperService {
|
||||||
|
constructor(
|
||||||
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
|
@Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: SimVoiceOptionsService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private parseOptionFlag(value: unknown, defaultValue: boolean = false): boolean {
|
||||||
|
// If value is undefined or null, return the default
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value === 10 || value === 1;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === "on" || normalized === "true") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (normalized === "off" || normalized === "false") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const numeric = Number(normalized);
|
||||||
|
if (!Number.isNaN(numeric)) {
|
||||||
|
return numeric === 10 || numeric === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map SIM status from Freebit API to domain status
|
||||||
|
*/
|
||||||
|
mapSimStatus(status: string): "active" | "suspended" | "cancelled" | "pending" {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return "active";
|
||||||
|
case "suspended":
|
||||||
|
return "suspended";
|
||||||
|
case "temporary":
|
||||||
|
case "waiting":
|
||||||
|
return "pending";
|
||||||
|
case "obsolete":
|
||||||
|
return "cancelled";
|
||||||
|
default:
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Freebit account details response to SimDetails
|
||||||
|
*/
|
||||||
|
async mapToSimDetails(response: FreebitAccountDetailsResponse): Promise<SimDetails> {
|
||||||
|
const account = response.responseDatas[0];
|
||||||
|
if (!account) {
|
||||||
|
throw new Error("No account data in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
let simType: "standard" | "nano" | "micro" | "esim" = "standard";
|
||||||
|
if (account.eid) {
|
||||||
|
simType = "esim";
|
||||||
|
} else if (account.simSize) {
|
||||||
|
simType = account.simSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get voice options from database first
|
||||||
|
let voiceMailEnabled = true;
|
||||||
|
let callWaitingEnabled = true;
|
||||||
|
let internationalRoamingEnabled = true;
|
||||||
|
let networkType = String(account.networkType ?? account.contractLine ?? "4G");
|
||||||
|
|
||||||
|
if (this.voiceOptionsService) {
|
||||||
|
try {
|
||||||
|
const storedOptions = await this.voiceOptionsService.getVoiceOptions(
|
||||||
|
String(account.account ?? "")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (storedOptions) {
|
||||||
|
voiceMailEnabled = storedOptions.voiceMailEnabled;
|
||||||
|
callWaitingEnabled = storedOptions.callWaitingEnabled;
|
||||||
|
internationalRoamingEnabled = storedOptions.internationalRoamingEnabled;
|
||||||
|
networkType = storedOptions.networkType;
|
||||||
|
|
||||||
|
this.logger.debug("[FreebitMapper] Loaded voice options from database", {
|
||||||
|
account: account.account,
|
||||||
|
options: storedOptions,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No stored options, check API response
|
||||||
|
voiceMailEnabled = this.parseOptionFlag(account.voicemail ?? account.voiceMail, true);
|
||||||
|
callWaitingEnabled = this.parseOptionFlag(
|
||||||
|
account.callwaiting ?? account.callWaiting,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
internationalRoamingEnabled = this.parseOptionFlag(
|
||||||
|
account.worldwing ?? account.worldWing,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
"[FreebitMapper] No stored options found, using defaults or API values",
|
||||||
|
{
|
||||||
|
account: account.account,
|
||||||
|
voiceMailEnabled,
|
||||||
|
callWaitingEnabled,
|
||||||
|
internationalRoamingEnabled,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn("[FreebitMapper] Failed to load voice options from database", {
|
||||||
|
account: account.account,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
account: String(account.account ?? ""),
|
||||||
|
status: this.mapSimStatus(String(account.state ?? account.status ?? "pending")),
|
||||||
|
planCode: String(account.planCode ?? ""),
|
||||||
|
planName: String(account.planName ?? ""),
|
||||||
|
simType,
|
||||||
|
iccid: String(account.iccid ?? ""),
|
||||||
|
eid: String(account.eid ?? ""),
|
||||||
|
msisdn: String(account.msisdn ?? account.account ?? ""),
|
||||||
|
imsi: String(account.imsi ?? ""),
|
||||||
|
remainingQuotaMb: Number(account.remainingQuotaMb ?? account.quota ?? 0),
|
||||||
|
remainingQuotaKb: Number(account.remainingQuotaKb ?? 0),
|
||||||
|
voiceMailEnabled,
|
||||||
|
callWaitingEnabled,
|
||||||
|
internationalRoamingEnabled,
|
||||||
|
networkType,
|
||||||
|
activatedAt: account.startDate ? String(account.startDate) : undefined,
|
||||||
|
expiresAt: account.async ? String(account.async.date) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Freebit traffic info response to SimUsage
|
||||||
|
*/
|
||||||
|
mapToSimUsage(response: FreebitTrafficInfoResponse): SimUsage {
|
||||||
|
if (!response.traffic) {
|
||||||
|
throw new Error("No traffic data in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
const todayUsageKb = parseInt(response.traffic.today, 10) || 0;
|
||||||
|
const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({
|
||||||
|
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
||||||
|
usageKb: parseInt(usage, 10) || 0,
|
||||||
|
usageMb: Math.round(((parseInt(usage, 10) || 0) / 1024) * 100) / 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
account: String(response.account ?? ""),
|
||||||
|
todayUsageMb: Math.round((todayUsageKb / 1024) * 100) / 100,
|
||||||
|
todayUsageKb,
|
||||||
|
recentDaysUsage: recentDaysData,
|
||||||
|
isBlacklisted: response.traffic.blackList === "10",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Freebit quota history response to SimTopUpHistory
|
||||||
|
*/
|
||||||
|
mapToSimTopUpHistory(response: FreebitQuotaHistoryResponse, account: string): SimTopUpHistory {
|
||||||
|
if (!response.quotaHistory) {
|
||||||
|
throw new Error("No history data in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
totalAdditions: Number(response.total) || 0,
|
||||||
|
additionCount: Number(response.count) || 0,
|
||||||
|
history: response.quotaHistory.map(item => ({
|
||||||
|
quotaKb: parseInt(item.quota, 10),
|
||||||
|
quotaMb: Math.round((parseInt(item.quota, 10) / 1024) * 100) / 100,
|
||||||
|
addedDate: item.date,
|
||||||
|
expiryDate: item.expire,
|
||||||
|
campaignCode: item.quotaCode,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize account identifier (remove formatting)
|
||||||
|
*/
|
||||||
|
normalizeAccount(account: string): string {
|
||||||
|
return account.replace(/[-\s()]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate account format
|
||||||
|
*/
|
||||||
|
validateAccount(account: string): boolean {
|
||||||
|
const normalized = this.normalizeAccount(account);
|
||||||
|
// Basic validation - should be digits, typically 10-11 digits for Japanese phone numbers
|
||||||
|
return /^\d{10,11}$/.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date for Freebit API (YYYYMMDD)
|
||||||
|
*/
|
||||||
|
formatDateForApi(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}${month}${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse date from Freebit API format (YYYYMMDD)
|
||||||
|
*/
|
||||||
|
parseDateFromApi(dateString: string): Date | null {
|
||||||
|
if (!/^\d{8}$/.test(dateString)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = parseInt(dateString.substring(0, 4), 10);
|
||||||
|
const month = parseInt(dateString.substring(4, 6), 10) - 1; // Month is 0-indexed
|
||||||
|
const day = parseInt(dateString.substring(6, 8), 10);
|
||||||
|
|
||||||
|
return new Date(year, month, day);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,944 @@
|
|||||||
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import { FreebitClientService } from "./freebit-client.service";
|
||||||
|
import { FreebitMapperService } from "./freebit-mapper.service";
|
||||||
|
import { FreebitAuthService } from "./freebit-auth.service";
|
||||||
|
import type {
|
||||||
|
FreebitAccountDetailsRequest,
|
||||||
|
FreebitAccountDetailsResponse,
|
||||||
|
FreebitTrafficInfoRequest,
|
||||||
|
FreebitTrafficInfoResponse,
|
||||||
|
FreebitTopUpRequest,
|
||||||
|
FreebitTopUpResponse,
|
||||||
|
FreebitQuotaHistoryRequest,
|
||||||
|
FreebitQuotaHistoryResponse,
|
||||||
|
FreebitPlanChangeRequest,
|
||||||
|
FreebitPlanChangeResponse,
|
||||||
|
FreebitContractLineChangeRequest,
|
||||||
|
FreebitContractLineChangeResponse,
|
||||||
|
FreebitAddSpecRequest,
|
||||||
|
FreebitAddSpecResponse,
|
||||||
|
FreebitVoiceOptionSettings,
|
||||||
|
FreebitVoiceOptionRequest,
|
||||||
|
FreebitVoiceOptionResponse,
|
||||||
|
FreebitCancelPlanRequest,
|
||||||
|
FreebitCancelPlanResponse,
|
||||||
|
FreebitEsimReissueRequest,
|
||||||
|
FreebitEsimReissueResponse,
|
||||||
|
FreebitEsimAddAccountRequest,
|
||||||
|
FreebitEsimAddAccountResponse,
|
||||||
|
FreebitEsimAccountActivationRequest,
|
||||||
|
FreebitEsimAccountActivationResponse,
|
||||||
|
SimDetails,
|
||||||
|
SimUsage,
|
||||||
|
SimTopUpHistory,
|
||||||
|
} from "../interfaces/freebit.types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FreebitOperationsService {
|
||||||
|
constructor(
|
||||||
|
private readonly client: FreebitClientService,
|
||||||
|
private readonly mapper: FreebitMapperService,
|
||||||
|
private readonly auth: FreebitAuthService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
|
@Inject("SimVoiceOptionsService") private readonly voiceOptionsService?: any
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private readonly operationTimestamps = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
voice?: number;
|
||||||
|
network?: number;
|
||||||
|
plan?: number;
|
||||||
|
cancellation?: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
private getOperationWindow(account: string) {
|
||||||
|
if (!this.operationTimestamps.has(account)) {
|
||||||
|
this.operationTimestamps.set(account, {});
|
||||||
|
}
|
||||||
|
return this.operationTimestamps.get(account)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertOperationSpacing(account: string, op: "voice" | "network" | "plan") {
|
||||||
|
const windowMs = 30 * 60 * 1000;
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = this.getOperationWindow(account);
|
||||||
|
|
||||||
|
if (op === "voice") {
|
||||||
|
if (entry.plan && now - entry.plan < windowMs) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Voice feature changes must be at least 30 minutes apart from plan changes. Please try again later."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.network && now - entry.network < windowMs) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Voice feature changes must be at least 30 minutes apart from network type updates. Please try again later."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op === "network") {
|
||||||
|
if (entry.voice && now - entry.voice < windowMs) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Network type updates must be requested 30 minutes after voice option changes. Please try again later."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.plan && now - entry.plan < windowMs) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Network type updates must be requested at least 30 minutes apart from plan changes. Please try again later."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op === "plan") {
|
||||||
|
if (entry.voice && now - entry.voice < windowMs) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Plan changes must be requested 30 minutes after voice option changes. Please try again later."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.network && now - entry.network < windowMs) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Plan changes must be requested 30 minutes after network type updates. Please try again later."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (entry.cancellation) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"This subscription has a pending cancellation. Plan changes are no longer permitted."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stampOperation(account: string, op: "voice" | "network" | "plan" | "cancellation") {
|
||||||
|
const entry = this.getOperationWindow(account);
|
||||||
|
entry[op] = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM account details with endpoint fallback
|
||||||
|
*/
|
||||||
|
async getSimDetails(account: string): Promise<SimDetails> {
|
||||||
|
try {
|
||||||
|
const request: Omit<FreebitAccountDetailsRequest, "authKey"> = {
|
||||||
|
version: "2",
|
||||||
|
requestDatas: [{ kind: "MVNO", account }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = this.auth.getConfig();
|
||||||
|
const configured = 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: FreebitAccountDetailsResponse | undefined;
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (const ep of candidates) {
|
||||||
|
try {
|
||||||
|
if (ep !== candidates[0]) {
|
||||||
|
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
|
||||||
|
}
|
||||||
|
response = await this.client.makeAuthenticatedRequest<
|
||||||
|
FreebitAccountDetailsResponse,
|
||||||
|
typeof request
|
||||||
|
>(ep, request);
|
||||||
|
break;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
lastError = err;
|
||||||
|
if (getErrorMessage(err).includes("HTTP 404")) {
|
||||||
|
continue; // try next endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
if (lastError instanceof Error) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
throw new Error("Failed to get SIM details from any endpoint");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.mapper.mapToSimDetails(response);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to get SIM details for account ${account}`, {
|
||||||
|
account,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
throw new BadRequestException(`Failed to get SIM details: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM usage/traffic information
|
||||||
|
*/
|
||||||
|
async getSimUsage(account: string): Promise<SimUsage> {
|
||||||
|
try {
|
||||||
|
const request: Omit<FreebitTrafficInfoRequest, "authKey"> = { account };
|
||||||
|
|
||||||
|
const response = await this.client.makeAuthenticatedRequest<
|
||||||
|
FreebitTrafficInfoResponse,
|
||||||
|
typeof request
|
||||||
|
>("/mvno/getTrafficInfo/", request);
|
||||||
|
|
||||||
|
return this.mapper.mapToSimUsage(response);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to get SIM usage for account ${account}`, {
|
||||||
|
account,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
throw new BadRequestException(`Failed to get SIM usage: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top up SIM data quota
|
||||||
|
*/
|
||||||
|
async topUpSim(
|
||||||
|
account: string,
|
||||||
|
quotaMb: number,
|
||||||
|
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const quotaKb = Math.round(quotaMb * 1024);
|
||||||
|
const baseRequest: Omit<FreebitTopUpRequest, "authKey"> = {
|
||||||
|
account,
|
||||||
|
quota: quotaKb,
|
||||||
|
quotaCode: options.campaignCode,
|
||||||
|
expire: options.expiryDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduled = !!options.scheduledAt;
|
||||||
|
const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/";
|
||||||
|
const request = scheduled ? { ...baseRequest, runTime: options.scheduledAt } : baseRequest;
|
||||||
|
|
||||||
|
await this.client.makeAuthenticatedRequest<FreebitTopUpResponse, typeof request>(
|
||||||
|
endpoint,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully topped up ${quotaMb}MB for account ${account}`, {
|
||||||
|
account,
|
||||||
|
endpoint,
|
||||||
|
quotaMb,
|
||||||
|
quotaKb,
|
||||||
|
scheduled,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to top up SIM for account ${account}`, {
|
||||||
|
account,
|
||||||
|
quotaMb,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
throw new BadRequestException(`Failed to top up SIM: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM top-up history
|
||||||
|
*/
|
||||||
|
async getSimTopUpHistory(
|
||||||
|
account: string,
|
||||||
|
fromDate: string,
|
||||||
|
toDate: string
|
||||||
|
): Promise<SimTopUpHistory> {
|
||||||
|
try {
|
||||||
|
const request: Omit<FreebitQuotaHistoryRequest, "authKey"> = {
|
||||||
|
account,
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await this.client.makeAuthenticatedRequest<
|
||||||
|
FreebitQuotaHistoryResponse,
|
||||||
|
typeof request
|
||||||
|
>("/mvno/getQuotaHistory/", request);
|
||||||
|
|
||||||
|
return this.mapper.mapToSimTopUpHistory(response, account);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to get SIM top-up history for account ${account}`, {
|
||||||
|
account,
|
||||||
|
fromDate,
|
||||||
|
toDate,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
throw new BadRequestException(`Failed to get SIM top-up history: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change SIM plan
|
||||||
|
* Uses PA05-21 changePlan endpoint
|
||||||
|
*
|
||||||
|
* IMPORTANT CONSTRAINTS:
|
||||||
|
* - Requires runTime parameter set to 1st of following month (YYYYMMDDHHmm format)
|
||||||
|
* - Does NOT take effect immediately (unlike PA05-06 and PA05-38)
|
||||||
|
* - Must be done AFTER PA05-06 and PA05-38 (with 30-minute gaps)
|
||||||
|
* - Cannot coexist with PA02-04 (cancellation) - plan changes will cancel the cancellation
|
||||||
|
* - Must run 30 minutes apart from PA05-06 and PA05-38
|
||||||
|
*/
|
||||||
|
async changeSimPlan(
|
||||||
|
account: string,
|
||||||
|
newPlanCode: string,
|
||||||
|
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
||||||
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||||
|
try {
|
||||||
|
this.assertOperationSpacing(account, "plan");
|
||||||
|
// First, get current SIM details to log for debugging
|
||||||
|
let currentPlanCode: string | undefined;
|
||||||
|
try {
|
||||||
|
const simDetails = await this.getSimDetails(account);
|
||||||
|
currentPlanCode = simDetails.planCode;
|
||||||
|
this.logger.log(`Current SIM plan details before change`, {
|
||||||
|
account,
|
||||||
|
currentPlanCode: simDetails.planCode,
|
||||||
|
status: simDetails.status,
|
||||||
|
simType: simDetails.simType,
|
||||||
|
});
|
||||||
|
} catch (detailsError) {
|
||||||
|
this.logger.warn(`Could not fetch current SIM details`, {
|
||||||
|
account,
|
||||||
|
error: getErrorMessage(detailsError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PA05-21 requires runTime parameter in YYYYMMDD format (8 digits, date only)
|
||||||
|
// If not provided, default to 1st of next month
|
||||||
|
let runTime = options.scheduledAt || undefined;
|
||||||
|
if (!runTime) {
|
||||||
|
const nextMonth = new Date();
|
||||||
|
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||||
|
nextMonth.setDate(1);
|
||||||
|
const year = nextMonth.getFullYear();
|
||||||
|
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = "01";
|
||||||
|
runTime = `${year}${month}${day}`;
|
||||||
|
this.logger.log(`No scheduledAt provided, defaulting to 1st of next month: ${runTime}`, {
|
||||||
|
account,
|
||||||
|
runTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: Omit<FreebitPlanChangeRequest, "authKey"> = {
|
||||||
|
account,
|
||||||
|
planCode: newPlanCode, // Use camelCase as required by Freebit API
|
||||||
|
runTime: runTime, // Always include runTime for PA05-21
|
||||||
|
// Only include globalip flag when explicitly requested
|
||||||
|
...(options.assignGlobalIp === true ? { globalip: "1" } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(`Attempting to change SIM plan via PA05-21`, {
|
||||||
|
account,
|
||||||
|
currentPlanCode,
|
||||||
|
newPlanCode,
|
||||||
|
planCode: newPlanCode,
|
||||||
|
globalip: request.globalip,
|
||||||
|
runTime: request.runTime,
|
||||||
|
scheduledAt: options.scheduledAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.client.makeAuthenticatedRequest<
|
||||||
|
FreebitPlanChangeResponse,
|
||||||
|
typeof request
|
||||||
|
>("/mvno/changePlan/", request);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully changed plan for account ${account} to ${newPlanCode}`, {
|
||||||
|
account,
|
||||||
|
newPlanCode,
|
||||||
|
assignGlobalIp: options.assignGlobalIp,
|
||||||
|
scheduled: !!options.scheduledAt,
|
||||||
|
response: {
|
||||||
|
resultCode: response.resultCode,
|
||||||
|
statusCode: response.status?.statusCode,
|
||||||
|
message: response.status?.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.stampOperation(account, "plan");
|
||||||
|
|
||||||
|
return {
|
||||||
|
ipv4: response.ipv4,
|
||||||
|
ipv6: response.ipv6,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
|
||||||
|
// Extract Freebit error details if available
|
||||||
|
const errorDetails: Record<string, unknown> = {
|
||||||
|
account,
|
||||||
|
newPlanCode,
|
||||||
|
planCode: newPlanCode, // Use camelCase
|
||||||
|
globalip: options.assignGlobalIp ? "1" : undefined,
|
||||||
|
runTime: options.scheduledAt,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
errorDetails.errorName = error.name;
|
||||||
|
errorDetails.errorMessage = error.message;
|
||||||
|
|
||||||
|
// Check if it's a FreebitError with additional properties
|
||||||
|
if ('resultCode' in error) {
|
||||||
|
errorDetails.resultCode = error.resultCode;
|
||||||
|
}
|
||||||
|
if ('statusCode' in error) {
|
||||||
|
errorDetails.statusCode = error.statusCode;
|
||||||
|
}
|
||||||
|
if ('statusMessage' in error) {
|
||||||
|
errorDetails.statusMessage = error.statusMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error(`Failed to change SIM plan for account ${account}`, errorDetails);
|
||||||
|
throw new BadRequestException(`Failed to change SIM plan: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update SIM features (voice options and network type)
|
||||||
|
*
|
||||||
|
* IMPORTANT TIMING CONSTRAINTS from Freebit API:
|
||||||
|
* - PA05-06 (voice features): Runs with immediate effect
|
||||||
|
* - PA05-38 (contract line): Runs with immediate effect
|
||||||
|
* - PA05-21 (plan change): Requires runTime parameter, scheduled for 1st of following month
|
||||||
|
* - These must run 30 minutes apart to avoid canceling each other
|
||||||
|
* - PA05-06 and PA05-38 should be done first, then PA05-21 last (since it's scheduled)
|
||||||
|
* - PA05-21 and PA02-04 (cancellation) cannot coexist
|
||||||
|
*/
|
||||||
|
async updateSimFeatures(
|
||||||
|
account: string,
|
||||||
|
features: {
|
||||||
|
voiceMailEnabled?: boolean;
|
||||||
|
callWaitingEnabled?: boolean;
|
||||||
|
internationalRoamingEnabled?: boolean;
|
||||||
|
networkType?: "4G" | "5G";
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const voiceFeatures = {
|
||||||
|
voiceMailEnabled: features.voiceMailEnabled,
|
||||||
|
callWaitingEnabled: features.callWaitingEnabled,
|
||||||
|
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasVoiceFeatures = Object.values(voiceFeatures).some(value => typeof value === "boolean");
|
||||||
|
const hasNetworkTypeChange = typeof features.networkType === "string";
|
||||||
|
|
||||||
|
// Execute in sequence with 30-minute delays as per Freebit API requirements
|
||||||
|
if (hasVoiceFeatures && hasNetworkTypeChange) {
|
||||||
|
// Both voice features and network type change requested
|
||||||
|
this.logger.log(`Updating both voice features and network type with required 30-minute delay`, {
|
||||||
|
account,
|
||||||
|
hasVoiceFeatures,
|
||||||
|
hasNetworkTypeChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: Update voice features immediately (PA05-06)
|
||||||
|
await this.updateVoiceFeatures(account, voiceFeatures);
|
||||||
|
this.logger.log(`Voice features updated, scheduling network type change in 30 minutes`, {
|
||||||
|
account,
|
||||||
|
networkType: features.networkType,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Schedule network type change 30 minutes later (PA05-38)
|
||||||
|
// Note: This uses setTimeout which is not ideal for production
|
||||||
|
// Consider using a job queue like Bull or agenda for production
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await this.updateNetworkType(account, features.networkType!);
|
||||||
|
this.logger.log(`Network type change completed after 30-minute delay`, {
|
||||||
|
account,
|
||||||
|
networkType: features.networkType,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to update network type after 30-minute delay`, {
|
||||||
|
account,
|
||||||
|
networkType: features.networkType,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 30 * 60 * 1000); // 30 minutes
|
||||||
|
|
||||||
|
this.logger.log(`Voice features updated immediately, network type scheduled for 30 minutes`, {
|
||||||
|
account,
|
||||||
|
voiceMailEnabled: features.voiceMailEnabled,
|
||||||
|
callWaitingEnabled: features.callWaitingEnabled,
|
||||||
|
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
||||||
|
networkType: features.networkType,
|
||||||
|
});
|
||||||
|
|
||||||
|
} else if (hasVoiceFeatures) {
|
||||||
|
// Only voice features (PA05-06)
|
||||||
|
await this.updateVoiceFeatures(account, voiceFeatures);
|
||||||
|
this.logger.log(`Voice features updated successfully`, {
|
||||||
|
account,
|
||||||
|
voiceMailEnabled: features.voiceMailEnabled,
|
||||||
|
callWaitingEnabled: features.callWaitingEnabled,
|
||||||
|
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
} else if (hasNetworkTypeChange) {
|
||||||
|
// Only network type change (PA05-38)
|
||||||
|
await this.updateNetworkType(account, features.networkType!);
|
||||||
|
this.logger.log(`Network type updated successfully`, {
|
||||||
|
account,
|
||||||
|
networkType: features.networkType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Successfully updated SIM features for account ${account}`, {
|
||||||
|
account,
|
||||||
|
voiceMailEnabled: features.voiceMailEnabled,
|
||||||
|
callWaitingEnabled: features.callWaitingEnabled,
|
||||||
|
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
||||||
|
networkType: features.networkType,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to update SIM features for account ${account}`, {
|
||||||
|
account,
|
||||||
|
features,
|
||||||
|
error: message,
|
||||||
|
errorStack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
throw new BadRequestException(`Failed to update SIM features: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update voice features (voicemail, call waiting, international roaming)
|
||||||
|
* Uses PA05-06 MVNO Voice Option Change endpoint - runs with immediate effect
|
||||||
|
*
|
||||||
|
* Error codes specific to PA05-06:
|
||||||
|
* - 243: Voice option (list) problem
|
||||||
|
* - 244: Voicemail parameter problem
|
||||||
|
* - 245: Call waiting parameter problem
|
||||||
|
* - 250: WORLD WING parameter problem
|
||||||
|
*/
|
||||||
|
private async updateVoiceFeatures(
|
||||||
|
account: string,
|
||||||
|
features: {
|
||||||
|
voiceMailEnabled?: boolean;
|
||||||
|
callWaitingEnabled?: boolean;
|
||||||
|
internationalRoamingEnabled?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.assertOperationSpacing(account, "voice");
|
||||||
|
|
||||||
|
const buildVoiceOptionPayload = (): Omit<FreebitVoiceOptionRequest, "authKey"> => {
|
||||||
|
const talkOption: FreebitVoiceOptionSettings = {};
|
||||||
|
|
||||||
|
if (typeof features.voiceMailEnabled === "boolean") {
|
||||||
|
talkOption.voiceMail = features.voiceMailEnabled ? "10" : "20";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof features.callWaitingEnabled === "boolean") {
|
||||||
|
talkOption.callWaiting = features.callWaitingEnabled ? "10" : "20";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof features.internationalRoamingEnabled === "boolean") {
|
||||||
|
talkOption.worldWing = features.internationalRoamingEnabled ? "10" : "20";
|
||||||
|
if (features.internationalRoamingEnabled) {
|
||||||
|
talkOption.worldWingCreditLimit = "50000"; // minimum permitted when enabling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(talkOption).length === 0) {
|
||||||
|
throw new BadRequestException("No voice options specified for update");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
userConfirmed: "10",
|
||||||
|
aladinOperated: "10",
|
||||||
|
talkOption,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const voiceOptionPayload = buildVoiceOptionPayload();
|
||||||
|
|
||||||
|
this.logger.debug("Submitting voice option change via /mvno/talkoption/changeOrder/ (PA05-06)", {
|
||||||
|
account,
|
||||||
|
payload: voiceOptionPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.client.makeAuthenticatedRequest<
|
||||||
|
FreebitVoiceOptionResponse,
|
||||||
|
typeof voiceOptionPayload
|
||||||
|
>("/mvno/talkoption/changeOrder/", voiceOptionPayload);
|
||||||
|
|
||||||
|
this.logger.log("Voice option change completed via PA05-06", {
|
||||||
|
account,
|
||||||
|
voiceMailEnabled: features.voiceMailEnabled,
|
||||||
|
callWaitingEnabled: features.callWaitingEnabled,
|
||||||
|
internationalRoamingEnabled: features.internationalRoamingEnabled,
|
||||||
|
});
|
||||||
|
this.stampOperation(account, "voice");
|
||||||
|
|
||||||
|
// Save to database for future retrieval
|
||||||
|
if (this.voiceOptionsService) {
|
||||||
|
try {
|
||||||
|
await this.voiceOptionsService.saveVoiceOptions(account, features);
|
||||||
|
} catch (dbError) {
|
||||||
|
this.logger.warn("Failed to save voice options to database (non-fatal)", {
|
||||||
|
account,
|
||||||
|
error: getErrorMessage(dbError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to update voice features for account ${account}`, {
|
||||||
|
account,
|
||||||
|
features,
|
||||||
|
error: message,
|
||||||
|
errorStack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
throw new BadRequestException(`Failed to update voice features: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update network type (4G/5G)
|
||||||
|
* Uses PA05-38 contract line change - runs with immediate effect
|
||||||
|
* NOTE: Must be called 30 minutes after PA05-06 if both are being updated
|
||||||
|
*/
|
||||||
|
private async updateNetworkType(account: string, networkType: "4G" | "5G"): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.assertOperationSpacing(account, "network");
|
||||||
|
let eid: string | undefined;
|
||||||
|
let productNumber: string | undefined;
|
||||||
|
try {
|
||||||
|
const details = await this.getSimDetails(account);
|
||||||
|
if (details.eid) {
|
||||||
|
eid = details.eid;
|
||||||
|
} else if (details.iccid) {
|
||||||
|
productNumber = details.iccid;
|
||||||
|
}
|
||||||
|
this.logger.debug(`Resolved SIM identifiers for contract line change`, {
|
||||||
|
account,
|
||||||
|
eid,
|
||||||
|
productNumber,
|
||||||
|
currentNetworkType: details.networkType,
|
||||||
|
});
|
||||||
|
if (details.networkType?.toUpperCase() === networkType.toUpperCase()) {
|
||||||
|
this.logger.log(`Network type already ${networkType} for account ${account}; skipping update.`, {
|
||||||
|
account,
|
||||||
|
networkType,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (resolveError) {
|
||||||
|
this.logger.warn(`Unable to resolve SIM identifiers before contract line change`, {
|
||||||
|
account,
|
||||||
|
error: getErrorMessage(resolveError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: Omit<FreebitContractLineChangeRequest, "authKey"> = {
|
||||||
|
account,
|
||||||
|
contractLine: networkType,
|
||||||
|
...(eid ? { eid } : {}),
|
||||||
|
...(productNumber ? { productNumber } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.debug(`Updating network type via PA05-38 for account ${account}`, {
|
||||||
|
account,
|
||||||
|
networkType,
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.client.makeAuthenticatedJsonRequest<
|
||||||
|
FreebitContractLineChangeResponse,
|
||||||
|
typeof request
|
||||||
|
>("/mvno/contractline/change/", request);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully updated network type for account ${account}`, {
|
||||||
|
account,
|
||||||
|
networkType,
|
||||||
|
resultCode: response.resultCode,
|
||||||
|
statusCode: response.status?.statusCode,
|
||||||
|
message: response.status?.message,
|
||||||
|
});
|
||||||
|
this.stampOperation(account, "network");
|
||||||
|
|
||||||
|
// Save to database for future retrieval
|
||||||
|
if (this.voiceOptionsService) {
|
||||||
|
try {
|
||||||
|
await this.voiceOptionsService.saveVoiceOptions(account, { networkType });
|
||||||
|
} catch (dbError) {
|
||||||
|
this.logger.warn("Failed to save network type to database (non-fatal)", {
|
||||||
|
account,
|
||||||
|
error: getErrorMessage(dbError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to update network type for account ${account}`, {
|
||||||
|
account,
|
||||||
|
networkType,
|
||||||
|
error: message,
|
||||||
|
errorStack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
throw new BadRequestException(`Failed to update network type: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel SIM service
|
||||||
|
* Uses PA02-04 cancellation endpoint
|
||||||
|
*
|
||||||
|
* IMPORTANT CONSTRAINTS:
|
||||||
|
* - Must be sent with runDate as 1st of client's cancellation month n+1
|
||||||
|
* (e.g., cancel end of Jan = runDate 20250201)
|
||||||
|
* - After PA02-04 is sent, any subsequent PA05-21 calls will cancel it
|
||||||
|
* - PA05-21 and PA02-04 cannot coexist
|
||||||
|
* - Must prevent clients from making further changes after cancellation is requested
|
||||||
|
*/
|
||||||
|
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
|
||||||
|
account,
|
||||||
|
runTime: scheduledAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(`Cancelling SIM service via PA02-04 for account ${account}`, {
|
||||||
|
account,
|
||||||
|
runTime: scheduledAt,
|
||||||
|
note: "After this, PA05-21 plan changes will cancel the cancellation",
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
|
||||||
|
"/mvno/releasePlan/",
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully cancelled SIM for account ${account}`, {
|
||||||
|
account,
|
||||||
|
runTime: scheduledAt,
|
||||||
|
});
|
||||||
|
this.stampOperation(account, "cancellation");
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to cancel SIM for account ${account}`, {
|
||||||
|
account,
|
||||||
|
scheduledAt,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
throw new BadRequestException(`Failed to cancel SIM: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reissue eSIM profile (simple version)
|
||||||
|
*/
|
||||||
|
async reissueEsimProfile(account: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const request: Omit<FreebitEsimReissueRequest, "authKey"> = {
|
||||||
|
requestDatas: [{ kind: "MVNO", account }],
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.client.makeAuthenticatedRequest<FreebitEsimReissueResponse, typeof request>(
|
||||||
|
"/mvno/reissueEsim/",
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully reissued eSIM profile for account ${account}`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to reissue eSIM profile for account ${account}`, {
|
||||||
|
account,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
throw new BadRequestException(`Failed to reissue eSIM profile: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reissue eSIM profile with enhanced options
|
||||||
|
*/
|
||||||
|
async reissueEsimProfileEnhanced(
|
||||||
|
account: string,
|
||||||
|
newEid: string,
|
||||||
|
options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const request: Omit<FreebitEsimAddAccountRequest, "authKey"> = {
|
||||||
|
aladinOperated: "20",
|
||||||
|
account,
|
||||||
|
eid: newEid,
|
||||||
|
addKind: "R",
|
||||||
|
planCode: options.planCode,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.client.makeAuthenticatedRequest<FreebitEsimAddAccountResponse, typeof request>(
|
||||||
|
"/mvno/esim/addAcnt/",
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, {
|
||||||
|
account,
|
||||||
|
newEid,
|
||||||
|
oldProductNumber: options.oldProductNumber,
|
||||||
|
oldEid: options.oldEid,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
|
||||||
|
account,
|
||||||
|
newEid,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
throw new BadRequestException(`Failed to reissue eSIM profile: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate new eSIM account using PA05-41 (addAcct)
|
||||||
|
*/
|
||||||
|
async activateEsimAccountNew(params: {
|
||||||
|
account: string;
|
||||||
|
eid: string;
|
||||||
|
planCode?: string;
|
||||||
|
contractLine?: "4G" | "5G";
|
||||||
|
aladinOperated?: "10" | "20";
|
||||||
|
shipDate?: string;
|
||||||
|
addKind?: "N" | "M" | "R"; // N:新規, M:MNP転入, R:再発行
|
||||||
|
simKind?: "E0" | "E2" | "E3"; // E0:音声あり, E2:SMSなし, E3:SMSあり (Required except when addKind='R')
|
||||||
|
repAccount?: string; // 代表番号
|
||||||
|
deliveryCode?: string; // 顧客コード
|
||||||
|
globalIp?: "10" | "20"; // 10:なし, 20:あり
|
||||||
|
mnp?: { reserveNumber: string; reserveExpireDate?: string };
|
||||||
|
identity?: {
|
||||||
|
firstnameKanji?: string;
|
||||||
|
lastnameKanji?: string;
|
||||||
|
firstnameZenKana?: string;
|
||||||
|
lastnameZenKana?: string;
|
||||||
|
gender?: string;
|
||||||
|
birthday?: string;
|
||||||
|
};
|
||||||
|
}): Promise<void> {
|
||||||
|
const {
|
||||||
|
account,
|
||||||
|
eid,
|
||||||
|
planCode,
|
||||||
|
contractLine,
|
||||||
|
aladinOperated = "10",
|
||||||
|
shipDate,
|
||||||
|
addKind,
|
||||||
|
simKind,
|
||||||
|
repAccount,
|
||||||
|
deliveryCode,
|
||||||
|
globalIp,
|
||||||
|
mnp,
|
||||||
|
identity,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
if (!account || !eid) {
|
||||||
|
throw new BadRequestException("activateEsimAccountNew requires account and eid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalAddKind = addKind || "N";
|
||||||
|
|
||||||
|
// Validate simKind: Required except when addKind is 'R' (reissue)
|
||||||
|
if (finalAddKind !== "R" && !simKind) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"simKind is required for eSIM activation (use 'E0' for voice, 'E3' for SMS, 'E2' for data-only)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: FreebitEsimAccountActivationRequest = {
|
||||||
|
authKey: await this.auth.getAuthKey(),
|
||||||
|
aladinOperated,
|
||||||
|
createType: "new",
|
||||||
|
eid,
|
||||||
|
account,
|
||||||
|
simkind: simKind || "E0", // Default to voice-enabled if not specified
|
||||||
|
addKind: finalAddKind,
|
||||||
|
planCode,
|
||||||
|
contractLine,
|
||||||
|
shipDate,
|
||||||
|
repAccount,
|
||||||
|
deliveryCode,
|
||||||
|
globalIp,
|
||||||
|
...(mnp ? { mnp } : {}),
|
||||||
|
...(identity ? identity : {}),
|
||||||
|
} as FreebitEsimAccountActivationRequest;
|
||||||
|
|
||||||
|
// Use JSON request for PA05-41
|
||||||
|
await this.client.makeAuthenticatedJsonRequest<
|
||||||
|
FreebitEsimAccountActivationResponse,
|
||||||
|
FreebitEsimAccountActivationRequest
|
||||||
|
>("/mvno/esim/addAcct/", payload);
|
||||||
|
|
||||||
|
this.logger.log("Successfully activated new eSIM account via PA05-41", {
|
||||||
|
account,
|
||||||
|
planCode,
|
||||||
|
contractLine,
|
||||||
|
addKind: addKind || "N",
|
||||||
|
scheduled: !!shipDate,
|
||||||
|
mnp: !!mnp,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to activate new eSIM account ${account}`, {
|
||||||
|
account,
|
||||||
|
eid,
|
||||||
|
planCode,
|
||||||
|
addKind,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
throw new BadRequestException(`Failed to activate new eSIM account: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check - test API connectivity
|
||||||
|
*/
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Try a simple endpoint first
|
||||||
|
const simpleCheck = await this.client.makeSimpleRequest("/");
|
||||||
|
if (simpleCheck) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If simple check fails, try authenticated request
|
||||||
|
await this.auth.getAuthKey();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.debug("Freebit health check failed", {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,158 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { FreebitOperationsService } from "./freebit-operations.service";
|
||||||
|
import { FreebitMapperService } from "./freebit-mapper.service";
|
||||||
|
import type { SimDetails, SimUsage, SimTopUpHistory } from "../interfaces/freebit.types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FreebitOrchestratorService {
|
||||||
|
constructor(
|
||||||
|
private readonly operations: FreebitOperationsService,
|
||||||
|
private readonly mapper: FreebitMapperService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM account details
|
||||||
|
*/
|
||||||
|
async getSimDetails(account: string): Promise<SimDetails> {
|
||||||
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
|
return this.operations.getSimDetails(normalizedAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM usage information
|
||||||
|
*/
|
||||||
|
async getSimUsage(account: string): Promise<SimUsage> {
|
||||||
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
|
return this.operations.getSimUsage(normalizedAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top up SIM data quota
|
||||||
|
*/
|
||||||
|
async topUpSim(
|
||||||
|
account: string,
|
||||||
|
quotaMb: number,
|
||||||
|
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
|
return this.operations.topUpSim(normalizedAccount, quotaMb, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM top-up history
|
||||||
|
*/
|
||||||
|
async getSimTopUpHistory(
|
||||||
|
account: string,
|
||||||
|
fromDate: string,
|
||||||
|
toDate: string
|
||||||
|
): Promise<SimTopUpHistory> {
|
||||||
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
|
return this.operations.getSimTopUpHistory(normalizedAccount, fromDate, toDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change SIM plan
|
||||||
|
*/
|
||||||
|
async changeSimPlan(
|
||||||
|
account: string,
|
||||||
|
newPlanCode: string,
|
||||||
|
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
|
||||||
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||||
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
|
return this.operations.changeSimPlan(normalizedAccount, newPlanCode, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update SIM features
|
||||||
|
*/
|
||||||
|
async updateSimFeatures(
|
||||||
|
account: string,
|
||||||
|
features: {
|
||||||
|
voiceMailEnabled?: boolean;
|
||||||
|
callWaitingEnabled?: boolean;
|
||||||
|
internationalRoamingEnabled?: boolean;
|
||||||
|
networkType?: "4G" | "5G";
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
|
return this.operations.updateSimFeatures(normalizedAccount, features);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel SIM service
|
||||||
|
*/
|
||||||
|
async cancelSim(account: string, scheduledAt?: string): Promise<void> {
|
||||||
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
|
return this.operations.cancelSim(normalizedAccount, scheduledAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reissue eSIM profile (simple)
|
||||||
|
*/
|
||||||
|
async reissueEsimProfile(account: string): Promise<void> {
|
||||||
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
|
return this.operations.reissueEsimProfile(normalizedAccount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reissue eSIM profile with enhanced options
|
||||||
|
*/
|
||||||
|
async reissueEsimProfileEnhanced(
|
||||||
|
account: string,
|
||||||
|
newEid: string,
|
||||||
|
options: { oldEid?: string; planCode?: string } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const normalizedAccount = this.mapper.normalizeAccount(account);
|
||||||
|
return this.operations.reissueEsimProfileEnhanced(normalizedAccount, newEid, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate new eSIM account
|
||||||
|
*/
|
||||||
|
async activateEsimAccountNew(params: {
|
||||||
|
account: string;
|
||||||
|
eid: string;
|
||||||
|
planCode?: string;
|
||||||
|
contractLine?: "4G" | "5G";
|
||||||
|
aladinOperated?: "10" | "20";
|
||||||
|
shipDate?: string;
|
||||||
|
addKind?: "N" | "M" | "R";
|
||||||
|
simKind?: "E0" | "E2" | "E3"; // E0:音声あり, E2:SMSなし, E3:SMSあり
|
||||||
|
repAccount?: string;
|
||||||
|
deliveryCode?: string;
|
||||||
|
globalIp?: "10" | "20";
|
||||||
|
mnp?: { reserveNumber: string; reserveExpireDate?: string };
|
||||||
|
identity?: {
|
||||||
|
firstnameKanji?: string;
|
||||||
|
lastnameKanji?: string;
|
||||||
|
firstnameZenKana?: string;
|
||||||
|
lastnameZenKana?: string;
|
||||||
|
gender?: string;
|
||||||
|
birthday?: string;
|
||||||
|
};
|
||||||
|
}): Promise<void> {
|
||||||
|
const normalizedAccount = this.mapper.normalizeAccount(params.account);
|
||||||
|
return this.operations.activateEsimAccountNew({
|
||||||
|
account: normalizedAccount,
|
||||||
|
eid: params.eid,
|
||||||
|
planCode: params.planCode,
|
||||||
|
contractLine: params.contractLine,
|
||||||
|
aladinOperated: params.aladinOperated,
|
||||||
|
shipDate: params.shipDate,
|
||||||
|
addKind: params.addKind,
|
||||||
|
simKind: params.simKind,
|
||||||
|
repAccount: params.repAccount,
|
||||||
|
deliveryCode: params.deliveryCode,
|
||||||
|
globalIp: params.globalIp,
|
||||||
|
mnp: params.mnp,
|
||||||
|
identity: params.identity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check
|
||||||
|
*/
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
return this.operations.healthCheck();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
// Export all Freebit services
|
||||||
|
export { FreebitOrchestratorService } from "./freebit-orchestrator.service";
|
||||||
|
export { FreebitMapperService } from "./freebit-mapper.service";
|
||||||
|
export { FreebitOperationsService } from "./freebit-operations.service";
|
||||||
26
sim-manager-migration/backend/sim-management/index.ts
Normal file
26
sim-manager-migration/backend/sim-management/index.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// Services
|
||||||
|
export { SimOrchestratorService } from "./services/sim-orchestrator.service";
|
||||||
|
export { SimDetailsService } from "./services/sim-details.service";
|
||||||
|
export { SimUsageService } from "./services/sim-usage.service";
|
||||||
|
export { SimTopUpService } from "./services/sim-topup.service";
|
||||||
|
export { SimPlanService } from "./services/sim-plan.service";
|
||||||
|
export { SimCancellationService } from "./services/sim-cancellation.service";
|
||||||
|
export { EsimManagementService } from "./services/esim-management.service";
|
||||||
|
export { SimValidationService } from "./services/sim-validation.service";
|
||||||
|
export { SimNotificationService } from "./services/sim-notification.service";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
SimTopUpRequest,
|
||||||
|
SimPlanChangeRequest,
|
||||||
|
SimCancelRequest,
|
||||||
|
SimTopUpHistoryRequest,
|
||||||
|
SimFeaturesUpdateRequest,
|
||||||
|
} from "./types/sim-requests.types";
|
||||||
|
|
||||||
|
// Interfaces
|
||||||
|
export type {
|
||||||
|
SimValidationResult,
|
||||||
|
SimNotificationContext,
|
||||||
|
SimActionNotification,
|
||||||
|
} from "./interfaces/sim-base.interface";
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
export interface SimValidationResult {
|
||||||
|
account: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimNotificationContext {
|
||||||
|
userId: string;
|
||||||
|
subscriptionId: number;
|
||||||
|
account?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimActionNotification {
|
||||||
|
action: string;
|
||||||
|
status: "SUCCESS" | "ERROR";
|
||||||
|
context: SimNotificationContext;
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||||
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
|
import { SimNotificationService } from "./sim-notification.service";
|
||||||
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EsimManagementService {
|
||||||
|
constructor(
|
||||||
|
private readonly freebitService: FreebitOrchestratorService,
|
||||||
|
private readonly simValidation: SimValidationService,
|
||||||
|
private readonly simNotification: SimNotificationService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reissue eSIM profile
|
||||||
|
*/
|
||||||
|
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
|
// First check if this is actually an eSIM
|
||||||
|
const simDetails = await this.freebitService.getSimDetails(account);
|
||||||
|
if (simDetails.simType !== "esim") {
|
||||||
|
throw new BadRequestException("This operation is only available for eSIM subscriptions");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newEid) {
|
||||||
|
if (!/^\d{32}$/.test(newEid)) {
|
||||||
|
throw new BadRequestException("Invalid EID format. Expected 32 digits.");
|
||||||
|
}
|
||||||
|
await this.freebitService.reissueEsimProfileEnhanced(account, newEid, {
|
||||||
|
oldEid: simDetails.eid,
|
||||||
|
planCode: simDetails.planCode,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.freebitService.reissueEsimProfile(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Successfully reissued eSIM profile for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
oldEid: simDetails.eid,
|
||||||
|
newEid: newEid || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.simNotification.notifySimAction("Reissue eSIM", "SUCCESS", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
oldEid: simDetails.eid,
|
||||||
|
newEid: newEid || undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const sanitizedError = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to reissue eSIM profile for subscription ${subscriptionId}`, {
|
||||||
|
error: sanitizedError,
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
newEid: newEid || undefined,
|
||||||
|
});
|
||||||
|
await this.simNotification.notifySimAction("Reissue eSIM", "ERROR", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
newEid: newEid || undefined,
|
||||||
|
error: sanitizedError,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||||
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
|
import { SimNotificationService } from "./sim-notification.service";
|
||||||
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import type { SimCancelRequest } from "../types/sim-requests.types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimCancellationService {
|
||||||
|
constructor(
|
||||||
|
private readonly freebitService: FreebitOrchestratorService,
|
||||||
|
private readonly simValidation: SimValidationService,
|
||||||
|
private readonly simNotification: SimNotificationService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel SIM service
|
||||||
|
*/
|
||||||
|
async cancelSim(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimCancelRequest = {}
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
|
// Determine run date (PA02-04 requires runDate); default to 1st of next month
|
||||||
|
let runDate = request.scheduledAt;
|
||||||
|
if (runDate && !/^\d{8}$/.test(runDate)) {
|
||||||
|
throw new BadRequestException("Scheduled date must be in YYYYMMDD format");
|
||||||
|
}
|
||||||
|
if (!runDate) {
|
||||||
|
const nextMonth = new Date();
|
||||||
|
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||||
|
nextMonth.setDate(1);
|
||||||
|
const y = nextMonth.getFullYear();
|
||||||
|
const m = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(nextMonth.getDate()).padStart(2, "0");
|
||||||
|
runDate = `${y}${m}${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.freebitService.cancelSim(account, runDate);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
runDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.simNotification.notifySimAction("Cancel SIM", "SUCCESS", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
runDate,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const sanitizedError = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to cancel SIM for subscription ${subscriptionId}`, {
|
||||||
|
error: sanitizedError,
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
});
|
||||||
|
await this.simNotification.notifySimAction("Cancel SIM", "ERROR", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
error: sanitizedError,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||||
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import type { SimDetails } from "@bff/integrations/freebit/interfaces/freebit.types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimDetailsService {
|
||||||
|
constructor(
|
||||||
|
private readonly freebitService: FreebitOrchestratorService,
|
||||||
|
private readonly simValidation: SimValidationService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM details for a subscription
|
||||||
|
*/
|
||||||
|
async getSimDetails(userId: string, subscriptionId: number): Promise<SimDetails> {
|
||||||
|
try {
|
||||||
|
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
|
const simDetails = await this.freebitService.getSimDetails(account);
|
||||||
|
|
||||||
|
this.logger.log(`Retrieved SIM details for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
status: simDetails.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
return simDetails;
|
||||||
|
} catch (error) {
|
||||||
|
const sanitizedError = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to get SIM details for subscription ${subscriptionId}`, {
|
||||||
|
error: sanitizedError,
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM details directly from Freebit without subscription validation
|
||||||
|
* Used for debugging purposes
|
||||||
|
*/
|
||||||
|
async getSimDetailsDirectly(account: string): Promise<SimDetails> {
|
||||||
|
try {
|
||||||
|
this.logger.log(`[DEBUG] Querying Freebit for account: ${account}`);
|
||||||
|
|
||||||
|
const simDetails = await this.freebitService.getSimDetails(account);
|
||||||
|
|
||||||
|
this.logger.log(`[DEBUG] Retrieved SIM details from Freebit`, {
|
||||||
|
account,
|
||||||
|
planCode: simDetails.planCode,
|
||||||
|
planName: simDetails.planName,
|
||||||
|
status: simDetails.status,
|
||||||
|
simType: simDetails.simType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return simDetails;
|
||||||
|
} catch (error) {
|
||||||
|
const sanitizedError = getErrorMessage(error);
|
||||||
|
this.logger.error(`[DEBUG] Failed to get SIM details for account ${account}`, {
|
||||||
|
error: sanitizedError,
|
||||||
|
account,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { EmailService } from "@bff/infra/email/email.service";
|
||||||
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import type { SimNotificationContext } from "../interfaces/sim-base.interface";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimNotificationService {
|
||||||
|
constructor(
|
||||||
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
|
private readonly email: EmailService,
|
||||||
|
private readonly configService: ConfigService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification for SIM actions
|
||||||
|
*/
|
||||||
|
async notifySimAction(
|
||||||
|
action: string,
|
||||||
|
status: "SUCCESS" | "ERROR",
|
||||||
|
context: SimNotificationContext
|
||||||
|
): Promise<void> {
|
||||||
|
const subject = `[SIM ACTION] ${action} - ${status}`;
|
||||||
|
const toAddress = this.configService.get<string>("SIM_ALERT_EMAIL_TO");
|
||||||
|
const fromAddress = this.configService.get<string>("SIM_ALERT_EMAIL_FROM");
|
||||||
|
|
||||||
|
if (!toAddress || !fromAddress) {
|
||||||
|
this.logger.debug("SIM action notification skipped: email config missing", {
|
||||||
|
action,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicContext = this.redactSensitiveFields(context);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lines: string[] = [
|
||||||
|
`Action: ${action}`,
|
||||||
|
`Result: ${status}`,
|
||||||
|
`Timestamp: ${new Date().toISOString()}`,
|
||||||
|
"",
|
||||||
|
"Context:",
|
||||||
|
JSON.stringify(publicContext, null, 2),
|
||||||
|
];
|
||||||
|
await this.email.sendEmail({
|
||||||
|
to: toAddress,
|
||||||
|
from: fromAddress,
|
||||||
|
subject,
|
||||||
|
text: lines.join("\n"),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn("Failed to send SIM action notification email", {
|
||||||
|
action,
|
||||||
|
status,
|
||||||
|
error: getErrorMessage(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact sensitive information from notification context
|
||||||
|
*/
|
||||||
|
private redactSensitiveFields(context: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const sanitized: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(context)) {
|
||||||
|
if (typeof key === "string" && key.toLowerCase().includes("password")) {
|
||||||
|
sanitized[key] = "[REDACTED]";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string" && value.length > 200) {
|
||||||
|
sanitized[key] = `${value.substring(0, 200)}…`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized[key] = value;
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert technical errors to user-friendly messages for SIM operations
|
||||||
|
*/
|
||||||
|
getUserFriendlySimError(technicalError: string): string {
|
||||||
|
if (!technicalError) {
|
||||||
|
return "SIM operation failed. Please try again or contact support.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorLower = technicalError.toLowerCase();
|
||||||
|
|
||||||
|
// Freebit API errors
|
||||||
|
if (errorLower.includes("api error: ng") || errorLower.includes("account not found")) {
|
||||||
|
return "SIM account not found. Please contact support to verify your SIM configuration.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorLower.includes("authentication failed") || errorLower.includes("auth")) {
|
||||||
|
return "SIM service is temporarily unavailable. Please try again later.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorLower.includes("timeout") || errorLower.includes("network")) {
|
||||||
|
return "SIM service request timed out. Please try again.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHMCS errors
|
||||||
|
if (errorLower.includes("invalid permissions") || errorLower.includes("not allowed")) {
|
||||||
|
return "SIM service is temporarily unavailable. Please contact support for assistance.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic errors
|
||||||
|
if (errorLower.includes("failed") || errorLower.includes("error")) {
|
||||||
|
return "SIM operation failed. Please try again or contact support.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback
|
||||||
|
return "SIM operation failed. Please try again or contact support.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,172 @@
|
|||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { SimDetailsService } from "./sim-details.service";
|
||||||
|
import { SimUsageService } from "./sim-usage.service";
|
||||||
|
import { SimTopUpService } from "./sim-topup.service";
|
||||||
|
import { SimPlanService } from "./sim-plan.service";
|
||||||
|
import { SimCancellationService } from "./sim-cancellation.service";
|
||||||
|
import { EsimManagementService } from "./esim-management.service";
|
||||||
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import type {
|
||||||
|
SimDetails,
|
||||||
|
SimUsage,
|
||||||
|
SimTopUpHistory,
|
||||||
|
} from "@bff/integrations/freebit/interfaces/freebit.types";
|
||||||
|
import type {
|
||||||
|
SimTopUpRequest,
|
||||||
|
SimPlanChangeRequest,
|
||||||
|
SimCancelRequest,
|
||||||
|
SimTopUpHistoryRequest,
|
||||||
|
SimFeaturesUpdateRequest,
|
||||||
|
} from "../types/sim-requests.types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimOrchestratorService {
|
||||||
|
constructor(
|
||||||
|
private readonly simDetails: SimDetailsService,
|
||||||
|
private readonly simUsage: SimUsageService,
|
||||||
|
private readonly simTopUp: SimTopUpService,
|
||||||
|
private readonly simPlan: SimPlanService,
|
||||||
|
private readonly simCancellation: SimCancellationService,
|
||||||
|
private readonly esimManagement: EsimManagementService,
|
||||||
|
private readonly simValidation: SimValidationService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM details for a subscription
|
||||||
|
*/
|
||||||
|
async getSimDetails(userId: string, subscriptionId: number): Promise<SimDetails> {
|
||||||
|
return this.simDetails.getSimDetails(userId, subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM data usage for a subscription
|
||||||
|
*/
|
||||||
|
async getSimUsage(userId: string, subscriptionId: number): Promise<SimUsage> {
|
||||||
|
return this.simUsage.getSimUsage(userId, subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top up SIM data quota with payment processing
|
||||||
|
*/
|
||||||
|
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
||||||
|
return this.simTopUp.topUpSim(userId, subscriptionId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM top-up history
|
||||||
|
*/
|
||||||
|
async getSimTopUpHistory(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimTopUpHistoryRequest
|
||||||
|
): Promise<SimTopUpHistory> {
|
||||||
|
return this.simUsage.getSimTopUpHistory(userId, subscriptionId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change SIM plan
|
||||||
|
*/
|
||||||
|
async changeSimPlan(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimPlanChangeRequest
|
||||||
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||||
|
return this.simPlan.changeSimPlan(userId, subscriptionId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update SIM features (voicemail, call waiting, roaming, network type)
|
||||||
|
*/
|
||||||
|
async updateSimFeatures(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimFeaturesUpdateRequest
|
||||||
|
): Promise<void> {
|
||||||
|
return this.simPlan.updateSimFeatures(userId, subscriptionId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel SIM service
|
||||||
|
*/
|
||||||
|
async cancelSim(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimCancelRequest = {}
|
||||||
|
): Promise<void> {
|
||||||
|
return this.simCancellation.cancelSim(userId, subscriptionId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reissue eSIM profile
|
||||||
|
*/
|
||||||
|
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
|
||||||
|
return this.esimManagement.reissueEsimProfile(userId, subscriptionId, newEid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comprehensive SIM information (details + usage combined)
|
||||||
|
*/
|
||||||
|
async getSimInfo(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number
|
||||||
|
): Promise<{
|
||||||
|
details: SimDetails;
|
||||||
|
usage: SimUsage;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const [details, usage] = await Promise.all([
|
||||||
|
this.getSimDetails(userId, subscriptionId),
|
||||||
|
this.getSimUsage(userId, subscriptionId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// If Freebit doesn't return remaining quota, derive it from plan code (e.g., PASI_50G)
|
||||||
|
// by subtracting measured usage (today + recentDays) from the plan cap.
|
||||||
|
const normalizeNumber = (n: number) => (isFinite(n) && n > 0 ? n : 0);
|
||||||
|
const usedMb =
|
||||||
|
normalizeNumber(usage.todayUsageMb) +
|
||||||
|
(usage.recentDaysUsage || []).reduce((sum, d) => sum + normalizeNumber(d.usageMb), 0);
|
||||||
|
|
||||||
|
const planCapMatch = (details.planCode || "").match(/(\d+)\s*G/i);
|
||||||
|
if ((details.remainingQuotaMb === 0 || details.remainingQuotaMb == null) && planCapMatch) {
|
||||||
|
const capGb = parseInt(planCapMatch[1], 10);
|
||||||
|
if (!isNaN(capGb) && capGb > 0) {
|
||||||
|
const capMb = capGb * 1000;
|
||||||
|
const remainingMb = Math.max(capMb - usedMb, 0);
|
||||||
|
details.remainingQuotaMb = Math.round(remainingMb * 100) / 100;
|
||||||
|
details.remainingQuotaKb = Math.round(details.remainingQuotaMb * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { details, usage };
|
||||||
|
} catch (error) {
|
||||||
|
const sanitizedError = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to get comprehensive SIM info for subscription ${subscriptionId}`, {
|
||||||
|
error: sanitizedError,
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug method to check subscription data for SIM services
|
||||||
|
*/
|
||||||
|
async debugSimSubscription(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return this.simValidation.debugSimSubscription(userId, subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug method to query Freebit directly for any account's details
|
||||||
|
* Bypasses subscription validation and queries Freebit directly
|
||||||
|
*/
|
||||||
|
async getSimDetailsDirectly(account: string): Promise<SimDetails> {
|
||||||
|
return this.simDetails.getSimDetailsDirectly(account);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,207 @@
|
|||||||
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||||
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
|
import { SimNotificationService } from "./sim-notification.service";
|
||||||
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import type { SimPlanChangeRequest, SimFeaturesUpdateRequest } from "../types/sim-requests.types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimPlanService {
|
||||||
|
constructor(
|
||||||
|
private readonly freebitService: FreebitOrchestratorService,
|
||||||
|
private readonly simValidation: SimValidationService,
|
||||||
|
private readonly simNotification: SimNotificationService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change SIM plan
|
||||||
|
*/
|
||||||
|
async changeSimPlan(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimPlanChangeRequest
|
||||||
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||||
|
try {
|
||||||
|
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
|
// Validate plan code format - simplified for 5GB, 10GB, 25GB, 50GB plans
|
||||||
|
if (!request.newPlanCode || !["5GB", "10GB", "25GB", "50GB"].includes(request.newPlanCode)) {
|
||||||
|
throw new BadRequestException("Invalid plan code. Must be one of: 5GB, 10GB, 25GB, 50GB");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map simplified plan codes to Freebit plan codes
|
||||||
|
const planCodeMapping: Record<string, string> = {
|
||||||
|
"5GB": "PASI_5G",
|
||||||
|
"10GB": "PASI_10G",
|
||||||
|
"25GB": "PASI_25G",
|
||||||
|
"50GB": "PASI_50G",
|
||||||
|
};
|
||||||
|
|
||||||
|
const freebitPlanCode = planCodeMapping[request.newPlanCode];
|
||||||
|
if (!freebitPlanCode) {
|
||||||
|
throw new BadRequestException(`Unable to map plan code ${request.newPlanCode} to Freebit format`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatically set to 1st of next month
|
||||||
|
const nextMonth = new Date();
|
||||||
|
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||||
|
nextMonth.setDate(1); // Set to 1st of the month
|
||||||
|
|
||||||
|
// Format as YYYYMMDD for Freebit API
|
||||||
|
const year = nextMonth.getFullYear();
|
||||||
|
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(nextMonth.getDate()).padStart(2, "0");
|
||||||
|
const scheduledAt = `${year}${month}${day}`;
|
||||||
|
|
||||||
|
this.logger.log(`Auto-scheduled plan change to 1st of next month: ${scheduledAt}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
newPlanCode: request.newPlanCode,
|
||||||
|
freebitPlanCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// First try immediate change, fallback to scheduled if that fails
|
||||||
|
let result: { ipv4?: string; ipv6?: string };
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await this.freebitService.changeSimPlan(account, freebitPlanCode, {
|
||||||
|
assignGlobalIp: false, // No global IP assignment
|
||||||
|
// Try immediate first
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Immediate plan change successful for account ${account}`, {
|
||||||
|
account,
|
||||||
|
newPlanCode: request.newPlanCode,
|
||||||
|
freebitPlanCode,
|
||||||
|
});
|
||||||
|
} catch (immediateError) {
|
||||||
|
this.logger.warn(`Immediate plan change failed, trying scheduled: ${getErrorMessage(immediateError)}`, {
|
||||||
|
account,
|
||||||
|
newPlanCode: request.newPlanCode,
|
||||||
|
freebitPlanCode,
|
||||||
|
error: getErrorMessage(immediateError),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fallback to scheduled change
|
||||||
|
result = await this.freebitService.changeSimPlan(account, freebitPlanCode, {
|
||||||
|
assignGlobalIp: false, // No global IP assignment
|
||||||
|
scheduledAt: scheduledAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Scheduled plan change successful for account ${account}`, {
|
||||||
|
account,
|
||||||
|
newPlanCode: request.newPlanCode,
|
||||||
|
freebitPlanCode,
|
||||||
|
scheduledAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
newPlanCode: request.newPlanCode,
|
||||||
|
freebitPlanCode,
|
||||||
|
scheduledAt: scheduledAt,
|
||||||
|
assignGlobalIp: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.simNotification.notifySimAction("Change Plan", "SUCCESS", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
newPlanCode: request.newPlanCode,
|
||||||
|
freebitPlanCode,
|
||||||
|
scheduledAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const sanitizedError = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to change SIM plan for subscription ${subscriptionId}`, {
|
||||||
|
error: sanitizedError,
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
newPlanCode: request.newPlanCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.simNotification.notifySimAction("Change Plan", "ERROR", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account: "unknown",
|
||||||
|
newPlanCode: request.newPlanCode,
|
||||||
|
error: sanitizedError,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update SIM features (voicemail, call waiting, roaming, network type)
|
||||||
|
*/
|
||||||
|
async updateSimFeatures(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimFeaturesUpdateRequest
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
|
// Validate network type if provided
|
||||||
|
if (request.networkType && !["4G", "5G"].includes(request.networkType)) {
|
||||||
|
throw new BadRequestException('networkType must be either "4G" or "5G"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the request for debugging
|
||||||
|
this.logger.log(`Updating SIM features for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update all features in one call - the FreebitOperationsService will handle the complexity
|
||||||
|
await this.freebitService.updateSimFeatures(account, {
|
||||||
|
voiceMailEnabled: request.voiceMailEnabled,
|
||||||
|
callWaitingEnabled: request.callWaitingEnabled,
|
||||||
|
internationalRoamingEnabled: request.internationalRoamingEnabled,
|
||||||
|
networkType: request.networkType,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Successfully updated SIM features for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
...request,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.simNotification.notifySimAction("Update Features", "SUCCESS", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
...request,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const sanitizedError = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to update SIM features for subscription ${subscriptionId}`, {
|
||||||
|
error: sanitizedError,
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account: "unknown",
|
||||||
|
...request,
|
||||||
|
errorStack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
await this.simNotification.notifySimAction("Update Features", "ERROR", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
...request,
|
||||||
|
error: sanitizedError,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,280 @@
|
|||||||
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||||
|
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||||
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||||
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
|
import { SimNotificationService } from "./sim-notification.service";
|
||||||
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import type { SimTopUpRequest } from "../types/sim-requests.types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimTopUpService {
|
||||||
|
constructor(
|
||||||
|
private readonly freebitService: FreebitOrchestratorService,
|
||||||
|
private readonly whmcsService: WhmcsService,
|
||||||
|
private readonly mappingsService: MappingsService,
|
||||||
|
private readonly simValidation: SimValidationService,
|
||||||
|
private readonly simNotification: SimNotificationService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top up SIM data quota with payment processing
|
||||||
|
* Pricing: 1GB = 500 JPY
|
||||||
|
*/
|
||||||
|
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
||||||
|
let account: string = "";
|
||||||
|
let costJpy = 0;
|
||||||
|
let currency = request.currency ?? "JPY";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
account = validation.account;
|
||||||
|
|
||||||
|
// Validate quota amount
|
||||||
|
if (request.quotaMb <= 0 || request.quotaMb > 100000) {
|
||||||
|
throw new BadRequestException("Quota must be between 1MB and 100GB");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use amount from request (calculated by frontend)
|
||||||
|
const quotaGb = request.quotaMb / 1000;
|
||||||
|
const units = Math.ceil(quotaGb);
|
||||||
|
const expectedCost = units * 500;
|
||||||
|
|
||||||
|
costJpy = request.amount ?? expectedCost;
|
||||||
|
currency = request.currency ?? "JPY";
|
||||||
|
|
||||||
|
if (request.amount != null && request.amount !== expectedCost) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Amount mismatch: expected ¥${expectedCost} for ${units}GB, got ¥${request.amount}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate quota against Freebit API limits (100MB - 51200MB)
|
||||||
|
if (request.quotaMb < 100 || request.quotaMb > 51200) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Quota must be between 100MB and 51200MB (50GB) for Freebit API compatibility"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get client mapping for WHMCS
|
||||||
|
const mapping = await this.mappingsService.findByUserId(userId);
|
||||||
|
if (!mapping?.whmcsClientId) {
|
||||||
|
throw new BadRequestException("WHMCS client mapping not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const whmcsClientId = mapping.whmcsClientId;
|
||||||
|
|
||||||
|
this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
quotaMb: request.quotaMb,
|
||||||
|
quotaGb: quotaGb.toFixed(2),
|
||||||
|
costJpy,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: Create WHMCS invoice
|
||||||
|
const invoice = await this.whmcsService.createInvoice({
|
||||||
|
clientId: whmcsClientId,
|
||||||
|
description: `SIM Data Top-up: ${units}GB for ${account}`,
|
||||||
|
amount: costJpy,
|
||||||
|
currency,
|
||||||
|
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
|
||||||
|
notes: `Subscription ID: ${subscriptionId}, Phone: ${account}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Created WHMCS invoice ${invoice.id} for SIM top-up`, {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
invoiceNumber: invoice.number,
|
||||||
|
amount: costJpy,
|
||||||
|
currency,
|
||||||
|
subscriptionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Capture payment
|
||||||
|
this.logger.log(`Attempting payment capture`, {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
amount: costJpy,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentResult = await this.whmcsService.capturePayment({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
amount: costJpy,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!paymentResult.success) {
|
||||||
|
this.logger.error(`Payment capture failed for invoice ${invoice.id}`, {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
error: paymentResult.error,
|
||||||
|
subscriptionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel the invoice since payment failed
|
||||||
|
await this.handlePaymentFailure(invoice.id, paymentResult.error || "Unknown payment error");
|
||||||
|
|
||||||
|
throw new BadRequestException(`SIM top-up failed: ${paymentResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Payment captured successfully for invoice ${invoice.id}`, {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
transactionId: paymentResult.transactionId,
|
||||||
|
amount: costJpy,
|
||||||
|
currency,
|
||||||
|
subscriptionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 3: Only if payment successful, add data via Freebit
|
||||||
|
await this.freebitService.topUpSim(account, request.quotaMb, {});
|
||||||
|
|
||||||
|
this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
quotaMb: request.quotaMb,
|
||||||
|
costJpy,
|
||||||
|
currency,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
transactionId: paymentResult.transactionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.simNotification.notifySimAction("Top Up Data", "SUCCESS", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
quotaMb: request.quotaMb,
|
||||||
|
costJpy,
|
||||||
|
currency,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
transactionId: paymentResult.transactionId,
|
||||||
|
});
|
||||||
|
} catch (freebitError) {
|
||||||
|
// If Freebit fails after payment, handle carefully
|
||||||
|
await this.handleFreebitFailureAfterPayment(
|
||||||
|
freebitError,
|
||||||
|
invoice,
|
||||||
|
paymentResult.transactionId || "unknown",
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
request.quotaMb
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const sanitizedError = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to top up SIM for subscription ${subscriptionId}`, {
|
||||||
|
error: sanitizedError,
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
quotaMb: request.quotaMb,
|
||||||
|
costJpy,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
await this.simNotification.notifySimAction("Top Up Data", "ERROR", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account: account ?? "",
|
||||||
|
quotaMb: request.quotaMb,
|
||||||
|
costJpy,
|
||||||
|
currency,
|
||||||
|
error: sanitizedError,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle payment failure by canceling the invoice
|
||||||
|
*/
|
||||||
|
private async handlePaymentFailure(invoiceId: number, error: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.whmcsService.updateInvoice({
|
||||||
|
invoiceId,
|
||||||
|
status: "Cancelled",
|
||||||
|
notes: `Payment capture failed: ${error}. Invoice cancelled automatically.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Cancelled invoice ${invoiceId} due to payment failure`, {
|
||||||
|
invoiceId,
|
||||||
|
reason: "Payment capture failed",
|
||||||
|
});
|
||||||
|
} catch (cancelError) {
|
||||||
|
this.logger.error(`Failed to cancel invoice ${invoiceId} after payment failure`, {
|
||||||
|
invoiceId,
|
||||||
|
cancelError: getErrorMessage(cancelError),
|
||||||
|
originalError: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Freebit API failure after successful payment
|
||||||
|
*/
|
||||||
|
private async handleFreebitFailureAfterPayment(
|
||||||
|
freebitError: unknown,
|
||||||
|
invoice: { id: number; number: string },
|
||||||
|
transactionId: string,
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
account: string,
|
||||||
|
quotaMb: number
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.error(
|
||||||
|
`Freebit API failed after successful payment for subscription ${subscriptionId}`,
|
||||||
|
{
|
||||||
|
error: getErrorMessage(freebitError),
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
quotaMb,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
transactionId,
|
||||||
|
paymentCaptured: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add a note to the invoice about the Freebit failure
|
||||||
|
try {
|
||||||
|
await this.whmcsService.updateInvoice({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
notes: `Payment successful but SIM top-up failed: ${getErrorMessage(freebitError)}. Manual intervention required.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Added failure note to invoice ${invoice.id}`, {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
reason: "Freebit API failure after payment",
|
||||||
|
});
|
||||||
|
} catch (updateError) {
|
||||||
|
this.logger.error(`Failed to update invoice ${invoice.id} with failure note`, {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
updateError: getErrorMessage(updateError),
|
||||||
|
originalError: getErrorMessage(freebitError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement refund logic here
|
||||||
|
// await this.whmcsService.addCredit({
|
||||||
|
// clientId: whmcsClientId,
|
||||||
|
// description: `Refund for failed SIM top-up (Invoice: ${invoice.number})`,
|
||||||
|
// amount: costJpy,
|
||||||
|
// type: 'refund'
|
||||||
|
// });
|
||||||
|
|
||||||
|
const errMsg = `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`;
|
||||||
|
await this.simNotification.notifySimAction("Top Up Data", "ERROR", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
quotaMb,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
transactionId,
|
||||||
|
error: getErrorMessage(freebitError),
|
||||||
|
});
|
||||||
|
throw new Error(errMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||||
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
|
import { SimUsageStoreService } from "../../sim-usage-store.service";
|
||||||
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import type { SimUsage, SimTopUpHistory } from "@bff/integrations/freebit/interfaces/freebit.types";
|
||||||
|
import type { SimTopUpHistoryRequest } from "../types/sim-requests.types";
|
||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimUsageService {
|
||||||
|
constructor(
|
||||||
|
private readonly freebitService: FreebitOrchestratorService,
|
||||||
|
private readonly simValidation: SimValidationService,
|
||||||
|
private readonly usageStore: SimUsageStoreService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM data usage for a subscription
|
||||||
|
*/
|
||||||
|
async getSimUsage(userId: string, subscriptionId: number): Promise<SimUsage> {
|
||||||
|
let account = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
account = validation.account;
|
||||||
|
|
||||||
|
const simUsage = await this.freebitService.getSimUsage(account);
|
||||||
|
|
||||||
|
// Persist today's usage for monthly charts and cleanup previous months
|
||||||
|
try {
|
||||||
|
await this.usageStore.upsertToday(account, simUsage.todayUsageMb);
|
||||||
|
await this.usageStore.cleanupPreviousMonths();
|
||||||
|
const stored = await this.usageStore.getLastNDays(account, 30);
|
||||||
|
if (stored.length > 0) {
|
||||||
|
simUsage.recentDaysUsage = stored.map(d => ({
|
||||||
|
date: d.date,
|
||||||
|
usageKb: Math.round(d.usageMb * 1000),
|
||||||
|
usageMb: d.usageMb,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const sanitizedError = getErrorMessage(e);
|
||||||
|
this.logger.warn("SIM usage persistence failed (non-fatal)", {
|
||||||
|
account,
|
||||||
|
error: sanitizedError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Retrieved SIM usage for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
todayUsageMb: simUsage.todayUsageMb,
|
||||||
|
});
|
||||||
|
|
||||||
|
return simUsage;
|
||||||
|
} catch (error) {
|
||||||
|
const sanitizedError = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to get SIM usage for subscription ${subscriptionId}`, {
|
||||||
|
error: sanitizedError,
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (account && sanitizedError.toLowerCase().includes("failed to get sim usage")) {
|
||||||
|
try {
|
||||||
|
const fallback = await this.buildFallbackUsage(account);
|
||||||
|
this.logger.warn("Serving cached SIM usage after Freebit failure", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
fallbackSource: fallback.recentDaysUsage.length > 0 ? "cache" : "default",
|
||||||
|
});
|
||||||
|
return fallback;
|
||||||
|
} catch (fallbackError) {
|
||||||
|
this.logger.warn("Unable to build fallback SIM usage", {
|
||||||
|
account,
|
||||||
|
error: getErrorMessage(fallbackError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildFallbackUsage(account: string): Promise<SimUsage> {
|
||||||
|
try {
|
||||||
|
const records = await this.usageStore.getLastNDays(account, 30);
|
||||||
|
if (records.length > 0) {
|
||||||
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
|
const todayRecord = records.find(r => r.date === todayIso) ?? records[records.length - 1];
|
||||||
|
const todayUsageMb = todayRecord?.usageMb ?? 0;
|
||||||
|
|
||||||
|
const mostRecentDate = records[0]?.date;
|
||||||
|
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
todayUsageMb,
|
||||||
|
todayUsageKb: Math.round(todayUsageMb * 1000),
|
||||||
|
recentDaysUsage: records.map(r => ({
|
||||||
|
date: r.date,
|
||||||
|
usageMb: r.usageMb,
|
||||||
|
usageKb: Math.round(r.usageMb * 1000),
|
||||||
|
})),
|
||||||
|
isBlacklisted: false,
|
||||||
|
lastUpdated: mostRecentDate ? `${mostRecentDate}T00:00:00.000Z` : new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn("Failed to load cached SIM usage", {
|
||||||
|
account,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
todayUsageMb: 0,
|
||||||
|
todayUsageKb: 0,
|
||||||
|
recentDaysUsage: [],
|
||||||
|
isBlacklisted: false,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM top-up history
|
||||||
|
*/
|
||||||
|
async getSimTopUpHistory(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimTopUpHistoryRequest
|
||||||
|
): Promise<SimTopUpHistory> {
|
||||||
|
try {
|
||||||
|
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
|
// Validate date format
|
||||||
|
if (!/^\d{8}$/.test(request.fromDate) || !/^\d{8}$/.test(request.toDate)) {
|
||||||
|
throw new BadRequestException("Dates must be in YYYYMMDD format");
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await this.freebitService.getSimTopUpHistory(
|
||||||
|
account,
|
||||||
|
request.fromDate,
|
||||||
|
request.toDate
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Retrieved SIM top-up history for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
totalAdditions: history.totalAdditions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return history;
|
||||||
|
} catch (error) {
|
||||||
|
const sanitizedError = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to get SIM top-up history for subscription ${subscriptionId}`, {
|
||||||
|
error: sanitizedError,
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,303 @@
|
|||||||
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { SubscriptionsService } from "../../subscriptions.service";
|
||||||
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
|
import type { SimValidationResult } from "../interfaces/sim-base.interface";
|
||||||
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||||
|
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimValidationService {
|
||||||
|
constructor(
|
||||||
|
private readonly subscriptionsService: SubscriptionsService,
|
||||||
|
private readonly mappingsService: MappingsService,
|
||||||
|
private readonly whmcsService: WhmcsService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a subscription is a SIM service and extract account identifier
|
||||||
|
*/
|
||||||
|
async validateSimSubscription(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number
|
||||||
|
): Promise<SimValidationResult> {
|
||||||
|
try {
|
||||||
|
// Get subscription details to verify it's a SIM service
|
||||||
|
const subscription = await this.subscriptionsService.getSubscriptionById(
|
||||||
|
userId,
|
||||||
|
subscriptionId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if this is a SIM service
|
||||||
|
const isSimService =
|
||||||
|
subscription.productName.toLowerCase().includes("sim") ||
|
||||||
|
subscription.groupName?.toLowerCase().includes("sim");
|
||||||
|
|
||||||
|
if (!isSimService) {
|
||||||
|
throw new BadRequestException("This subscription is not a SIM service");
|
||||||
|
}
|
||||||
|
|
||||||
|
// For SIM services, the account identifier (phone number) can be stored in multiple places
|
||||||
|
let account = "";
|
||||||
|
let accountSource = "";
|
||||||
|
|
||||||
|
// 1. Try domain field first
|
||||||
|
if (subscription.domain && subscription.domain.trim()) {
|
||||||
|
account = subscription.domain.trim();
|
||||||
|
accountSource = "subscription.domain";
|
||||||
|
this.logger.log(`Found SIM account in domain field: ${account}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
source: accountSource,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If no domain, check custom fields for phone number/MSISDN
|
||||||
|
if (!account && subscription.customFields) {
|
||||||
|
account = this.extractAccountFromCustomFields(subscription.customFields, subscriptionId);
|
||||||
|
if (account) {
|
||||||
|
accountSource = "subscription.customFields";
|
||||||
|
this.logger.log(`Found SIM account in custom fields: ${account}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
source: accountSource,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If still no account, check if subscription ID looks like a phone number
|
||||||
|
if (!account && subscription.orderNumber) {
|
||||||
|
const orderNum = subscription.orderNumber.toString();
|
||||||
|
if (/^\d{10,11}$/.test(orderNum)) {
|
||||||
|
account = orderNum;
|
||||||
|
accountSource = "subscription.orderNumber";
|
||||||
|
this.logger.log(`Found SIM account in order number: ${account}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
source: accountSource,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Final fallback - get phone number from WHMCS account
|
||||||
|
if (!account) {
|
||||||
|
try {
|
||||||
|
const mapping = await this.mappingsService.findByUserId(userId);
|
||||||
|
if (mapping?.whmcsClientId) {
|
||||||
|
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
||||||
|
if (client?.phonenumber) {
|
||||||
|
account = client.phonenumber;
|
||||||
|
accountSource = "whmcs.account.phonenumber";
|
||||||
|
this.logger.log(
|
||||||
|
`Found SIM account in WHMCS account phone number: ${account}`,
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
productName: subscription.productName,
|
||||||
|
whmcsClientId: mapping.whmcsClientId,
|
||||||
|
source: accountSource,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to retrieve phone number from WHMCS account for user ${userId}`,
|
||||||
|
{
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still no account found, throw an error
|
||||||
|
if (!account) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`No SIM account identifier (phone number) found for subscription ${subscriptionId}. ` +
|
||||||
|
`Please ensure the subscription has a phone number in the domain field, custom fields, or in your WHMCS account profile.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the account format (remove hyphens, spaces, etc.)
|
||||||
|
account = account.replace(/[-\s()]/g, "");
|
||||||
|
|
||||||
|
// Skip phone number format validation for testing
|
||||||
|
// In production, you might want to add validation back:
|
||||||
|
// const cleanAccount = account.replace(/^\+81/, '0'); // Convert +81 to 0
|
||||||
|
// if (!/^0\d{9,10}$/.test(cleanAccount)) {
|
||||||
|
// throw new BadRequestException(`Invalid SIM account format: ${account}. Expected Japanese phone number format (10-11 digits starting with 0).`);
|
||||||
|
// }
|
||||||
|
// account = cleanAccount;
|
||||||
|
|
||||||
|
this.logger.log(`Using SIM account: ${account} (from ${accountSource})`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
source: accountSource,
|
||||||
|
note: "Phone number format validation skipped for testing",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { account };
|
||||||
|
} catch (error) {
|
||||||
|
const sanitizedError = getErrorMessage(error);
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to validate SIM subscription ${subscriptionId} for user ${userId}`,
|
||||||
|
{
|
||||||
|
error: sanitizedError,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract account identifier from custom fields
|
||||||
|
*/
|
||||||
|
private extractAccountFromCustomFields(
|
||||||
|
customFields: Record<string, unknown>,
|
||||||
|
subscriptionId: number
|
||||||
|
): string {
|
||||||
|
// Common field names for SIM phone numbers in WHMCS
|
||||||
|
const phoneFields = [
|
||||||
|
"phone",
|
||||||
|
"msisdn",
|
||||||
|
"phonenumber",
|
||||||
|
"phone_number",
|
||||||
|
"mobile",
|
||||||
|
"sim_phone",
|
||||||
|
"Phone Number",
|
||||||
|
"MSISDN",
|
||||||
|
"Phone",
|
||||||
|
"Mobile",
|
||||||
|
"SIM Phone",
|
||||||
|
"PhoneNumber",
|
||||||
|
"phone_number",
|
||||||
|
"mobile_number",
|
||||||
|
"sim_number",
|
||||||
|
"account_number",
|
||||||
|
"Account Number",
|
||||||
|
"SIM Account",
|
||||||
|
"Phone Number (SIM)",
|
||||||
|
"Mobile Number",
|
||||||
|
// Specific field names that might contain the SIM number
|
||||||
|
"SIM Number",
|
||||||
|
"SIM_Number",
|
||||||
|
"sim_number",
|
||||||
|
"SIM_Phone_Number",
|
||||||
|
"Phone_Number_SIM",
|
||||||
|
"Mobile_SIM_Number",
|
||||||
|
"SIM_Account_Number",
|
||||||
|
"ICCID",
|
||||||
|
"iccid",
|
||||||
|
"IMSI",
|
||||||
|
"imsi",
|
||||||
|
"EID",
|
||||||
|
"eid",
|
||||||
|
// Additional variations
|
||||||
|
"SIM_Data",
|
||||||
|
"SIM_Info",
|
||||||
|
"SIM_Details",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const fieldName of phoneFields) {
|
||||||
|
const rawValue = customFields[fieldName];
|
||||||
|
if (rawValue !== undefined && rawValue !== null && rawValue !== "") {
|
||||||
|
const accountValue = this.formatCustomFieldValue(rawValue);
|
||||||
|
this.logger.log(`Found SIM account in custom field '${fieldName}': ${accountValue}`, {
|
||||||
|
subscriptionId,
|
||||||
|
fieldName,
|
||||||
|
account: accountValue,
|
||||||
|
});
|
||||||
|
return accountValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still no account found, log all available custom fields for debugging
|
||||||
|
this.logger.warn(`No SIM account found in custom fields for subscription ${subscriptionId}`, {
|
||||||
|
subscriptionId,
|
||||||
|
availableFields: Object.keys(customFields),
|
||||||
|
customFields,
|
||||||
|
searchedFields: phoneFields,
|
||||||
|
});
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug method to check subscription data for SIM services
|
||||||
|
*/
|
||||||
|
async debugSimSubscription(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
try {
|
||||||
|
const subscription = await this.subscriptionsService.getSubscriptionById(
|
||||||
|
userId,
|
||||||
|
subscriptionId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get WHMCS account phone number for debugging
|
||||||
|
let whmcsAccountPhone: string | undefined;
|
||||||
|
try {
|
||||||
|
const mapping = await this.mappingsService.findByUserId(userId);
|
||||||
|
if (mapping?.whmcsClientId) {
|
||||||
|
const client = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
||||||
|
whmcsAccountPhone = client?.phonenumber;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Failed to fetch WHMCS account phone for debugging`, {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscriptionId,
|
||||||
|
productName: subscription.productName,
|
||||||
|
domain: subscription.domain,
|
||||||
|
orderNumber: subscription.orderNumber,
|
||||||
|
customFields: subscription.customFields,
|
||||||
|
isSimService:
|
||||||
|
subscription.productName.toLowerCase().includes("sim") ||
|
||||||
|
subscription.groupName?.toLowerCase().includes("sim"),
|
||||||
|
groupName: subscription.groupName,
|
||||||
|
status: subscription.status,
|
||||||
|
whmcsAccountPhone,
|
||||||
|
allCustomFieldKeys: Object.keys(subscription.customFields || {}),
|
||||||
|
allCustomFieldValues: subscription.customFields,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const sanitizedError = getErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to debug subscription ${subscriptionId}`, {
|
||||||
|
error: sanitizedError,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatCustomFieldValue(value: unknown): string {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return "[unserializable]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,124 @@
|
|||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "@bff/infra/database/prisma.service";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
|
||||||
|
export interface VoiceOptionsSettings {
|
||||||
|
voiceMailEnabled: boolean;
|
||||||
|
callWaitingEnabled: boolean;
|
||||||
|
internationalRoamingEnabled: boolean;
|
||||||
|
networkType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimVoiceOptionsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get voice options for a SIM account
|
||||||
|
* Returns null if no settings found
|
||||||
|
*/
|
||||||
|
async getVoiceOptions(account: string): Promise<VoiceOptionsSettings | null> {
|
||||||
|
try {
|
||||||
|
const options = await this.prisma.simVoiceOptions.findUnique({
|
||||||
|
where: { account },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!options) {
|
||||||
|
this.logger.debug(`No voice options found in database for account ${account}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
voiceMailEnabled: options.voiceMailEnabled,
|
||||||
|
callWaitingEnabled: options.callWaitingEnabled,
|
||||||
|
internationalRoamingEnabled: options.internationalRoamingEnabled,
|
||||||
|
networkType: options.networkType,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to get voice options for account ${account}`, { error });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save or update voice options for a SIM account
|
||||||
|
*/
|
||||||
|
async saveVoiceOptions(account: string, settings: Partial<VoiceOptionsSettings>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.prisma.simVoiceOptions.upsert({
|
||||||
|
where: { account },
|
||||||
|
create: {
|
||||||
|
account,
|
||||||
|
voiceMailEnabled: settings.voiceMailEnabled ?? false,
|
||||||
|
callWaitingEnabled: settings.callWaitingEnabled ?? false,
|
||||||
|
internationalRoamingEnabled: settings.internationalRoamingEnabled ?? false,
|
||||||
|
networkType: settings.networkType ?? "4G",
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...(settings.voiceMailEnabled !== undefined && {
|
||||||
|
voiceMailEnabled: settings.voiceMailEnabled,
|
||||||
|
}),
|
||||||
|
...(settings.callWaitingEnabled !== undefined && {
|
||||||
|
callWaitingEnabled: settings.callWaitingEnabled,
|
||||||
|
}),
|
||||||
|
...(settings.internationalRoamingEnabled !== undefined && {
|
||||||
|
internationalRoamingEnabled: settings.internationalRoamingEnabled,
|
||||||
|
}),
|
||||||
|
...(settings.networkType !== undefined && {
|
||||||
|
networkType: settings.networkType,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Saved voice options for account ${account}`, { settings });
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to save voice options for account ${account}`, {
|
||||||
|
error,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize voice options for a new SIM account
|
||||||
|
*/
|
||||||
|
async initializeVoiceOptions(
|
||||||
|
account: string,
|
||||||
|
settings: {
|
||||||
|
voiceMailEnabled?: boolean;
|
||||||
|
callWaitingEnabled?: boolean;
|
||||||
|
internationalRoamingEnabled?: boolean;
|
||||||
|
networkType?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<void> {
|
||||||
|
await this.saveVoiceOptions(account, {
|
||||||
|
voiceMailEnabled: settings.voiceMailEnabled ?? true,
|
||||||
|
callWaitingEnabled: settings.callWaitingEnabled ?? true,
|
||||||
|
internationalRoamingEnabled: settings.internationalRoamingEnabled ?? true,
|
||||||
|
networkType: settings.networkType ?? "5G",
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Initialized voice options for new SIM account ${account}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete voice options for a SIM account (e.g., when SIM is cancelled)
|
||||||
|
*/
|
||||||
|
async deleteVoiceOptions(account: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.prisma.simVoiceOptions.delete({
|
||||||
|
where: { account },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Deleted voice options for account ${account}`);
|
||||||
|
} catch (error) {
|
||||||
|
// Silently ignore if record doesn't exist
|
||||||
|
this.logger.debug(`Could not delete voice options for account ${account}`, { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import { Module, forwardRef } from "@nestjs/common";
|
||||||
|
import { FreebitModule } from "@bff/integrations/freebit/freebit.module";
|
||||||
|
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module";
|
||||||
|
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
|
||||||
|
import { EmailModule } from "@bff/infra/email/email.module";
|
||||||
|
import { SimUsageStoreService } from "../sim-usage-store.service";
|
||||||
|
import { SubscriptionsService } from "../subscriptions.service";
|
||||||
|
|
||||||
|
// Import all SIM management services
|
||||||
|
import { SimOrchestratorService } from "./services/sim-orchestrator.service";
|
||||||
|
import { SimDetailsService } from "./services/sim-details.service";
|
||||||
|
import { SimUsageService } from "./services/sim-usage.service";
|
||||||
|
import { SimTopUpService } from "./services/sim-topup.service";
|
||||||
|
import { SimPlanService } from "./services/sim-plan.service";
|
||||||
|
import { SimCancellationService } from "./services/sim-cancellation.service";
|
||||||
|
import { EsimManagementService } from "./services/esim-management.service";
|
||||||
|
import { SimValidationService } from "./services/sim-validation.service";
|
||||||
|
import { SimNotificationService } from "./services/sim-notification.service";
|
||||||
|
import { SimVoiceOptionsService } from "./services/sim-voice-options.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [forwardRef(() => FreebitModule), WhmcsModule, MappingsModule, EmailModule],
|
||||||
|
providers: [
|
||||||
|
// Core services that the SIM services depend on
|
||||||
|
SimUsageStoreService,
|
||||||
|
SubscriptionsService,
|
||||||
|
|
||||||
|
// SIM management services
|
||||||
|
SimValidationService,
|
||||||
|
SimNotificationService,
|
||||||
|
SimVoiceOptionsService,
|
||||||
|
SimDetailsService,
|
||||||
|
SimUsageService,
|
||||||
|
SimTopUpService,
|
||||||
|
SimPlanService,
|
||||||
|
SimCancellationService,
|
||||||
|
EsimManagementService,
|
||||||
|
SimOrchestratorService,
|
||||||
|
// Export with token for optional injection in Freebit module
|
||||||
|
{
|
||||||
|
provide: "SimVoiceOptionsService",
|
||||||
|
useExisting: SimVoiceOptionsService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
SimOrchestratorService,
|
||||||
|
// Export individual services in case they're needed elsewhere
|
||||||
|
SimDetailsService,
|
||||||
|
SimUsageService,
|
||||||
|
SimTopUpService,
|
||||||
|
SimPlanService,
|
||||||
|
SimCancellationService,
|
||||||
|
EsimManagementService,
|
||||||
|
SimValidationService,
|
||||||
|
SimNotificationService,
|
||||||
|
SimVoiceOptionsService,
|
||||||
|
"SimVoiceOptionsService", // Export the token
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class SimManagementModule {}
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { SimOrchestratorService } from "./sim-management/services/sim-orchestrator.service";
|
||||||
|
import { SimNotificationService } from "./sim-management/services/sim-notification.service";
|
||||||
|
import type {
|
||||||
|
SimDetails,
|
||||||
|
SimUsage,
|
||||||
|
SimTopUpHistory,
|
||||||
|
} from "@bff/integrations/freebit/interfaces/freebit.types";
|
||||||
|
import type {
|
||||||
|
SimTopUpRequest,
|
||||||
|
SimPlanChangeRequest,
|
||||||
|
SimCancelRequest,
|
||||||
|
SimTopUpHistoryRequest,
|
||||||
|
SimFeaturesUpdateRequest,
|
||||||
|
} from "./sim-management/types/sim-requests.types";
|
||||||
|
import type { SimNotificationContext } from "./sim-management/interfaces/sim-base.interface";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimManagementService {
|
||||||
|
constructor(
|
||||||
|
private readonly simOrchestrator: SimOrchestratorService,
|
||||||
|
private readonly simNotification: SimNotificationService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Delegate to notification service for backward compatibility
|
||||||
|
private async notifySimAction(
|
||||||
|
action: string,
|
||||||
|
status: "SUCCESS" | "ERROR",
|
||||||
|
context: SimNotificationContext
|
||||||
|
): Promise<void> {
|
||||||
|
return this.simNotification.notifySimAction(action, status, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug method to check subscription data for SIM services
|
||||||
|
*/
|
||||||
|
async debugSimSubscription(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return this.simOrchestrator.debugSimSubscription(userId, subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug method to query Freebit directly for any account's details
|
||||||
|
*/
|
||||||
|
async getSimDetailsDebug(account: string): Promise<SimDetails> {
|
||||||
|
return this.simOrchestrator.getSimDetailsDirectly(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This method is now handled by SimValidationService internally
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM details for a subscription
|
||||||
|
*/
|
||||||
|
async getSimDetails(userId: string, subscriptionId: number): Promise<SimDetails> {
|
||||||
|
return this.simOrchestrator.getSimDetails(userId, subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM data usage for a subscription
|
||||||
|
*/
|
||||||
|
async getSimUsage(userId: string, subscriptionId: number): Promise<SimUsage> {
|
||||||
|
return this.simOrchestrator.getSimUsage(userId, subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top up SIM data quota with payment processing
|
||||||
|
* Pricing: 1GB = 500 JPY
|
||||||
|
*/
|
||||||
|
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
||||||
|
return this.simOrchestrator.topUpSim(userId, subscriptionId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM top-up history
|
||||||
|
*/
|
||||||
|
async getSimTopUpHistory(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimTopUpHistoryRequest
|
||||||
|
): Promise<SimTopUpHistory> {
|
||||||
|
return this.simOrchestrator.getSimTopUpHistory(userId, subscriptionId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change SIM plan
|
||||||
|
*/
|
||||||
|
async changeSimPlan(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimPlanChangeRequest
|
||||||
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||||
|
return this.simOrchestrator.changeSimPlan(userId, subscriptionId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update SIM features (voicemail, call waiting, roaming, network type)
|
||||||
|
*/
|
||||||
|
async updateSimFeatures(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimFeaturesUpdateRequest
|
||||||
|
): Promise<void> {
|
||||||
|
return this.simOrchestrator.updateSimFeatures(userId, subscriptionId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel SIM service
|
||||||
|
*/
|
||||||
|
async cancelSim(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimCancelRequest = {}
|
||||||
|
): Promise<void> {
|
||||||
|
return this.simOrchestrator.cancelSim(userId, subscriptionId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reissue eSIM profile
|
||||||
|
*/
|
||||||
|
async reissueEsimProfile(userId: string, subscriptionId: number, newEid?: string): Promise<void> {
|
||||||
|
return this.simOrchestrator.reissueEsimProfile(userId, subscriptionId, newEid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comprehensive SIM information (details + usage combined)
|
||||||
|
*/
|
||||||
|
async getSimInfo(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number
|
||||||
|
): Promise<{
|
||||||
|
details: SimDetails;
|
||||||
|
usage: SimUsage;
|
||||||
|
}> {
|
||||||
|
return this.simOrchestrator.getSimInfo(userId, subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert technical errors to user-friendly messages for SIM operations
|
||||||
|
*/
|
||||||
|
private getUserFriendlySimError(technicalError: string): string {
|
||||||
|
return this.simNotification.getUserFriendlySimError(technicalError);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
export interface SimTopUpRequest {
|
||||||
|
quotaMb: number;
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimPlanChangeRequest {
|
||||||
|
newPlanCode: "5GB" | "10GB" | "25GB" | "50GB";
|
||||||
|
effectiveDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimCancelRequest {
|
||||||
|
scheduledAt?: string; // YYYYMMDD - optional, immediate if omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimTopUpHistoryRequest {
|
||||||
|
fromDate: string; // YYYYMMDD
|
||||||
|
toDate: string; // YYYYMMDD
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SimFeaturesUpdateRequest {
|
||||||
|
voiceMailEnabled?: boolean;
|
||||||
|
callWaitingEnabled?: boolean;
|
||||||
|
internationalRoamingEnabled?: boolean;
|
||||||
|
networkType?: "4G" | "5G";
|
||||||
|
}
|
||||||
934
sim-manager-migration/docs/FREEBIT-SIM-MANAGEMENT.md
Normal file
934
sim-manager-migration/docs/FREEBIT-SIM-MANAGEMENT.md
Normal file
@ -0,0 +1,934 @@
|
|||||||
|
# Freebit SIM Management - Implementation Guide
|
||||||
|
|
||||||
|
_Complete implementation of Freebit SIM management functionality for the Customer Portal._
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document outlines the complete implementation of Freebit SIM management features, including backend API integration, frontend UI components, and Salesforce data tracking requirements.
|
||||||
|
|
||||||
|
Where to find it in the portal:
|
||||||
|
|
||||||
|
- Subscriptions > [Subscription] > SIM Management section on the page
|
||||||
|
- Direct link from sidebar goes to `#sim-management` anchor
|
||||||
|
- Component: `apps/portal/src/features/sim-management/components/SimManagementSection.tsx`
|
||||||
|
|
||||||
|
**Last Updated**: January 2025
|
||||||
|
**Implementation Status**: ✅ Complete and Deployed
|
||||||
|
**Latest Updates**: Enhanced UI/UX design, improved layout, and streamlined interface
|
||||||
|
|
||||||
|
## 🏗️ Implementation Summary
|
||||||
|
|
||||||
|
### ✅ Completed Features
|
||||||
|
|
||||||
|
1. **Backend (BFF) Integration**
|
||||||
|
- ✅ Freebit API service with all endpoints
|
||||||
|
- ✅ SIM management service layer
|
||||||
|
- ✅ REST API endpoints for portal consumption
|
||||||
|
- ✅ Authentication and error handling
|
||||||
|
- ✅ **Fixed**: Switched from `axios` to native `fetch` API for consistency
|
||||||
|
- ✅ **Fixed**: Proper `application/x-www-form-urlencoded` format for Freebit API
|
||||||
|
- ✅ **Added**: Enhanced eSIM reissue using `/mvno/esim/addAcnt/` endpoint
|
||||||
|
|
||||||
|
2. **Frontend (Portal) Components**
|
||||||
|
- ✅ SIM details card with status and information
|
||||||
|
- ✅ Data usage chart with visual progress tracking
|
||||||
|
- ✅ SIM management actions (top-up, cancel, reissue)
|
||||||
|
- ✅ Interactive top-up modal with presets and scheduling
|
||||||
|
- ✅ Integrated into subscription detail page
|
||||||
|
- ✅ **Fixed**: Updated all components to use `authenticatedApi` utility
|
||||||
|
- ✅ **Fixed**: Proper API routing to BFF (port 4000) instead of frontend (port 3000)
|
||||||
|
- ✅ **Enhanced**: Modern responsive layout with 2/3 + 1/3 grid structure
|
||||||
|
- ✅ **Enhanced**: Soft color scheme matching website design language
|
||||||
|
- ✅ **Enhanced**: Improved dropdown styling and form consistency
|
||||||
|
- ✅ **Enhanced**: Streamlined service options interface
|
||||||
|
|
||||||
|
3. **Features Implemented**
|
||||||
|
- ✅ View SIM details (ICCID, MSISDN, plan, status)
|
||||||
|
- ✅ Real-time data usage monitoring
|
||||||
|
- ✅ Data quota top-up (immediate and scheduled)
|
||||||
|
- ✅ eSIM profile reissue (both simple and enhanced methods)
|
||||||
|
- ✅ SIM service cancellation
|
||||||
|
- ✅ Plan change functionality
|
||||||
|
- ✅ Usage history tracking
|
||||||
|
- ✅ **Added**: Debug endpoint for troubleshooting SIM account mapping
|
||||||
|
|
||||||
|
### 🔧 Critical Fixes Applied
|
||||||
|
|
||||||
|
#### Session 1 Issues (GPT-4):
|
||||||
|
|
||||||
|
- **Backend Module Registration**: Fixed missing Freebit module imports
|
||||||
|
- **TypeScript Interfaces**: Comprehensive Freebit API type definitions
|
||||||
|
- **Error Handling**: Proper Freebit API error responses and logging
|
||||||
|
|
||||||
|
#### Session 2 Issues (Claude Sonnet 4):
|
||||||
|
|
||||||
|
- **HTTP Client Migration**: Replaced `axios` with `fetch` for consistency
|
||||||
|
- **API Authentication Format**: Fixed request format to match Salesforce implementation
|
||||||
|
- **Frontend API Routing**: Fixed 404 errors by using correct API base URL
|
||||||
|
- **Environment Configuration**: Added missing `FREEBIT_OEM_KEY` and credentials
|
||||||
|
- **Status Mapping**: Proper Freebit status (`active`, `suspended`, etc.) to portal status mapping
|
||||||
|
|
||||||
|
## 🔧 API Endpoints
|
||||||
|
|
||||||
|
### Backend (BFF) Endpoints
|
||||||
|
|
||||||
|
All endpoints are prefixed with `/api/subscriptions/{id}/sim/`
|
||||||
|
|
||||||
|
- `GET /` - Get comprehensive SIM info (details + usage)
|
||||||
|
- `GET /details` - Get SIM details only
|
||||||
|
- `GET /usage` - Get data usage information
|
||||||
|
- `GET /top-up-history?fromDate=&toDate=` - Get top-up history
|
||||||
|
- `POST /top-up` - Add data quota
|
||||||
|
- `POST /change-plan` - Change SIM plan
|
||||||
|
- `POST /cancel` - Cancel SIM service
|
||||||
|
- `POST /reissue-esim` - Reissue eSIM profile (eSIM only)
|
||||||
|
- `GET /debug` - **[NEW]** Debug SIM account mapping and validation
|
||||||
|
|
||||||
|
**Request/Response Format:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// GET /api/subscriptions/29951/sim
|
||||||
|
{
|
||||||
|
"details": {
|
||||||
|
"iccid": "8944504101234567890",
|
||||||
|
"msisdn": "08077052946",
|
||||||
|
"plan": "plan1g",
|
||||||
|
"status": "active",
|
||||||
|
"simType": "physical"
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"usedMb": 500,
|
||||||
|
"totalMb": 1000,
|
||||||
|
"remainingMb": 500,
|
||||||
|
"usagePercentage": 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/subscriptions/29951/sim/top-up
|
||||||
|
{
|
||||||
|
"quotaMb": 1000,
|
||||||
|
"scheduledDate": "2025-01-15" // optional
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Freebit API Integration
|
||||||
|
|
||||||
|
**Implemented Freebit APIs:**
|
||||||
|
|
||||||
|
- PA01-01: OEM Authentication (`/authOem/`)
|
||||||
|
- PA03-02: Get Account Details (`/mvno/getDetail/`)
|
||||||
|
- PA04-04: Add Specs & Quota (`/master/addSpec/`)
|
||||||
|
- PA05-0: MVNO Communication Information Retrieval (`/mvno/getTrafficInfo/`)
|
||||||
|
- PA05-02: MVNO Quota Addition History (`/mvno/getQuotaHistory/`)
|
||||||
|
- PA05-04: MVNO Plan Cancellation (`/mvno/releasePlan/`)
|
||||||
|
- PA05-21: MVNO Plan Change (`/mvno/changePlan/`)
|
||||||
|
- PA05-22: MVNO Quota Settings (`/mvno/eachQuota/`)
|
||||||
|
- PA05-42: eSIM Profile Reissue (`/esim/reissueProfile/`)
|
||||||
|
- **Enhanced**: eSIM Add Account/Reissue (`/mvno/esim/addAcnt/`) - Based on Salesforce implementation
|
||||||
|
|
||||||
|
**Note**: The implementation includes both the simple reissue endpoint and the enhanced addAcnt method for more complex eSIM reissue scenarios, matching your existing Salesforce integration patterns.
|
||||||
|
|
||||||
|
## 🎨 Frontend Components
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/portal/src/features/sim-management/
|
||||||
|
├── components/
|
||||||
|
│ ├── SimManagementSection.tsx # Main container component
|
||||||
|
│ ├── SimDetailsCard.tsx # SIM information display
|
||||||
|
│ ├── DataUsageChart.tsx # Usage visualization
|
||||||
|
│ ├── SimActions.tsx # Action buttons and confirmations
|
||||||
|
│ ├── SimFeatureToggles.tsx # Service options (Voice Mail, Call Waiting, etc.)
|
||||||
|
│ └── TopUpModal.tsx # Data top-up interface
|
||||||
|
└── index.ts # Exports
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📱 SIM Management Page Analysis
|
||||||
|
|
||||||
|
### Page URL: `http://localhost:3000/subscriptions/29951#sim-management`
|
||||||
|
|
||||||
|
This section provides a detailed breakdown of every element on the SIM management page, mapping each UI component to its corresponding API endpoint and data transformation.
|
||||||
|
|
||||||
|
### 🔄 Data Flow Overview
|
||||||
|
|
||||||
|
1. **Page Load**: `SimManagementSection.tsx` calls `GET /api/subscriptions/29951/sim`
|
||||||
|
2. **Backend Processing**: BFF calls multiple Freebit APIs to gather comprehensive SIM data
|
||||||
|
3. **Data Transformation**: Raw Freebit responses are transformed into portal-friendly format
|
||||||
|
4. **UI Rendering**: Components display the processed data with interactive elements
|
||||||
|
|
||||||
|
### 📊 Page Layout Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ SIM Management Page │
|
||||||
|
│ (max-w-7xl container) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Left Side (2/3 width) │ Right Side (1/3 width) │
|
||||||
|
│ ┌─────────────────────────┐ │ ┌─────────────────────┐ │
|
||||||
|
│ │ SIM Management Actions │ │ │ SIM Details Card │ │
|
||||||
|
│ │ (4 action buttons) │ │ │ (eSIM/Physical) │ │
|
||||||
|
│ └─────────────────────────┘ │ └─────────────────────┘ │
|
||||||
|
│ ┌─────────────────────────┐ │ ┌─────────────────────┐ │
|
||||||
|
│ │ Service Options │ │ │ Data Usage Chart │ │
|
||||||
|
│ │ (Voice Mail, etc.) │ │ │ (Progress + History)│ │
|
||||||
|
│ └─────────────────────────┘ │ └─────────────────────┘ │
|
||||||
|
│ │ ┌─────────────────────┐ │
|
||||||
|
│ │ │ Important Info │ │
|
||||||
|
│ │ │ (Notices & Warnings)│ │
|
||||||
|
│ │ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Detailed Component Analysis
|
||||||
|
|
||||||
|
### 1. **SIM Details Card** (Right Side - Top)
|
||||||
|
|
||||||
|
**Component**: `SimDetailsCard.tsx`
|
||||||
|
**API Endpoint**: `GET /api/subscriptions/29951/sim/details`
|
||||||
|
**Freebit API**: `PA03-02: Get Account Details` (`/mvno/getDetail/`)
|
||||||
|
|
||||||
|
#### Data Mapping:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Freebit API Response → Portal Display
|
||||||
|
{
|
||||||
|
"account": "08077052946", // → Phone Number display
|
||||||
|
"iccid": "8944504101234567890", // → ICCID (Physical SIM only)
|
||||||
|
"eid": "8904xxxxxxxx...", // → EID (eSIM only)
|
||||||
|
"imsi": "440100123456789", // → IMSI display
|
||||||
|
"planCode": "PASI_5G", // → "5GB Plan" (formatted)
|
||||||
|
"status": "active", // → Status badge with color
|
||||||
|
"simType": "physical", // → SIM type indicator
|
||||||
|
"size": "nano", // → SIM size display
|
||||||
|
"hasVoice": true, // → Voice service indicator
|
||||||
|
"hasSms": true, // → SMS service indicator
|
||||||
|
"remainingQuotaMb": 512, // → "512 MB" (formatted)
|
||||||
|
"ipv4": "27.108.216.188", // → IPv4 address display
|
||||||
|
"ipv6": "2001:db8::1", // → IPv6 address display
|
||||||
|
"startDate": "2024-01-15", // → Service start date
|
||||||
|
"voiceMailEnabled": true, // → Voice Mail status
|
||||||
|
"callWaitingEnabled": false, // → Call Waiting status
|
||||||
|
"internationalRoamingEnabled": true, // → Roaming status
|
||||||
|
"networkType": "5G" // → Network type display
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Visual Elements:
|
||||||
|
|
||||||
|
- **Header**: SIM type icon + plan name + status badge
|
||||||
|
- **Phone Number**: Large, prominent display
|
||||||
|
- **Data Remaining**: Green highlight with formatted units (MB/GB)
|
||||||
|
- **Service Features**: Status indicators with color coding
|
||||||
|
- **IP Addresses**: Monospace font for technical data
|
||||||
|
- **Pending Operations**: Blue warning box for scheduled changes
|
||||||
|
|
||||||
|
### 2. **Data Usage Chart** (Right Side - Middle)
|
||||||
|
|
||||||
|
**Component**: `DataUsageChart.tsx`
|
||||||
|
**API Endpoint**: `GET /api/subscriptions/29951/sim/usage`
|
||||||
|
**Freebit API**: `PA05-01: MVNO Communication Information Retrieval` (`/mvno/getTrafficInfo/`)
|
||||||
|
|
||||||
|
#### Data Mapping:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Freebit API Response → Portal Display
|
||||||
|
{
|
||||||
|
"account": "08077052946",
|
||||||
|
"todayUsageKb": 500000, // → "500 MB" (today's usage)
|
||||||
|
"todayUsageMb": 500, // → Today's usage card
|
||||||
|
"recentDaysUsage": [ // → Recent usage history
|
||||||
|
{
|
||||||
|
"date": "2024-01-14",
|
||||||
|
"usageKb": 1000000,
|
||||||
|
"usageMb": 1000 // → Individual day bars
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isBlacklisted": false // → Service restriction warning
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Visual Elements:
|
||||||
|
|
||||||
|
- **Progress Bar**: Color-coded based on usage percentage
|
||||||
|
- Green: 0-50% usage
|
||||||
|
- Orange: 50-75% usage
|
||||||
|
- Yellow: 75-90% usage
|
||||||
|
- Red: 90%+ usage
|
||||||
|
- **Today's Usage Card**: Blue gradient with usage amount
|
||||||
|
- **Remaining Quota Card**: Green gradient with remaining data
|
||||||
|
- **Recent History**: Mini progress bars for last 5 days
|
||||||
|
- **Usage Warnings**: Color-coded alerts for high usage
|
||||||
|
|
||||||
|
### 3. **SIM Management Actions** (Left Side - Top)
|
||||||
|
|
||||||
|
**Component**: `SimActions.tsx`
|
||||||
|
**API Endpoints**: Various POST endpoints for actions
|
||||||
|
|
||||||
|
#### Action Buttons:
|
||||||
|
|
||||||
|
##### 🔵 **Top Up Data** Button
|
||||||
|
|
||||||
|
- **API**: `POST /api/subscriptions/29951/sim/top-up`
|
||||||
|
- **WHMCS APIs**: `CreateInvoice` → `CapturePayment`
|
||||||
|
- **Freebit API**: `PA04-04: Add Specs & Quota` (`/master/addSpec/`)
|
||||||
|
- **Modal**: `TopUpModal.tsx` with custom GB input field
|
||||||
|
- **Pricing**: 1GB = 500 JPY
|
||||||
|
- **Color Theme**: Blue (`bg-blue-50`, `text-blue-700`, `border-blue-200`)
|
||||||
|
- **Status**: ✅ **Fully Implemented** with payment processing
|
||||||
|
|
||||||
|
##### 🟢 **Reissue eSIM** Button (eSIM only)
|
||||||
|
|
||||||
|
- **API**: `POST /api/subscriptions/29951/sim/reissue-esim`
|
||||||
|
- **Freebit API**: `PA05-42: eSIM Profile Reissue` (`/esim/reissueProfile/`)
|
||||||
|
- **Confirmation**: Inline modal with warning about new QR code
|
||||||
|
- **Color Theme**: Green (`bg-green-50`, `text-green-700`, `border-green-200`)
|
||||||
|
|
||||||
|
##### 🔴 **Cancel SIM** Button
|
||||||
|
|
||||||
|
- **API**: `POST /api/subscriptions/29951/sim/cancel`
|
||||||
|
- **Freebit API**: `PA05-04: MVNO Plan Cancellation` (`/mvno/releasePlan/`)
|
||||||
|
- **Confirmation**: Destructive action modal with permanent warning
|
||||||
|
- **Color Theme**: Red (`bg-red-50`, `text-red-700`, `border-red-200`)
|
||||||
|
|
||||||
|
##### 🟣 **Change Plan** Button
|
||||||
|
|
||||||
|
- **API**: `POST /api/subscriptions/29951/sim/change-plan`
|
||||||
|
- **Freebit API**: `PA05-21: MVNO Plan Change` (`/mvno/changePlan/`)
|
||||||
|
- **Modal**: `ChangePlanModal.tsx` with plan selection
|
||||||
|
- **Color Theme**: Purple (`bg-purple-50`, `text-purple-700`, `border-purple-300`)
|
||||||
|
- **Important Notice**: "Plan changes must be requested before the 25th of the month"
|
||||||
|
|
||||||
|
#### Button States:
|
||||||
|
|
||||||
|
- **Enabled**: Full color theme with hover effects
|
||||||
|
- **Disabled**: Gray theme when SIM is not active
|
||||||
|
- **Loading**: "Processing..." text with disabled state
|
||||||
|
|
||||||
|
### 4. **Service Options** (Left Side - Bottom)
|
||||||
|
|
||||||
|
**Component**: `SimFeatureToggles.tsx`
|
||||||
|
**API Endpoint**: `POST /api/subscriptions/29951/sim/features`
|
||||||
|
**Freebit APIs**: Various voice option endpoints
|
||||||
|
|
||||||
|
#### Service Options:
|
||||||
|
|
||||||
|
##### 📞 **Voice Mail** (¥300/month)
|
||||||
|
|
||||||
|
- **Current Status**: Enabled/Disabled indicator
|
||||||
|
- **Toggle**: Dropdown to change status
|
||||||
|
- **API Mapping**: Voice option management endpoints
|
||||||
|
|
||||||
|
##### 📞 **Call Waiting** (¥300/month)
|
||||||
|
|
||||||
|
- **Current Status**: Enabled/Disabled indicator
|
||||||
|
- **Toggle**: Dropdown to change status
|
||||||
|
- **API Mapping**: Voice option management endpoints
|
||||||
|
|
||||||
|
##### 🌍 **International Roaming**
|
||||||
|
|
||||||
|
- **Current Status**: Enabled/Disabled indicator
|
||||||
|
- **Toggle**: Dropdown to change status
|
||||||
|
- **API Mapping**: Roaming configuration endpoints
|
||||||
|
|
||||||
|
##### 📶 **Network Type** (4G/5G)
|
||||||
|
|
||||||
|
- **Current Status**: Network type display
|
||||||
|
- **Toggle**: Dropdown to switch between 4G/5G
|
||||||
|
- **API Mapping**: Contract line change endpoints
|
||||||
|
|
||||||
|
### 5. **Important Information** (Right Side - Bottom)
|
||||||
|
|
||||||
|
**Component**: Static information panel in `SimManagementSection.tsx`
|
||||||
|
|
||||||
|
#### Information Items:
|
||||||
|
|
||||||
|
- **Real-time Updates**: "Data usage is updated in real-time and may take a few minutes to reflect recent activity"
|
||||||
|
- **Top-up Processing**: "Top-up data will be available immediately after successful processing"
|
||||||
|
- **Cancellation Warning**: "SIM cancellation is permanent and cannot be undone"
|
||||||
|
- **eSIM Reissue**: "eSIM profile reissue will provide a new QR code for activation" (eSIM only)
|
||||||
|
|
||||||
|
## 🔄 API Call Sequence
|
||||||
|
|
||||||
|
### Page Load Sequence:
|
||||||
|
|
||||||
|
1. **Initial Load**: `GET /api/subscriptions/29951/sim`
|
||||||
|
2. **Backend Processing**:
|
||||||
|
- `PA01-01: OEM Authentication` → Get auth token
|
||||||
|
- `PA03-02: Get Account Details` → SIM details
|
||||||
|
- `PA05-01: MVNO Communication Information` → Usage data
|
||||||
|
3. **Data Transformation**: Combine responses into unified format
|
||||||
|
4. **UI Rendering**: Display all components with data
|
||||||
|
|
||||||
|
### Action Sequences:
|
||||||
|
|
||||||
|
#### Top Up Data (Complete Payment Flow):
|
||||||
|
|
||||||
|
1. User clicks "Top Up Data" → Opens `TopUpModal`
|
||||||
|
2. User selects amount (1GB = 500 JPY) → `POST /api/subscriptions/29951/sim/top-up`
|
||||||
|
3. Backend: Calculate cost (ceil(GB) × ¥500)
|
||||||
|
4. Backend: WHMCS `CreateInvoice` → Generate invoice for payment
|
||||||
|
5. Backend: WHMCS `CapturePayment` → Process payment with invoice
|
||||||
|
6. Backend: If payment successful → Freebit `PA04-04: Add Specs & Quota`
|
||||||
|
7. Backend: If payment failed → Return error, no data added
|
||||||
|
8. Frontend: Success/Error response → Refresh SIM data → Show message
|
||||||
|
|
||||||
|
#### eSIM Reissue:
|
||||||
|
|
||||||
|
1. User clicks "Reissue eSIM" → Confirmation modal
|
||||||
|
2. User confirms → `POST /api/subscriptions/29951/sim/reissue-esim`
|
||||||
|
3. Backend calls `PA05-42: eSIM Profile Reissue`
|
||||||
|
4. Success response → Show success message
|
||||||
|
|
||||||
|
#### Cancel SIM:
|
||||||
|
|
||||||
|
1. User clicks "Cancel SIM" → Destructive confirmation modal
|
||||||
|
2. User confirms → `POST /api/subscriptions/29951/sim/cancel`
|
||||||
|
3. Backend calls `PA05-04: MVNO Plan Cancellation`
|
||||||
|
4. Success response → Refresh SIM data → Show success message
|
||||||
|
|
||||||
|
#### Change Plan:
|
||||||
|
|
||||||
|
1. User clicks "Change Plan" → Opens `ChangePlanModal`
|
||||||
|
2. User selects new plan → `POST /api/subscriptions/29951/sim/change-plan`
|
||||||
|
3. Backend calls `PA05-21: MVNO Plan Change`
|
||||||
|
4. Success response → Refresh SIM data → Show success message
|
||||||
|
|
||||||
|
## 🎨 Visual Design Elements
|
||||||
|
|
||||||
|
### Color Coding:
|
||||||
|
|
||||||
|
- **Blue**: Primary actions (Top Up Data)
|
||||||
|
- **Green**: eSIM operations (Reissue eSIM)
|
||||||
|
- **Red**: Destructive actions (Cancel SIM)
|
||||||
|
- **Purple**: Secondary actions (Change Plan)
|
||||||
|
- **Yellow**: Warnings and notices
|
||||||
|
- **Gray**: Disabled states
|
||||||
|
|
||||||
|
### Status Indicators:
|
||||||
|
|
||||||
|
- **Active**: Green checkmark + green badge
|
||||||
|
- **Suspended**: Yellow warning + yellow badge
|
||||||
|
- **Cancelled**: Red X + red badge
|
||||||
|
- **Pending**: Blue clock + blue badge
|
||||||
|
|
||||||
|
### Progress Visualization:
|
||||||
|
|
||||||
|
- **Usage Bar**: Color-coded based on percentage
|
||||||
|
- **Mini Bars**: Recent usage history
|
||||||
|
- **Cards**: Today's usage and remaining quota
|
||||||
|
|
||||||
|
### Current Layout Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Subscription Detail Page │
|
||||||
|
│ (max-w-7xl container) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Left Side (2/3 width) │ Right Side (1/3 width) │
|
||||||
|
│ ┌─────────────────────────┐ │ ┌─────────────────────┐ │
|
||||||
|
│ │ SIM Management Actions │ │ │ Important Info │ │
|
||||||
|
│ │ (2x2 button grid) │ │ │ (notices & warnings)│ │
|
||||||
|
│ └─────────────────────────┘ │ └─────────────────────┘ │
|
||||||
|
│ ┌─────────────────────────┐ │ ┌─────────────────────┐ │
|
||||||
|
│ │ Plan Settings │ │ │ eSIM Details │ │
|
||||||
|
│ │ (Service Options) │ │ │ (compact view) │ │
|
||||||
|
│ └─────────────────────────┘ │ └─────────────────────┘ │
|
||||||
|
│ │ ┌─────────────────────┐ │
|
||||||
|
│ │ │ Data Usage Chart │ │
|
||||||
|
│ │ │ (compact view) │ │
|
||||||
|
│ │ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Responsive Design**: Works on desktop and mobile
|
||||||
|
- **Real-time Updates**: Automatic refresh after actions
|
||||||
|
- **Visual Feedback**: Progress bars, status indicators, loading states
|
||||||
|
- **Error Handling**: Comprehensive error messages and recovery
|
||||||
|
- **Accessibility**: Proper ARIA labels and keyboard navigation
|
||||||
|
|
||||||
|
## 🎨 Recent UI/UX Enhancements (January 2025)
|
||||||
|
|
||||||
|
### Layout Improvements
|
||||||
|
|
||||||
|
- **Wider Container**: Changed from `max-w-4xl` to `max-w-7xl` to match subscriptions page width
|
||||||
|
- **Optimized Grid Layout**: 2/3 + 1/3 responsive grid for better content distribution
|
||||||
|
- **Left Side (2/3 width)**: SIM Management Actions + Plan Settings (content-heavy sections)
|
||||||
|
- **Right Side (1/3 width)**: Important Information + eSIM Details + Data Usage (compact info)
|
||||||
|
- **Mobile-First Design**: Stacks vertically on smaller screens, horizontal on desktop
|
||||||
|
|
||||||
|
### Visual Design Updates
|
||||||
|
|
||||||
|
- **Soft Color Scheme**: Replaced solid gradients with website-consistent soft colors
|
||||||
|
- **Top Up Data**: Blue theme (`bg-blue-50`, `text-blue-700`, `border-blue-200`)
|
||||||
|
- **Reissue eSIM**: Green theme (`bg-green-50`, `text-green-700`, `border-green-200`)
|
||||||
|
- **Cancel SIM**: Red theme (`bg-red-50`, `text-red-700`, `border-red-200`)
|
||||||
|
- **Change Plan**: Purple theme (`bg-purple-50`, `text-purple-700`, `border-purple-300`)
|
||||||
|
- **Enhanced Dropdowns**: Consistent styling with subtle borders and focus states
|
||||||
|
- **Improved Cards**: Better shadows, spacing, and visual hierarchy
|
||||||
|
|
||||||
|
### Interface Streamlining
|
||||||
|
|
||||||
|
- **Removed Plan Management Section**: Consolidated plan change info into action descriptions
|
||||||
|
- **Removed Service Options Header**: Cleaner, more focused interface
|
||||||
|
- **Enhanced Action Descriptions**: Added important notices and timing information
|
||||||
|
- **Important Information Repositioned**: Moved to top of right sidebar for better visibility
|
||||||
|
|
||||||
|
### User Experience Improvements
|
||||||
|
|
||||||
|
- **2x2 Action Button Grid**: Better organization and space utilization
|
||||||
|
- **Consistent Icon Usage**: Color-coded icons with background containers
|
||||||
|
- **Better Information Hierarchy**: Important notices prominently displayed
|
||||||
|
- **Improved Form Styling**: Modern dropdowns and form elements
|
||||||
|
|
||||||
|
### Action Descriptions & Important Notices
|
||||||
|
|
||||||
|
The SIM Management Actions now include comprehensive descriptions with important timing information:
|
||||||
|
|
||||||
|
- **Top Up Data**: Add additional data quota with scheduling options
|
||||||
|
- **Reissue eSIM**: Generate new QR code for eSIM profile (eSIM only)
|
||||||
|
- **Cancel SIM**: Permanently cancel service (cannot be undone)
|
||||||
|
- **Change Plan**: Switch data plans with **important timing notice**:
|
||||||
|
- "Important: Plan changes must be requested before the 25th of the month. Changes will take effect on the 1st of the following month."
|
||||||
|
|
||||||
|
### Service Options Interface
|
||||||
|
|
||||||
|
The Plan Settings section includes streamlined service options:
|
||||||
|
|
||||||
|
- **Voice Mail** (¥300/month): Enable/disable with current status display
|
||||||
|
- **Call Waiting** (¥300/month): Enable/disable with current status display
|
||||||
|
- **International Roaming**: Global connectivity options
|
||||||
|
- **Network Type**: 4G/5G connectivity selection
|
||||||
|
|
||||||
|
Each option shows:
|
||||||
|
|
||||||
|
- Current status with color-coded indicators
|
||||||
|
- Clean dropdown for status changes
|
||||||
|
- Consistent styling with website design
|
||||||
|
|
||||||
|
## 🗄️ Required Salesforce Custom Fields
|
||||||
|
|
||||||
|
To enable proper SIM data tracking in Salesforce, add these custom fields:
|
||||||
|
|
||||||
|
### On Service/Product Object
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Core SIM Identifiers
|
||||||
|
Freebit_Account__c (Text, 15) - Freebit account identifier (phone number)
|
||||||
|
Freebit_MSISDN__c (Text, 15) - Phone number/MSISDN
|
||||||
|
Freebit_ICCID__c (Text, 22) - SIM card identifier (physical SIMs)
|
||||||
|
Freebit_EID__c (Text, 32) - eSIM identifier (eSIMs only)
|
||||||
|
Freebit_IMSI__c (Text, 15) - International Mobile Subscriber Identity
|
||||||
|
|
||||||
|
-- Service Information
|
||||||
|
Freebit_Plan_Code__c (Text, 20) - Current Freebit plan code
|
||||||
|
Freebit_Status__c (Picklist) - active, suspended, cancelled, pending
|
||||||
|
Freebit_SIM_Type__c (Picklist) - physical, esim
|
||||||
|
Freebit_SIM_Size__c (Picklist) - standard, nano, micro, esim
|
||||||
|
|
||||||
|
-- Service Features
|
||||||
|
Freebit_Has_Voice__c (Checkbox) - Voice service enabled
|
||||||
|
Freebit_Has_SMS__c (Checkbox) - SMS service enabled
|
||||||
|
Freebit_IPv4__c (Text, 15) - Assigned IPv4 address
|
||||||
|
Freebit_IPv6__c (Text, 39) - Assigned IPv6 address
|
||||||
|
|
||||||
|
-- Data Tracking
|
||||||
|
Freebit_Remaining_Quota_KB__c (Number) - Current remaining data in KB
|
||||||
|
Freebit_Remaining_Quota_MB__c (Formula) - Freebit_Remaining_Quota_KB__c / 1000
|
||||||
|
Freebit_Last_Usage_Sync__c (DateTime) - Last usage data sync
|
||||||
|
Freebit_Is_Blacklisted__c (Checkbox) - Service restriction status
|
||||||
|
|
||||||
|
-- Service Dates
|
||||||
|
Freebit_Service_Start__c (Date) - Service activation date
|
||||||
|
Freebit_Last_Sync__c (DateTime) - Last sync with Freebit API
|
||||||
|
|
||||||
|
-- Pending Operations
|
||||||
|
Freebit_Pending_Operation__c (Text, 50) - Scheduled operation type
|
||||||
|
Freebit_Operation_Date__c (Date) - Scheduled operation date
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional: Dedicated SIM Management Object
|
||||||
|
|
||||||
|
For detailed tracking, create a custom object `SIM_Management__c`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SIM_Management__c
|
||||||
|
├── Service__c (Lookup to Service) - Related service record
|
||||||
|
├── Freebit_Account__c (Text, 15) - Freebit account identifier
|
||||||
|
├── Action_Type__c (Picklist) - topup, cancel, reissue, plan_change
|
||||||
|
├── Action_Date__c (DateTime) - When action was performed
|
||||||
|
├── Amount_MB__c (Number) - Data amount (for top-ups)
|
||||||
|
├── Previous_Plan__c (Text, 20) - Previous plan (for plan changes)
|
||||||
|
├── New_Plan__c (Text, 20) - New plan (for plan changes)
|
||||||
|
├── Status__c (Picklist) - success, failed, pending
|
||||||
|
├── Error_Message__c (Long Text) - Error details if failed
|
||||||
|
├── Scheduled_Date__c (Date) - For scheduled operations
|
||||||
|
├── Campaign_Code__c (Text, 20) - Campaign code used
|
||||||
|
└── Notes__c (Long Text) - Additional notes
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Deployment Configuration
|
||||||
|
|
||||||
|
### Environment Variables (BFF)
|
||||||
|
|
||||||
|
Add these to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Freebit API Configuration
|
||||||
|
# Test URL (default for development/testing)
|
||||||
|
FREEBIT_BASE_URL=https://i1-q.mvno.net/emptool/api/
|
||||||
|
# Production URL (uncomment for production)
|
||||||
|
# FREEBIT_BASE_URL=https://i1.mvno.net/emptool/api
|
||||||
|
|
||||||
|
FREEBIT_OEM_ID=PASI
|
||||||
|
FREEBIT_OEM_KEY=6Au3o7wrQNR07JxFHPmf0YfFqN9a31t5
|
||||||
|
FREEBIT_TIMEOUT=30000
|
||||||
|
FREEBIT_RETRY_ATTEMPTS=3
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Production Security Note**: The OEM key shown above is for development/testing. In production:
|
||||||
|
|
||||||
|
1. Use environment-specific key management (AWS Secrets Manager, Azure Key Vault, etc.)
|
||||||
|
2. Rotate keys regularly according to security policy
|
||||||
|
3. Never commit production keys to version control
|
||||||
|
|
||||||
|
**✅ Configuration Applied**: These environment variables have been added to the project and the BFF server has been restarted to load the new configuration.
|
||||||
|
|
||||||
|
### Module Registration
|
||||||
|
|
||||||
|
Ensure the Freebit module is imported in your main app module:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/bff/src/app.module.ts
|
||||||
|
import { FreebitModule } from "./vendors/freebit/freebit.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
// ... other modules
|
||||||
|
FreebitModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
### Backend Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test Freebit API connectivity
|
||||||
|
curl -X POST http://localhost:3001/api/subscriptions/{id}/sim/details \
|
||||||
|
-H "Authorization: Bearer {token}"
|
||||||
|
|
||||||
|
# Test data top-up
|
||||||
|
curl -X POST http://localhost:3001/api/subscriptions/{id}/sim/top-up \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer {token}" \
|
||||||
|
-d '{"quotaMb": 1000}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Testing
|
||||||
|
|
||||||
|
1. Navigate to a SIM subscription detail page
|
||||||
|
2. Verify SIM management section appears
|
||||||
|
3. Test top-up modal with different amounts
|
||||||
|
4. Test eSIM reissue (if applicable)
|
||||||
|
5. Verify error handling with invalid inputs
|
||||||
|
|
||||||
|
## 🔒 Security Considerations
|
||||||
|
|
||||||
|
1. **API Authentication**: Freebit auth keys are securely cached and refreshed
|
||||||
|
2. **Input Validation**: All user inputs are validated on both frontend and backend
|
||||||
|
3. **Rate Limiting**: Implement rate limiting for SIM management operations
|
||||||
|
4. **Audit Logging**: All SIM actions are logged with user context
|
||||||
|
5. **Error Handling**: Sensitive error details are not exposed to users
|
||||||
|
|
||||||
|
## 📊 Monitoring & Analytics
|
||||||
|
|
||||||
|
### Key Metrics to Track
|
||||||
|
|
||||||
|
- SIM management API response times
|
||||||
|
- Top-up success/failure rates
|
||||||
|
- Most popular data amounts
|
||||||
|
- Error rates by operation type
|
||||||
|
- Usage by SIM type (physical vs eSIM)
|
||||||
|
|
||||||
|
### Recommended Dashboards
|
||||||
|
|
||||||
|
1. **SIM Operations Dashboard**
|
||||||
|
- Daily/weekly top-up volumes
|
||||||
|
- Plan change requests
|
||||||
|
- Cancellation rates
|
||||||
|
- Error tracking
|
||||||
|
|
||||||
|
2. **User Engagement Dashboard**
|
||||||
|
- SIM management feature usage
|
||||||
|
- Self-service vs support ticket ratio
|
||||||
|
- User satisfaction metrics
|
||||||
|
|
||||||
|
## 🆘 Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
**1. "This subscription is not a SIM service"**
|
||||||
|
|
||||||
|
- ✅ **Fixed**: Check if subscription product name contains "sim"
|
||||||
|
- ✅ **Added**: Conditional rendering in subscription detail page
|
||||||
|
- Verify subscription has proper SIM identifiers
|
||||||
|
|
||||||
|
**2. "SIM account identifier not found"**
|
||||||
|
|
||||||
|
- ✅ **Fixed**: Enhanced validation logic in `validateSimSubscription`
|
||||||
|
- ✅ **Added**: Debug endpoint `/debug` to troubleshoot account mapping
|
||||||
|
- Ensure subscription.domain contains valid phone number
|
||||||
|
- Check WHMCS service configuration
|
||||||
|
|
||||||
|
**3. Freebit API authentication failures**
|
||||||
|
|
||||||
|
- ✅ **Fixed**: Added proper environment variable validation
|
||||||
|
- ✅ **Fixed**: Corrected request format to `application/x-www-form-urlencoded`
|
||||||
|
- ✅ **Resolved**: Added missing `FREEBIT_OEM_KEY` configuration
|
||||||
|
- Verify OEM ID and key configuration
|
||||||
|
- Check Freebit API endpoint accessibility
|
||||||
|
- Review authentication token expiry
|
||||||
|
|
||||||
|
**4. "404 Not Found" errors from frontend**
|
||||||
|
|
||||||
|
- ✅ **Fixed**: Updated all SIM components to use `authenticatedApi` utility
|
||||||
|
- ✅ **Fixed**: Corrected API base URL routing (port 3000 → 4000)
|
||||||
|
- ✅ **Cause**: Frontend was calling itself instead of the BFF server
|
||||||
|
- ✅ **Solution**: Use `NEXT_PUBLIC_API_BASE` environment variable properly
|
||||||
|
|
||||||
|
**5. "Cannot find module 'axios'" errors**
|
||||||
|
|
||||||
|
- ✅ **Fixed**: Migrated from `axios` to native `fetch` API
|
||||||
|
- ✅ **Reason**: Project uses `fetch` as standard HTTP client
|
||||||
|
- ✅ **Result**: Consistent HTTP handling across codebase
|
||||||
|
|
||||||
|
**6. Data usage not updating**
|
||||||
|
|
||||||
|
- Check Freebit API rate limits
|
||||||
|
- Verify account identifier format
|
||||||
|
- Review sync job logs
|
||||||
|
- ✅ **Added**: Enhanced error logging in Freebit service
|
||||||
|
|
||||||
|
### Support Contacts
|
||||||
|
|
||||||
|
- **Freebit API Issues**: Contact Freebit technical support
|
||||||
|
- **Portal Issues**: Check application logs and error tracking
|
||||||
|
- **Salesforce Integration**: Review field mapping and data sync jobs
|
||||||
|
|
||||||
|
## 🔄 Future Enhancements
|
||||||
|
|
||||||
|
### Planned Features
|
||||||
|
|
||||||
|
1. **Voice Options Management**
|
||||||
|
- Enable/disable voicemail
|
||||||
|
- Configure call forwarding
|
||||||
|
- International calling settings
|
||||||
|
|
||||||
|
2. **Usage Analytics**
|
||||||
|
- Monthly usage trends
|
||||||
|
- Cost optimization recommendations
|
||||||
|
- Usage prediction and alerts
|
||||||
|
|
||||||
|
3. **Bulk Operations**
|
||||||
|
- Multi-SIM management for business accounts
|
||||||
|
- Bulk data top-ups
|
||||||
|
- Group plan management
|
||||||
|
|
||||||
|
4. **Advanced Notifications**
|
||||||
|
- Low data alerts
|
||||||
|
- Usage milestone notifications
|
||||||
|
- Plan recommendation engine
|
||||||
|
|
||||||
|
### Integration Opportunities
|
||||||
|
|
||||||
|
1. **Payment Integration**: Direct payment for top-ups
|
||||||
|
2. **Support Integration**: Create support cases from SIM issues
|
||||||
|
3. **Billing Integration**: Usage-based billing reconciliation
|
||||||
|
4. **Analytics Integration**: Usage data for business intelligence
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Implementation Complete
|
||||||
|
|
||||||
|
The Freebit SIM management system is now fully implemented and ready for deployment. The system provides customers with complete self-service SIM management capabilities while maintaining proper data tracking and security standards.
|
||||||
|
|
||||||
|
### 🎯 Final Implementation Status
|
||||||
|
|
||||||
|
**✅ All Issues Resolved:**
|
||||||
|
|
||||||
|
- Backend Freebit API integration working
|
||||||
|
- Frontend components properly routing to BFF
|
||||||
|
- Environment configuration complete
|
||||||
|
- Error handling and logging implemented
|
||||||
|
- Debug tools available for troubleshooting
|
||||||
|
|
||||||
|
**✅ Deployment Ready:**
|
||||||
|
|
||||||
|
- Environment variables configured
|
||||||
|
- Servers running and tested
|
||||||
|
- API endpoints responding correctly
|
||||||
|
- Frontend UI components integrated
|
||||||
|
|
||||||
|
### 📋 Implementation Checklist
|
||||||
|
|
||||||
|
- [x] **Backend (BFF)**
|
||||||
|
- [x] Freebit API service implementation
|
||||||
|
- [x] SIM management service layer
|
||||||
|
- [x] REST API endpoints
|
||||||
|
- [x] Error handling and logging
|
||||||
|
- [x] Environment configuration
|
||||||
|
- [x] HTTP client migration (fetch)
|
||||||
|
|
||||||
|
- [x] **Frontend (Portal)**
|
||||||
|
- [x] SIM management components
|
||||||
|
- [x] Integration with subscription page
|
||||||
|
- [x] API routing fixes
|
||||||
|
- [x] Error handling and UX
|
||||||
|
- [x] Responsive design
|
||||||
|
|
||||||
|
- [x] **Configuration & Testing**
|
||||||
|
- [x] Environment variables
|
||||||
|
- [x] Freebit API credentials
|
||||||
|
- [x] Module registration
|
||||||
|
- [x] End-to-end testing
|
||||||
|
- [x] Debug endpoints
|
||||||
|
|
||||||
|
### 🚀 Next Steps (Optional)
|
||||||
|
|
||||||
|
1. ✅ ~~Configure Freebit API credentials~~ **DONE**
|
||||||
|
2. Add Salesforce custom fields (see custom fields section)
|
||||||
|
3. ✅ ~~Test with sample SIM subscriptions~~ **DONE**
|
||||||
|
4. Train customer support team
|
||||||
|
5. Deploy to production
|
||||||
|
|
||||||
|
### 📞 Support & Maintenance
|
||||||
|
|
||||||
|
**Development Sessions:**
|
||||||
|
|
||||||
|
- **Session 1 (GPT-4)**: Initial implementation, type definitions, core functionality
|
||||||
|
- **Session 2 (Claude Sonnet 4)**: Bug fixes, API routing, environment configuration, final testing
|
||||||
|
|
||||||
|
**For technical support or questions about this implementation:**
|
||||||
|
|
||||||
|
- Refer to the troubleshooting section above
|
||||||
|
- Check server logs for specific error messages
|
||||||
|
- Use the debug endpoint (`/api/subscriptions/{id}/sim/debug`) for account validation
|
||||||
|
- Contact the development team for advanced issues
|
||||||
|
|
||||||
|
## 📋 SIM Management Page Summary
|
||||||
|
|
||||||
|
### Complete API Mapping for `http://localhost:3000/subscriptions/29951#sim-management`
|
||||||
|
|
||||||
|
| UI Element | Component | Portal API | Freebit API | Data Transformation |
|
||||||
|
| ----------------------- | ----------------------- | ------------------------------------------------ | ----------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||||
|
| **SIM Details Card** | `SimDetailsCard.tsx` | `GET /api/subscriptions/29951/sim/details` | `PA03-02: Get Account Details` | Raw Freebit response → Formatted display with status badges |
|
||||||
|
| **Data Usage Chart** | `DataUsageChart.tsx` | `GET /api/subscriptions/29951/sim/usage` | `PA05-01: MVNO Communication Information` | Usage data → Progress bars and history charts |
|
||||||
|
| **Top Up Data Button** | `SimActions.tsx` | `POST /api/subscriptions/29951/sim/top-up` | `WHMCS: CreateInvoice + CapturePayment`<br>`PA04-04: Add Specs & Quota` | User input → Invoice creation → Payment capture → Freebit top-up |
|
||||||
|
| **Reissue eSIM Button** | `SimActions.tsx` | `POST /api/subscriptions/29951/sim/reissue-esim` | `PA05-42: eSIM Profile Reissue` | Confirmation → eSIM reissue request |
|
||||||
|
| **Cancel SIM Button** | `SimActions.tsx` | `POST /api/subscriptions/29951/sim/cancel` | `PA05-04: MVNO Plan Cancellation` | Confirmation → Cancellation request |
|
||||||
|
| **Change Plan Button** | `SimActions.tsx` | `POST /api/subscriptions/29951/sim/change-plan` | `PA05-21: MVNO Plan Change` | Plan selection → Plan change request |
|
||||||
|
| **Service Options** | `SimFeatureToggles.tsx` | `POST /api/subscriptions/29951/sim/features` | Various voice option APIs | Feature toggles → Service updates |
|
||||||
|
|
||||||
|
### Key Data Transformations:
|
||||||
|
|
||||||
|
1. **Status Mapping**: Freebit status → Portal status with color coding
|
||||||
|
2. **Plan Formatting**: Plan codes → Human-readable plan names
|
||||||
|
3. **Usage Visualization**: Raw KB data → MB/GB with progress bars
|
||||||
|
4. **Date Formatting**: ISO dates → User-friendly date displays
|
||||||
|
5. **Error Handling**: Freebit errors → User-friendly error messages
|
||||||
|
|
||||||
|
### Real-time Updates:
|
||||||
|
|
||||||
|
- All actions trigger data refresh via `handleActionSuccess()`
|
||||||
|
- Loading states prevent duplicate actions
|
||||||
|
- Success/error messages provide immediate feedback
|
||||||
|
- Automatic retry on network failures
|
||||||
|
|
||||||
|
## 🔄 **Recent Implementation: Complete Top-Up Payment Flow**
|
||||||
|
|
||||||
|
### ✅ **What Was Added (January 2025)**:
|
||||||
|
|
||||||
|
#### **WHMCS Invoice Creation & Payment Capture**
|
||||||
|
|
||||||
|
- ✅ **New WHMCS API Types**: `WhmcsCreateInvoiceParams`, `WhmcsCapturePaymentParams`, etc.
|
||||||
|
- ✅ **WhmcsConnectionService**: Added `createInvoice()` and `capturePayment()` methods
|
||||||
|
- ✅ **WhmcsInvoiceService**: Added invoice creation and payment processing
|
||||||
|
- ✅ **WhmcsService**: Exposed new invoice and payment methods
|
||||||
|
|
||||||
|
#### **Enhanced SIM Management Service**
|
||||||
|
|
||||||
|
- ✅ **Payment Integration**: `SimManagementService.topUpSim()` now includes full payment flow
|
||||||
|
- ✅ **Pricing Logic**: 1GB = 500 JPY calculation
|
||||||
|
- ✅ **Error Handling**: Payment failures prevent data addition
|
||||||
|
- ✅ **Transaction Logging**: Complete audit trail for payments and top-ups
|
||||||
|
|
||||||
|
#### **Complete Flow Implementation**
|
||||||
|
|
||||||
|
```
|
||||||
|
User Action → Cost Calculation → Invoice Creation → Payment Capture → Data Addition
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📊 **Pricing Structure**
|
||||||
|
|
||||||
|
- **1 GB = ¥500**
|
||||||
|
- **2 GB = ¥1,000**
|
||||||
|
- **5 GB = ¥2,500**
|
||||||
|
- **10 GB = ¥5,000**
|
||||||
|
|
||||||
|
### ⚠️ **Error Handling**:
|
||||||
|
|
||||||
|
- **Payment Failed**: No data added, user notified
|
||||||
|
- **Freebit Failed**: Payment captured but data not added (requires manual intervention)
|
||||||
|
- **Invoice Creation Failed**: No charge, no data added
|
||||||
|
|
||||||
|
### 📝 **Implementation Files Modified**:
|
||||||
|
|
||||||
|
1. `apps/bff/src/vendors/whmcs/types/whmcs-api.types.ts` - Added WHMCS API types
|
||||||
|
2. `apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts` - Added API methods
|
||||||
|
3. `apps/bff/src/vendors/whmcs/services/whmcs-invoice.service.ts` - Added invoice creation
|
||||||
|
4. `apps/bff/src/vendors/whmcs/whmcs.service.ts` - Exposed new methods
|
||||||
|
5. `apps/bff/src/subscriptions/sim-management.service.ts` - Complete payment flow
|
||||||
|
|
||||||
|
## 🎯 **Latest Update: Simplified Top-Up Interface (January 2025)**
|
||||||
|
|
||||||
|
### ✅ **Interface Improvements**:
|
||||||
|
|
||||||
|
#### **Simplified Top-Up Modal**
|
||||||
|
|
||||||
|
- ✅ **Custom GB Input**: Users can now enter any amount of GB (0.1 - 100 GB)
|
||||||
|
- ✅ **Real-time Cost Calculation**: Shows JPY cost as user types (1GB = 500 JPY)
|
||||||
|
- ✅ **Removed Complexity**: No more preset buttons, campaign codes, or scheduling
|
||||||
|
- ✅ **Cleaner UX**: Single input field with immediate cost feedback
|
||||||
|
|
||||||
|
#### **Updated Backend**
|
||||||
|
|
||||||
|
- ✅ **Simplified API**: Only requires `quotaMb` parameter
|
||||||
|
- ✅ **Removed Optional Fields**: No more `campaignCode`, `expiryDate`, or `scheduledAt`
|
||||||
|
- ✅ **Streamlined Processing**: Direct payment → data addition flow
|
||||||
|
|
||||||
|
#### **New User Experience**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User clicks "Top Up Data"
|
||||||
|
2. Enters desired GB amount (e.g., "2.5")
|
||||||
|
3. Sees real-time cost calculation (¥1,250)
|
||||||
|
4. Clicks "Top Up Now - ¥1,250"
|
||||||
|
5. Payment processed → Data added
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📊 **Interface Changes**:
|
||||||
|
|
||||||
|
| **Before** | **After** |
|
||||||
|
| -------------------------------------- | ---------------------------------- |
|
||||||
|
| 6 preset buttons (1GB, 2GB, 5GB, etc.) | Single GB input field (0.1-100 GB) |
|
||||||
|
| Campaign code input | Removed |
|
||||||
|
| Schedule date picker | Removed |
|
||||||
|
| Complex validation | Simple amount validation |
|
||||||
|
| Multiple form fields | Single input + cost display |
|
||||||
|
|
||||||
|
**🏆 The SIM management system is now production-ready with complete payment processing and simplified user interface!**
|
||||||
509
sim-manager-migration/docs/SIM-MANAGEMENT-API-DATA-FLOW.md
Normal file
509
sim-manager-migration/docs/SIM-MANAGEMENT-API-DATA-FLOW.md
Normal file
@ -0,0 +1,509 @@
|
|||||||
|
# SIM Management Page - API Data Flow & System Architecture
|
||||||
|
|
||||||
|
_Technical documentation explaining the API integration and data flow for the SIM Management interface_
|
||||||
|
|
||||||
|
**Purpose**: This document provides a detailed explanation of how the SIM Management page retrieves, processes, and displays data through various API integrations.
|
||||||
|
|
||||||
|
**Audience**: Management, Technical Teams, System Architects
|
||||||
|
**Last Updated**: September 2025
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Executive Summary
|
||||||
|
|
||||||
|
Change Log (2025-09-05)
|
||||||
|
|
||||||
|
- Adopted official Freebit API names across all callouts (e.g., "Add Specs & Quota", "MVNO Plan Change").
|
||||||
|
- Added Freebit API Quick Reference (Portal Operations) table.
|
||||||
|
- Documented Top‑Up Payment Flow (WHMCS invoice + auto‑capture then Freebit AddSpec).
|
||||||
|
- Listed additional Freebit APIs not used by the portal today.
|
||||||
|
|
||||||
|
The SIM Management page integrates with multiple backend systems to provide real-time SIM data, usage statistics, and management capabilities. The system uses a **Backend-for-Frontend (BFF)** architecture that aggregates data from Freebit APIs and WHMCS, providing a unified interface for SIM management operations.
|
||||||
|
|
||||||
|
### Key Systems Integration:
|
||||||
|
|
||||||
|
- **WHMCS**: Subscription and billing data
|
||||||
|
- **Freebit API**: SIM details, usage, and management operations
|
||||||
|
- **Customer Portal BFF**: Data aggregation and API orchestration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ System Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Customer Portal Frontend │
|
||||||
|
│ (Next.js - Port 3000) │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ SIM Management Page Components: │
|
||||||
|
│ • SimManagementSection.tsx │
|
||||||
|
│ • SimDetailsCard.tsx │
|
||||||
|
│ • DataUsageChart.tsx │
|
||||||
|
│ • SimActions.tsx │
|
||||||
|
│ • SimFeatureToggles.tsx │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ HTTP Requests
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Backend-for-Frontend (BFF) │
|
||||||
|
│ (Port 4000) │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ API Endpoints: │
|
||||||
|
│ • /api/subscriptions/{id}/sim │
|
||||||
|
│ • /api/subscriptions/{id}/sim/details │
|
||||||
|
│ • /api/subscriptions/{id}/sim/usage │
|
||||||
|
│ • /api/subscriptions/{id}/sim/top-up │
|
||||||
|
│ • /api/subscriptions/{id}/sim/top-up-history │
|
||||||
|
│ • /api/subscriptions/{id}/sim/change-plan │
|
||||||
|
│ • /api/subscriptions/{id}/sim/features │
|
||||||
|
│ • /api/subscriptions/{id}/sim/cancel │
|
||||||
|
│ • /api/subscriptions/{id}/sim/reissue-esim │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ Data Aggregation
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ External Systems │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ WHMCS │ │ Freebit API │ │
|
||||||
|
│ │ (Billing) │ │ (SIM Services) │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ • Subscriptions │ │ • SIM Details │ │
|
||||||
|
│ │ • Customer Data │ │ • Usage Data │ │
|
||||||
|
│ │ • Billing Info │ │ • Management │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Data Flow by Section
|
||||||
|
|
||||||
|
### 1. **SIM Management Actions Section**
|
||||||
|
|
||||||
|
**Purpose**: Provides action buttons for SIM operations (Top Up, Reissue, Cancel, Change Plan)
|
||||||
|
|
||||||
|
**Data Sources**:
|
||||||
|
|
||||||
|
- **WHMCS**: Subscription status and customer permissions
|
||||||
|
- **Freebit API**: SIM type (physical/eSIM) and current status
|
||||||
|
|
||||||
|
**API Calls**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Initial Load - Get SIM details for action availability
|
||||||
|
GET / api / subscriptions / { id } / sim / details;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data Flow**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Frontend │ │ BFF │ │ Freebit API │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ SimActions.tsx │───▶│ /sim/details │───▶│ /mvno/getDetail/│
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ • Check SIM │ │ • Authenticate │ │ • Return SIM │
|
||||||
|
│ type & status │ │ • Map response │ │ details │
|
||||||
|
│ • Enable/disable│ │ • Handle errors │ │ • Status info │
|
||||||
|
│ buttons │ │ │ │ │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Action-Specific APIs**:
|
||||||
|
|
||||||
|
- **Top Up Data**: `POST /api/subscriptions/{id}/sim/top-up` → Freebit `/master/addSpec/`
|
||||||
|
- **Reissue eSIM**: `POST /api/subscriptions/{id}/sim/reissue-esim` → Freebit `/mvno/esim/addAcnt/`
|
||||||
|
- **Cancel SIM**: `POST /api/subscriptions/{id}/sim/cancel` → Freebit `/mvno/releasePlan/`
|
||||||
|
- **Change Plan**: `POST /api/subscriptions/{id}/sim/change-plan` → Freebit `/mvno/changePlan/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **eSIM Details Card (Right Sidebar)**
|
||||||
|
|
||||||
|
**Purpose**: Displays essential SIM information in compact format
|
||||||
|
|
||||||
|
**Data Sources**:
|
||||||
|
|
||||||
|
- **WHMCS**: Subscription product name and billing info
|
||||||
|
- **Freebit API**: SIM technical details and status
|
||||||
|
|
||||||
|
**API Calls**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get comprehensive SIM information
|
||||||
|
GET / api / subscriptions / { id } / sim;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data Flow**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Frontend │ │ BFF │ │ External │
|
||||||
|
│ │ │ Systems │ │ Systems │
|
||||||
|
│ SimDetailsCard │───▶│ /sim │───▶│ ┌─────────────┐ │
|
||||||
|
│ │ │ │ │ │ WHMCS │ │
|
||||||
|
│ • Phone number │ │ • Aggregate │ │ │ • Product │ │
|
||||||
|
│ • Data remaining│ │ data from │ │ │ name │ │
|
||||||
|
│ • Service status│ │ multiple │ │ │ • Billing │ │
|
||||||
|
│ • Plan info │ │ sources │ │ └─────────────┘ │
|
||||||
|
│ │ │ • Transform │ │ ┌─────────────┐ │
|
||||||
|
│ │ │ responses │ │ │ Freebit │ │
|
||||||
|
│ │ │ • Handle errors │ │ │ • ICCID │ │
|
||||||
|
│ │ │ │ │ │ • MSISDN │ │
|
||||||
|
│ │ │ │ │ │ • Status │ │
|
||||||
|
│ │ │ │ │ │ • Plan code │ │
|
||||||
|
│ │ │ │ │ └─────────────┘ │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data Mapping**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BFF Response Structure
|
||||||
|
{
|
||||||
|
"details": {
|
||||||
|
"iccid": "8944504101234567890", // From Freebit
|
||||||
|
"msisdn": "08077052946", // From Freebit
|
||||||
|
"planCode": "PASI_50G", // From Freebit
|
||||||
|
"status": "active", // From Freebit
|
||||||
|
"simType": "esim", // From Freebit
|
||||||
|
"productName": "SonixNet SIM Service", // From WHMCS
|
||||||
|
"remainingQuotaMb": 48256 // Calculated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Data Usage Chart (Right Sidebar)**
|
||||||
|
|
||||||
|
**Purpose**: Visual representation of data consumption and remaining quota
|
||||||
|
|
||||||
|
**Data Sources**:
|
||||||
|
|
||||||
|
- **Freebit API**: Real-time usage statistics and quota information
|
||||||
|
|
||||||
|
**API Calls**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get usage data
|
||||||
|
GET / api / subscriptions / { id } / sim / usage;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data Flow**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Frontend │ │ BFF │ │ Freebit API │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ DataUsageChart │───▶│ /sim/usage │───▶│ /mvno/getTraffic│
|
||||||
|
│ │ │ │ │ Info/ │
|
||||||
|
│ • Progress bar │ │ • Authenticate │ │ │
|
||||||
|
│ • Usage stats │ │ • Format data │ │ • Today's usage │
|
||||||
|
│ • History chart │ │ • Calculate │ │ • Total quota │
|
||||||
|
│ • Remaining GB │ │ percentages │ │ • Usage history │
|
||||||
|
│ │ │ • Handle errors │ │ │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data Processing**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Freebit API Response
|
||||||
|
{
|
||||||
|
"todayUsageMb": 748.47,
|
||||||
|
"totalQuotaMb": 51200,
|
||||||
|
"usageHistory": [
|
||||||
|
{ "date": "2025-01-04", "usageMb": 1228.8 },
|
||||||
|
{ "date": "2025-01-03", "usageMb": 595.2 },
|
||||||
|
{ "date": "2025-01-02", "usageMb": 448.0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// BFF Processing
|
||||||
|
const usagePercentage = (usedMb / totalQuotaMb) * 100;
|
||||||
|
const remainingMb = totalQuotaMb - usedMb;
|
||||||
|
const formattedRemaining = formatQuota(remainingMb); // "47.1 GB"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **Plan & Service Options**
|
||||||
|
|
||||||
|
**Purpose**: Manage SIM plan and optional features (Voice Mail, Call Waiting, International Roaming, 4G/5G).
|
||||||
|
|
||||||
|
**Data Sources**:
|
||||||
|
|
||||||
|
- **Freebit API**: Current service settings and options
|
||||||
|
- **WHMCS**: Plan catalog and billing context
|
||||||
|
|
||||||
|
**API Calls**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get current service settings
|
||||||
|
GET / api / subscriptions / { id } / sim / details;
|
||||||
|
|
||||||
|
// Update optional features (flags)
|
||||||
|
POST / api / subscriptions / { id } / sim / features;
|
||||||
|
|
||||||
|
// Change plan
|
||||||
|
POST / api / subscriptions / { id } / sim / change - plan;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data Flow**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────────────┐
|
||||||
|
│ Frontend │ │ BFF │ │ Freebit API │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ SimFeatureToggles│───▶│ /sim/details │───▶│ /mvno/getDetail/ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ Apply Changes │───▶│ /sim/features │───▶│ /master/addSpec/ (flags) │
|
||||||
|
│ Change Plan │───▶│ /sim/change-plan│───▶│ /mvno/changePlan/ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ • Validate │ │ • Authenticate │ │ • Apply changes │
|
||||||
|
│ • Update UI │ │ • Transform │ │ • Return resultCode=100 │
|
||||||
|
│ • Refresh data │ │ • Handle errors │ │ │
|
||||||
|
└─────────────────┘ └─────────────────┘ └──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed plans and mapping
|
||||||
|
|
||||||
|
- The portal currently supports the following SIM data plans from Salesforce:
|
||||||
|
- SIM Data-only 5GB → Freebit planCode `PASI_5G`
|
||||||
|
- SIM Data-only 10GB → `PASI_10G`
|
||||||
|
- SIM Data-only 25GB → `PASI_25G`
|
||||||
|
- SIM Data-only 50GB → `PASI_50G`
|
||||||
|
- UI behavior: The Change Plan action lives inside the “SIM Management Actions” card. Clicking it opens a modal listing only “other” plans. For example, if the current plan is `PASI_50G`, options will be 5GB, 10GB, 25GB. If the current plan is not 50GB, the 50GB option is included.
|
||||||
|
- Request payload sent to BFF:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"newPlanCode": "PASI_25G"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- BFF calls MVNO Plan Change with fields per the API spec (account, planCode, optional globalIP, optional runTime).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. **Top-Up Payment Flow (Invoice + Auto-Capture)**
|
||||||
|
|
||||||
|
When a user tops up data, the portal bills through WHMCS before applying the quota via Freebit. Unit price is fixed: 1 GB = ¥500.
|
||||||
|
|
||||||
|
Endpoints used
|
||||||
|
|
||||||
|
- Frontend → BFF: `POST /api/subscriptions/{id}/sim/top-up` with `{ quotaMb, campaignCode?, expiryDate? }`
|
||||||
|
- BFF → WHMCS: `createInvoice` then `capturePayment` (gateway-selected SSO or stored method)
|
||||||
|
- BFF → Freebit: `PA04-04 Add Spec & Quota` (`/master/addSpec/`) if payment succeeds
|
||||||
|
|
||||||
|
Pricing
|
||||||
|
|
||||||
|
- Amount in JPY = ceil(quotaMb / 1000) × 500
|
||||||
|
- Example: 1000MB → ¥500, 3000MB → ¥1,500
|
||||||
|
|
||||||
|
Happy-path sequence
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend BFF WHMCS Freebit
|
||||||
|
────────── ──────────────── ──────────────── ────────────────
|
||||||
|
TopUpModal ───────▶ POST /sim/top-up ───────▶ createInvoice ─────▶
|
||||||
|
(quotaMb) (validate + map) (amount=ceil(MB/1000)*500)
|
||||||
|
│ │
|
||||||
|
│ invoiceId
|
||||||
|
▼ │
|
||||||
|
capturePayment ───────────────▶ │
|
||||||
|
│ paid (or failed)
|
||||||
|
├── on success ─────────────────────────────▶ /master/addSpec/
|
||||||
|
│ (quota in MB)
|
||||||
|
└── on failure ──┐
|
||||||
|
└──── return error (no Freebit call)
|
||||||
|
```
|
||||||
|
|
||||||
|
Failure handling
|
||||||
|
|
||||||
|
- If `capturePayment` fails, BFF responds with 402/400 and does NOT call Freebit. UI shows error and invoice link for manual payment.
|
||||||
|
- If Freebit returns non-100 `resultCode`, BFF logs, returns 502/500, and may void/refund invoice in future enhancement.
|
||||||
|
|
||||||
|
BFF responsibilities
|
||||||
|
|
||||||
|
- Validate `quotaMb` (1–100000)
|
||||||
|
- Price computation and invoice line creation (description includes quota)
|
||||||
|
- Attempt payment capture (stored method or SSO handoff)
|
||||||
|
- On success, call Freebit AddSpec with `quota` in MB (string) and optional `expire`
|
||||||
|
- Return success to UI and refresh SIM info
|
||||||
|
|
||||||
|
Freebit PA04-04 (Add Spec & Quota) request fields
|
||||||
|
|
||||||
|
- `account`: MSISDN (phone number)
|
||||||
|
- `quota`: integer MB (string) (100MB–51200MB)
|
||||||
|
- `quotaCode` (optional): campaign code
|
||||||
|
- `expire` (optional): YYYYMMDD
|
||||||
|
|
||||||
|
Notes
|
||||||
|
|
||||||
|
- Scheduled top-ups use `/mvno/eachQuota/` with `runTime`; immediate uses `/master/addSpec/`.
|
||||||
|
- For development, amounts and gateway can be simulated; production requires real WHMCS gateway configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Real-Time Data Updates
|
||||||
|
|
||||||
|
### Automatic Refresh Mechanism
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// After any action (top-up, cancel, etc.)
|
||||||
|
const handleActionSuccess = () => {
|
||||||
|
// Refresh all data
|
||||||
|
refetchSimDetails();
|
||||||
|
refetchUsageData();
|
||||||
|
refetchSubscriptionData();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Consistency
|
||||||
|
|
||||||
|
- **Immediate Updates**: UI updates optimistically
|
||||||
|
- **Background Sync**: Real data fetched after actions
|
||||||
|
- **Error Handling**: Rollback on API failures
|
||||||
|
- **Loading States**: Visual feedback during operations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Performance Considerations
|
||||||
|
|
||||||
|
### Caching Strategy
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BFF Level Caching
|
||||||
|
- SIM Details: 5 minutes TTL
|
||||||
|
- Usage Data: 1 minute TTL
|
||||||
|
- Subscription Info: 10 minutes TTL
|
||||||
|
|
||||||
|
// Frontend Caching
|
||||||
|
- React Query: 30 seconds stale time
|
||||||
|
- Background refetch: Every 2 minutes
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Optimization
|
||||||
|
|
||||||
|
- **Batch Requests**: Single endpoint for comprehensive data
|
||||||
|
- **Selective Updates**: Only refresh changed sections
|
||||||
|
- **Error Recovery**: Retry failed requests with exponential backoff
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Security & Authentication
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Frontend │ │ BFF │ │ External │
|
||||||
|
│ │ │ │ │ Systems │
|
||||||
|
│ • JWT Token │───▶│ • Validate JWT │───▶│ • WHMCS API Key │
|
||||||
|
│ • User Context │ │ • Map to WHMCS │ │ • Freebit Auth │
|
||||||
|
│ • Permissions │ │ Client ID │ │ • Rate Limiting │
|
||||||
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Protection
|
||||||
|
|
||||||
|
- **Input Validation**: All user inputs sanitized
|
||||||
|
- **Rate Limiting**: API calls throttled per user
|
||||||
|
- **Audit Logging**: All actions logged for compliance
|
||||||
|
- **Error Masking**: Sensitive data not exposed in errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Monitoring & Analytics
|
||||||
|
|
||||||
|
### Key Metrics Tracked
|
||||||
|
|
||||||
|
- **API Response Times**: < 500ms target
|
||||||
|
- **Error Rates**: < 1% target
|
||||||
|
- **User Actions**: Top-up frequency, plan changes
|
||||||
|
- **Data Usage Patterns**: Peak usage times, quota consumption
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// BFF Health Endpoints
|
||||||
|
GET / health / sim - management;
|
||||||
|
GET / health / freebit - api;
|
||||||
|
GET / health / whmcs - api;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Future Enhancements
|
||||||
|
|
||||||
|
### Planned Improvements
|
||||||
|
|
||||||
|
1. **Real-time WebSocket Updates**: Live usage data without refresh
|
||||||
|
2. **Advanced Analytics**: Usage predictions and recommendations
|
||||||
|
3. **Bulk Operations**: Manage multiple SIMs simultaneously
|
||||||
|
4. **Mobile App Integration**: Native mobile SIM management
|
||||||
|
|
||||||
|
### Scalability Considerations
|
||||||
|
|
||||||
|
- **Microservices**: Split BFF into domain-specific services
|
||||||
|
- **CDN Integration**: Cache static SIM data globally
|
||||||
|
- **Database Optimization**: Implement read replicas for usage data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Support & Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **API Timeouts**: Check Freebit API status
|
||||||
|
2. **Data Inconsistency**: Verify WHMCS sync
|
||||||
|
3. **Authentication Errors**: Validate JWT tokens
|
||||||
|
4. **Rate Limiting**: Monitor API quotas
|
||||||
|
|
||||||
|
### Debug Endpoints
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Development only
|
||||||
|
GET / api / subscriptions / { id } / sim / debug;
|
||||||
|
GET / api / health / sim - management / detailed;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 **Summary for Your Managers**
|
||||||
|
|
||||||
|
This comprehensive documentation explains:
|
||||||
|
|
||||||
|
### **🏗️ System Architecture**
|
||||||
|
|
||||||
|
- **3-Tier Architecture**: Frontend → BFF → External APIs (WHMCS + Freebit)
|
||||||
|
- **Data Aggregation**: BFF combines data from multiple sources
|
||||||
|
- **Real-time Updates**: Automatic refresh after user actions
|
||||||
|
|
||||||
|
### **📊 Key Data Flows**
|
||||||
|
|
||||||
|
1. **SIM Actions**: Button availability based on SIM type and status
|
||||||
|
2. **SIM Details**: Phone number, data remaining, service status
|
||||||
|
3. **Usage Chart**: Real-time consumption and quota visualization
|
||||||
|
4. **Service Options**: Voice mail, call waiting, roaming settings
|
||||||
|
|
||||||
|
### **🔧 Technical Benefits**
|
||||||
|
|
||||||
|
- **Performance**: Caching and optimized API calls
|
||||||
|
- **Security**: JWT authentication and input validation
|
||||||
|
- **Reliability**: Error handling and retry mechanisms
|
||||||
|
- **Monitoring**: Health checks and performance metrics
|
||||||
|
|
||||||
|
### **💼 Business Value**
|
||||||
|
|
||||||
|
- **User Experience**: Real-time data and intuitive interface
|
||||||
|
- **Operational Efficiency**: Automated SIM management operations
|
||||||
|
- **Data Accuracy**: Direct integration with Freebit and WHMCS
|
||||||
|
- **Scalability**: Architecture supports future enhancements
|
||||||
|
|
||||||
|
This documentation will help your managers understand the technical complexity and business value of the SIM Management system!
|
||||||
125
sim-manager-migration/frontend/components/ChangePlanModal.tsx
Normal file
125
sim-manager-migration/frontend/components/ChangePlanModal.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { mapToSimplifiedFormat } from "../utils/plan";
|
||||||
|
|
||||||
|
interface ChangePlanModalProps {
|
||||||
|
subscriptionId: number;
|
||||||
|
currentPlanCode?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChangePlanModal({
|
||||||
|
subscriptionId,
|
||||||
|
currentPlanCode,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: ChangePlanModalProps) {
|
||||||
|
const PLAN_CODES = ["5GB", "10GB", "25GB", "50GB"] as const;
|
||||||
|
type PlanCode = (typeof PLAN_CODES)[number];
|
||||||
|
|
||||||
|
const normalizedCurrentPlan = mapToSimplifiedFormat(currentPlanCode);
|
||||||
|
|
||||||
|
const allowedPlans = (PLAN_CODES as readonly PlanCode[]).filter(
|
||||||
|
code => code !== (normalizedCurrentPlan as PlanCode)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!newPlanCode) {
|
||||||
|
onError("Please select a new plan");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await apiClient.POST("/api/subscriptions/{id}/sim/change-plan", {
|
||||||
|
params: { path: { id: subscriptionId } },
|
||||||
|
body: {
|
||||||
|
newPlanCode,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
onError(e instanceof Error ? e.message : "Failed to change plan");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||||
|
​
|
||||||
|
</span>
|
||||||
|
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||||
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
|
<div className="sm:flex sm:items-start">
|
||||||
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg leading-6 font-medium text-gray-900">Change SIM Plan</h3>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<XMarkIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
Select New Plan
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={newPlanCode}
|
||||||
|
onChange={e => setNewPlanCode(e.target.value as PlanCode)}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Choose a plan</option>
|
||||||
|
{allowedPlans.map(code => (
|
||||||
|
<option key={code} value={code}>
|
||||||
|
{code}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Only plans different from your current plan are listed. The change will be
|
||||||
|
scheduled for the 1st of the next month.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void submit()}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Processing..." : "Change Plan"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
sim-manager-migration/frontend/components/DataUsageChart.tsx
Normal file
257
sim-manager-migration/frontend/components/DataUsageChart.tsx
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
export interface SimUsage {
|
||||||
|
account: string;
|
||||||
|
todayUsageKb: number;
|
||||||
|
todayUsageMb: number;
|
||||||
|
recentDaysUsage: Array<{
|
||||||
|
date: string;
|
||||||
|
usageKb: number;
|
||||||
|
usageMb: number;
|
||||||
|
}>;
|
||||||
|
isBlacklisted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataUsageChartProps {
|
||||||
|
usage: SimUsage;
|
||||||
|
remainingQuotaMb: number;
|
||||||
|
isLoading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
embedded?: boolean; // when true, render content without card container
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataUsageChart({
|
||||||
|
usage,
|
||||||
|
remainingQuotaMb,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
embedded = false,
|
||||||
|
}: DataUsageChartProps) {
|
||||||
|
const formatUsage = (usageMb: number) => {
|
||||||
|
if (usageMb >= 1000) {
|
||||||
|
return `${(usageMb / 1000).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
return `${usageMb.toFixed(0)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUsageColor = (percentage: number) => {
|
||||||
|
if (percentage >= 90) return "bg-red-500";
|
||||||
|
if (percentage >= 75) return "bg-yellow-500";
|
||||||
|
if (percentage >= 50) return "bg-orange-500";
|
||||||
|
return "bg-green-500";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUsageTextColor = (percentage: number) => {
|
||||||
|
if (percentage >= 90) return "text-red-600";
|
||||||
|
if (percentage >= 75) return "text-yellow-600";
|
||||||
|
if (percentage >= 50) return "text-orange-600";
|
||||||
|
return "text-green-600";
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={`${embedded ? "" : "bg-white shadow rounded-lg "}p-6`}>
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||||
|
<div className="h-8 bg-gray-200 rounded mb-4"></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={`${embedded ? "" : "bg-white shadow rounded-lg "}p-6`}>
|
||||||
|
<div className="text-center">
|
||||||
|
<ExclamationTriangleIcon className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">Error Loading Usage Data</h3>
|
||||||
|
<p className="text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total usage from recent days (assume it includes today)
|
||||||
|
const totalRecentUsage =
|
||||||
|
usage.recentDaysUsage.reduce((sum, day) => sum + day.usageMb, 0) + usage.todayUsageMb;
|
||||||
|
const totalQuota = remainingQuotaMb + totalRecentUsage;
|
||||||
|
const usagePercentage = totalQuota > 0 ? (totalRecentUsage / totalQuota) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300"}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`${embedded ? "" : "px-6 lg:px-8 py-5 border-b border-gray-200"}`}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
||||||
|
<ChartBarIcon className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900">Data Usage</h3>
|
||||||
|
<p className="text-sm text-gray-600">Current month usage and remaining quota</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
|
||||||
|
{/* Current Usage Overview */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700">Used this month</span>
|
||||||
|
<span className={`text-sm font-semibold ${getUsageTextColor(usagePercentage)}`}>
|
||||||
|
{formatUsage(totalRecentUsage)} of {formatUsage(totalQuota)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<div
|
||||||
|
className={`h-3 rounded-full transition-all duration-300 ${getUsageColor(usagePercentage)}`}
|
||||||
|
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||||
|
<span>0%</span>
|
||||||
|
<span className={getUsageTextColor(usagePercentage)}>
|
||||||
|
{usagePercentage.toFixed(1)}% used
|
||||||
|
</span>
|
||||||
|
<span>100%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Today's Usage */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
|
||||||
|
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl p-6 border border-blue-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl font-bold text-blue-600">
|
||||||
|
{formatUsage(usage.todayUsageMb)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-blue-700 mt-1">Used today</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-200 rounded-full p-3">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-br from-green-50 to-green-100 rounded-xl p-6 border border-green-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl font-bold text-green-600">
|
||||||
|
{formatUsage(remainingQuotaMb)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-green-700 mt-1">Remaining</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-green-200 rounded-full p-3">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-green-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M20 12H4m16 0l-4 4m4-4l-4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Days Usage */}
|
||||||
|
{usage.recentDaysUsage.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||||
|
Recent Usage History
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{usage.recentDaysUsage.slice(0, 5).map((day, index) => {
|
||||||
|
const dayPercentage = totalQuota > 0 ? (day.usageMb / totalQuota) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex items-center justify-between py-2">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{new Date(day.date).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-24 bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${Math.min(dayPercentage, 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-900 w-16 text-right">
|
||||||
|
{formatUsage(day.usageMb)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
|
|
||||||
|
{usagePercentage >= 90 && (
|
||||||
|
<div className="mt-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mr-2" />
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-red-800">High Usage Warning</h4>
|
||||||
|
<p className="text-sm text-red-700 mt-1">
|
||||||
|
You have used {usagePercentage.toFixed(1)}% of your data quota. Consider topping
|
||||||
|
up to avoid service interruption.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{usagePercentage >= 75 && usagePercentage < 90 && (
|
||||||
|
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 mr-2" />
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-yellow-800">Usage Notice</h4>
|
||||||
|
<p className="text-sm text-yellow-700 mt-1">
|
||||||
|
You have used {usagePercentage.toFixed(1)}% of your data quota. Consider
|
||||||
|
monitoring your usage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
sim-manager-migration/frontend/components/ReissueSimModal.tsx
Normal file
221
sim-manager-migration/frontend/components/ReissueSimModal.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { ArrowPathIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
|
||||||
|
|
||||||
|
type SimKind = "physical" | "esim";
|
||||||
|
|
||||||
|
interface ReissueSimModalProps {
|
||||||
|
subscriptionId: number;
|
||||||
|
currentSimType: SimKind;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMPORTANT_POINTS: string[] = [
|
||||||
|
"The reissue request cannot be reversed.",
|
||||||
|
"Service to the existing SIM will be terminated with immediate effect.",
|
||||||
|
"A fee of 1,500 yen + tax will be incurred.",
|
||||||
|
"For physical SIM: allow approximately 3-5 business days for shipping.",
|
||||||
|
"For eSIM: activation typically completes within 30-60 minutes after processing.",
|
||||||
|
];
|
||||||
|
|
||||||
|
const EID_HELP = "Enter the 32-digit EID (numbers only). Leave blank to reuse Freebit's generated EID.";
|
||||||
|
|
||||||
|
export function ReissueSimModal({
|
||||||
|
subscriptionId,
|
||||||
|
currentSimType,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
}: ReissueSimModalProps) {
|
||||||
|
const [selectedSimType, setSelectedSimType] = useState<SimKind>(currentSimType);
|
||||||
|
const [newEid, setNewEid] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isEsimSelected = selectedSimType === "esim";
|
||||||
|
const isPhysicalSelected = selectedSimType === "physical";
|
||||||
|
|
||||||
|
const disableSubmit = useMemo(() => {
|
||||||
|
if (isPhysicalSelected) {
|
||||||
|
return false; // Allow click to show guidance message
|
||||||
|
}
|
||||||
|
if (!isEsimSelected) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!newEid) {
|
||||||
|
return false; // Optional – backend supports auto EID
|
||||||
|
}
|
||||||
|
return !/^\d{32}$/.test(newEid.trim());
|
||||||
|
}, [isPhysicalSelected, isEsimSelected, newEid]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (isPhysicalSelected) {
|
||||||
|
setValidationError(
|
||||||
|
"Physical SIM reissue cannot be requested online yet. Please contact support for assistance."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEsimSelected && newEid && !/^\d{32}$/.test(newEid.trim())) {
|
||||||
|
setValidationError("EID must be 32 digits.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidationError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await simActionsService.reissueEsim(String(subscriptionId), {
|
||||||
|
newEid: newEid.trim() || undefined,
|
||||||
|
});
|
||||||
|
onSuccess();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to submit reissue request";
|
||||||
|
onError(message);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-gray-500 bg-opacity-75" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div className="relative z-10 w-full max-w-2xl rounded-lg border border-gray-200 bg-white shadow-2xl">
|
||||||
|
<div className="px-6 pt-6 pb-4 sm:px-8 sm:pb-6">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100">
|
||||||
|
<ArrowPathIcon className="h-6 w-6 text-green-600" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Reissue SIM</h3>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Submit a reissue request for your SIM. Review the important information before continuing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 transition hover:text-gray-600"
|
||||||
|
aria-label="Close reissue SIM modal"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||||
|
<h4 className="text-sm font-semibold text-amber-800">Important information</h4>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-amber-900">
|
||||||
|
{IMPORTANT_POINTS.map(point => (
|
||||||
|
<li key={point}>{point}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-6 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Select SIM type</label>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<label className="flex items-start gap-3 rounded-lg border border-gray-200 p-3">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sim-type"
|
||||||
|
value="physical"
|
||||||
|
checked={selectedSimType === "physical"}
|
||||||
|
onChange={() => setSelectedSimType("physical")}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">Physical SIM</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
We’ll ship a replacement SIM card. Currently, online requests are not available; contact support to proceed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 rounded-lg border border-gray-200 p-3">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sim-type"
|
||||||
|
value="esim"
|
||||||
|
checked={selectedSimType === "esim"}
|
||||||
|
onChange={() => setSelectedSimType("esim")}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">eSIM</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Generate a new eSIM activation profile. You’ll receive new QR code details once processing completes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-200 p-4 text-sm text-gray-600">
|
||||||
|
<p>
|
||||||
|
Current SIM type: <strong className="uppercase">{currentSimType}</strong>
|
||||||
|
</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
The selection above lets you specify which type of replacement you need. If you choose a physical SIM, a support agent will contact you to finalise the process.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEsimSelected && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<label htmlFor="new-eid" className="block text-sm font-medium text-gray-700">
|
||||||
|
New EID (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-eid"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
value={newEid}
|
||||||
|
onChange={event => {
|
||||||
|
setNewEid(event.target.value.replace(/\s+/g, ""));
|
||||||
|
setValidationError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Enter 32-digit EID"
|
||||||
|
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">{EID_HELP}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validationError && (
|
||||||
|
<p className="mt-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{validationError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 border-t border-gray-200 bg-gray-50 p-4 sm:flex-row sm:justify-end sm:px-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
disabled={disableSubmit || submitting}
|
||||||
|
className="inline-flex justify-center rounded-md px-4 py-2 text-sm font-semibold text-white shadow-sm disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
style={{ background: "linear-gradient(90deg, #16a34a, #15803d)" }}
|
||||||
|
>
|
||||||
|
{submitting ? "Submitting..." : isPhysicalSelected ? "Contact Support" : "Confirm Reissue"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
className="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
425
sim-manager-migration/frontend/components/SimActions.tsx
Normal file
425
sim-manager-migration/frontend/components/SimActions.tsx
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
PlusIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
import { TopUpModal } from "./TopUpModal";
|
||||||
|
import { ChangePlanModal } from "./ChangePlanModal";
|
||||||
|
import { ReissueSimModal } from "./ReissueSimModal";
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
|
||||||
|
interface SimActionsProps {
|
||||||
|
subscriptionId: number;
|
||||||
|
simType: "physical" | "esim";
|
||||||
|
status: string;
|
||||||
|
onTopUpSuccess?: () => void;
|
||||||
|
onPlanChangeSuccess?: () => void;
|
||||||
|
onCancelSuccess?: () => void;
|
||||||
|
onReissueSuccess?: () => void;
|
||||||
|
embedded?: boolean; // when true, render content without card container
|
||||||
|
currentPlanCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimActions({
|
||||||
|
subscriptionId,
|
||||||
|
simType,
|
||||||
|
status,
|
||||||
|
onTopUpSuccess,
|
||||||
|
onPlanChangeSuccess,
|
||||||
|
onCancelSuccess,
|
||||||
|
onReissueSuccess,
|
||||||
|
embedded = false,
|
||||||
|
currentPlanCode,
|
||||||
|
}: SimActionsProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showTopUpModal, setShowTopUpModal] = useState(false);
|
||||||
|
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
|
||||||
|
const [showReissueModal, setShowReissueModal] = useState(false);
|
||||||
|
const [loading, setLoading] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
const [showChangePlanModal, setShowChangePlanModal] = useState(false);
|
||||||
|
const [activeInfo, setActiveInfo] = useState<
|
||||||
|
"topup" | "reissue" | "cancel" | "changePlan" | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const isActive = status === "active";
|
||||||
|
const canTopUp = isActive;
|
||||||
|
const canReissue = isActive;
|
||||||
|
const canCancel = isActive;
|
||||||
|
|
||||||
|
const reissueDisabledReason = useMemo(() => {
|
||||||
|
if (!isActive) {
|
||||||
|
return "SIM must be active to request a reissue.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [isActive]);
|
||||||
|
|
||||||
|
const handleCancelSim = async () => {
|
||||||
|
setLoading("cancel");
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.POST("/api/subscriptions/{id}/sim/cancel", {
|
||||||
|
params: { path: { id: subscriptionId } },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
setSuccess("SIM service cancelled successfully");
|
||||||
|
setShowCancelConfirm(false);
|
||||||
|
onCancelSuccess?.();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setError(error instanceof Error ? error.message : "Failed to cancel SIM service");
|
||||||
|
} finally {
|
||||||
|
setLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear success/error messages after 5 seconds
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (success || error) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setSuccess(null);
|
||||||
|
setError(null);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}, [success, error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="sim-actions"
|
||||||
|
className={`${embedded ? "" : "bg-white shadow-md rounded-xl border border-gray-100"}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
{!embedded && (
|
||||||
|
<div className="px-6 py-6 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-semibold tracking-tight text-slate-900 mb-1">
|
||||||
|
SIM Management Actions
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-600">Manage your SIM service</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
|
||||||
|
{/* Status Messages */}
|
||||||
|
{success && (
|
||||||
|
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
|
||||||
|
<p className="text-sm text-green-800">{success}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mr-2" />
|
||||||
|
<p className="text-sm text-red-800">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isActive && (
|
||||||
|
<div className="mb-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 mr-2" />
|
||||||
|
<p className="text-sm text-yellow-800">
|
||||||
|
SIM management actions are only available for active services.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Top Up Data - Primary Action */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveInfo("topup");
|
||||||
|
try {
|
||||||
|
router.push(`/subscriptions/${subscriptionId}/sim/top-up`);
|
||||||
|
} catch {
|
||||||
|
setShowTopUpModal(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!canTopUp || loading !== null}
|
||||||
|
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||||
|
canTopUp && loading === null
|
||||||
|
? "text-white bg-blue-600 hover:bg-blue-700 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 active:scale-[0.98]"
|
||||||
|
: "text-gray-400 bg-gray-100 cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<PlusIcon className="h-4 w-4 mr-3" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium">
|
||||||
|
{loading === "topup" ? "Processing..." : "Top Up Data"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs opacity-90">Add more data to your plan</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Change Plan - Secondary Action */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveInfo("changePlan");
|
||||||
|
try {
|
||||||
|
router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
|
||||||
|
} catch {
|
||||||
|
setShowChangePlanModal(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!isActive || loading !== null}
|
||||||
|
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||||
|
isActive && loading === null
|
||||||
|
? "text-slate-700 bg-slate-100 hover:bg-slate-200 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 active:scale-[0.98]"
|
||||||
|
: "text-gray-400 bg-gray-100 cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ArrowPathIcon className="h-4 w-4 mr-3" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium">
|
||||||
|
{loading === "change-plan" ? "Processing..." : "Change Plan"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs opacity-70">Switch to a different plan</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Reissue SIM */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveInfo("reissue");
|
||||||
|
setShowReissueModal(true);
|
||||||
|
}}
|
||||||
|
disabled={!canReissue || loading !== null}
|
||||||
|
className={`w-full flex flex-col items-start justify-start rounded-lg border px-4 py-4 text-left text-sm font-medium transition-all duration-200 ${
|
||||||
|
canReissue && loading === null
|
||||||
|
? "border-green-200 bg-green-50 text-green-900 hover:bg-green-100 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 active:scale-[0.98]"
|
||||||
|
: "text-gray-400 bg-gray-100 border-gray-200 cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ArrowPathIcon className="h-4 w-4 mr-3" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium">{"Reissue SIM"}</div>
|
||||||
|
<div className="text-xs opacity-70">
|
||||||
|
Configure replacement options and submit your request.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!canReissue && reissueDisabledReason && (
|
||||||
|
<div className="mt-3 w-full rounded-md border border-yellow-200 bg-yellow-50 px-3 py-2 text-xs text-yellow-800">
|
||||||
|
{reissueDisabledReason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Cancel SIM - Destructive Action */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveInfo("cancel");
|
||||||
|
try {
|
||||||
|
router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
|
||||||
|
} catch {
|
||||||
|
// Fallback to inline confirmation modal if navigation is unavailable
|
||||||
|
setShowCancelConfirm(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!canCancel || loading !== null}
|
||||||
|
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||||
|
canCancel && loading === null
|
||||||
|
? "text-red-700 bg-white border border-red-200 hover:bg-red-50 hover:border-red-300 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 active:scale-[0.98]"
|
||||||
|
: "text-gray-400 bg-gray-100 cursor-not-allowed"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<XMarkIcon className="h-4 w-4 mr-3" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium">
|
||||||
|
{loading === "cancel" ? "Processing..." : "Cancel SIM"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs opacity-70">Permanently cancel service</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Description (contextual) */}
|
||||||
|
{activeInfo && (
|
||||||
|
<div className="mt-6 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
{activeInfo === "topup" && (
|
||||||
|
<div className="flex items-start">
|
||||||
|
<PlusIcon className="h-4 w-4 text-blue-600 mr-2 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<strong>Top Up Data:</strong> Add additional data quota to your SIM service. You
|
||||||
|
can choose the amount and schedule it for later if needed.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeInfo === "reissue" && (
|
||||||
|
<div className="flex items-start">
|
||||||
|
<ArrowPathIcon className="h-4 w-4 text-green-600 mr-2 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<strong>Reissue SIM:</strong> Submit a replacement request for either a physical SIM or an eSIM. eSIM users can optionally supply a new EID to pair with the replacement profile.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeInfo === "cancel" && (
|
||||||
|
<div className="flex items-start">
|
||||||
|
<XMarkIcon className="h-4 w-4 text-red-600 mr-2 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<strong>Cancel SIM:</strong> Permanently cancel your SIM service. This action
|
||||||
|
cannot be undone and will terminate your service immediately.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeInfo === "changePlan" && (
|
||||||
|
<div className="flex items-start">
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4 text-purple-600 mr-2 mt-0.5 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<strong>Change Plan:</strong> Switch to a different data plan.{" "}
|
||||||
|
<span className="text-red-600 font-medium">
|
||||||
|
Important: Plan changes must be requested before the 25th of the month. Changes
|
||||||
|
will take effect on the 1st of the following month.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Up Modal */}
|
||||||
|
{showTopUpModal && (
|
||||||
|
<TopUpModal
|
||||||
|
subscriptionId={subscriptionId}
|
||||||
|
onClose={() => {
|
||||||
|
setShowTopUpModal(false);
|
||||||
|
setActiveInfo(null);
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowTopUpModal(false);
|
||||||
|
setSuccess("Data top-up completed successfully");
|
||||||
|
onTopUpSuccess?.();
|
||||||
|
}}
|
||||||
|
onError={message => setError(message)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Change Plan Modal */}
|
||||||
|
{showChangePlanModal && (
|
||||||
|
<ChangePlanModal
|
||||||
|
subscriptionId={subscriptionId}
|
||||||
|
currentPlanCode={currentPlanCode}
|
||||||
|
onClose={() => {
|
||||||
|
setShowChangePlanModal(false);
|
||||||
|
setActiveInfo(null);
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowChangePlanModal(false);
|
||||||
|
setSuccess("SIM plan change submitted successfully");
|
||||||
|
onPlanChangeSuccess?.();
|
||||||
|
}}
|
||||||
|
onError={message => setError(message)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reissue SIM Modal */}
|
||||||
|
{showReissueModal && (
|
||||||
|
<ReissueSimModal
|
||||||
|
subscriptionId={subscriptionId}
|
||||||
|
currentSimType={simType}
|
||||||
|
onClose={() => {
|
||||||
|
setShowReissueModal(false);
|
||||||
|
setActiveInfo(null);
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowReissueModal(false);
|
||||||
|
setSuccess("SIM reissue request submitted successfully");
|
||||||
|
onReissueSuccess?.();
|
||||||
|
}}
|
||||||
|
onError={message => {
|
||||||
|
setError(message);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cancel Confirmation */}
|
||||||
|
{showCancelConfirm && (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
|
||||||
|
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||||
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
|
<div className="sm:flex sm:items-start">
|
||||||
|
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||||
|
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||||
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
Cancel SIM Service
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Are you sure you want to cancel this SIM service? This action cannot be
|
||||||
|
undone and will permanently terminate your service.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCancelSim()}
|
||||||
|
disabled={loading === "cancel"}
|
||||||
|
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading === "cancel" ? "Processing..." : "Cancel SIM"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCancelConfirm(false);
|
||||||
|
setActiveInfo(null);
|
||||||
|
}}
|
||||||
|
disabled={loading === "cancel"}
|
||||||
|
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
416
sim-manager-migration/frontend/components/SimDetailsCard.tsx
Normal file
416
sim-manager-migration/frontend/components/SimDetailsCard.tsx
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { formatPlanShort } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
DevicePhoneMobileIcon,
|
||||||
|
WifiIcon,
|
||||||
|
SignalIcon,
|
||||||
|
ClockIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
export interface SimDetails {
|
||||||
|
account: string;
|
||||||
|
msisdn: string;
|
||||||
|
iccid?: string;
|
||||||
|
imsi?: string;
|
||||||
|
eid?: string;
|
||||||
|
planCode: string;
|
||||||
|
status: "active" | "suspended" | "cancelled" | "pending";
|
||||||
|
simType: "physical" | "esim";
|
||||||
|
size: "standard" | "nano" | "micro" | "esim";
|
||||||
|
hasVoice: boolean;
|
||||||
|
hasSms: boolean;
|
||||||
|
remainingQuotaKb: number;
|
||||||
|
remainingQuotaMb: number;
|
||||||
|
startDate?: string;
|
||||||
|
ipv4?: string;
|
||||||
|
ipv6?: string;
|
||||||
|
voiceMailEnabled?: boolean;
|
||||||
|
callWaitingEnabled?: boolean;
|
||||||
|
internationalRoamingEnabled?: boolean;
|
||||||
|
networkType?: string;
|
||||||
|
pendingOperations?: Array<{
|
||||||
|
operation: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimDetailsCardProps {
|
||||||
|
simDetails: SimDetails;
|
||||||
|
isLoading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
embedded?: boolean; // when true, render content without card container
|
||||||
|
showFeaturesSummary?: boolean; // show the right-side Service Features summary
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimDetailsCard({
|
||||||
|
simDetails,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
embedded = false,
|
||||||
|
showFeaturesSummary = true,
|
||||||
|
}: SimDetailsCardProps) {
|
||||||
|
const formatPlan = (code?: string) => {
|
||||||
|
const formatted = formatPlanShort(code);
|
||||||
|
// Remove "PASI" prefix if present
|
||||||
|
return formatted?.replace(/^PASI\s*/, "") || formatted;
|
||||||
|
};
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
|
||||||
|
case "suspended":
|
||||||
|
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
|
||||||
|
case "cancelled":
|
||||||
|
return <XCircleIcon className="h-6 w-6 text-red-500" />;
|
||||||
|
case "pending":
|
||||||
|
return <ClockIcon className="h-6 w-6 text-blue-500" />;
|
||||||
|
default:
|
||||||
|
return <DevicePhoneMobileIcon className="h-6 w-6 text-gray-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return "bg-green-100 text-green-800";
|
||||||
|
case "suspended":
|
||||||
|
return "bg-yellow-100 text-yellow-800";
|
||||||
|
case "cancelled":
|
||||||
|
return "bg-red-100 text-red-800";
|
||||||
|
case "pending":
|
||||||
|
return "bg-blue-100 text-blue-800";
|
||||||
|
default:
|
||||||
|
return "bg-gray-100 text-gray-800";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatQuota = (quotaMb: number) => {
|
||||||
|
if (quotaMb >= 1000) {
|
||||||
|
return `${(quotaMb / 1000).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
return `${quotaMb.toFixed(0)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
const Skeleton = (
|
||||||
|
<div
|
||||||
|
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300 "}p-6 lg:p-8`}
|
||||||
|
>
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="rounded-full bg-gradient-to-br from-blue-200 to-blue-300 h-14 w-14"></div>
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="h-5 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-3/4"></div>
|
||||||
|
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 space-y-4">
|
||||||
|
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg"></div>
|
||||||
|
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-5/6"></div>
|
||||||
|
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-4/6"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return Skeleton;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-red-100 "}p-6 lg:p-8`}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="bg-red-50 rounded-full p-3 w-16 h-16 mx-auto mb-4">
|
||||||
|
<ExclamationTriangleIcon className="h-10 w-10 text-red-500 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Error Loading SIM Details</h3>
|
||||||
|
<p className="text-red-600 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modern eSIM details view with usage visualization
|
||||||
|
if (simDetails.simType === "esim") {
|
||||||
|
const remainingGB = simDetails.remainingQuotaMb / 1000;
|
||||||
|
const totalGB = 1048.6; // Mock total - should come from API
|
||||||
|
const usedGB = totalGB - remainingGB;
|
||||||
|
const usagePercentage = (usedGB / totalGB) * 100;
|
||||||
|
|
||||||
|
// Usage Sparkline Component
|
||||||
|
const UsageSparkline = ({ data }: { data: Array<{ date: string; usedMB: number }> }) => {
|
||||||
|
const maxValue = Math.max(...data.map(d => d.usedMB), 1);
|
||||||
|
const width = 80;
|
||||||
|
const height = 16;
|
||||||
|
|
||||||
|
const points = data.map((d, i) => {
|
||||||
|
const x = (i / (data.length - 1)) * width;
|
||||||
|
const y = height - (d.usedMB / maxValue) * height;
|
||||||
|
return `${x},${y}`;
|
||||||
|
}).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={width} height={height} className="text-blue-500">
|
||||||
|
<polyline
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
points={points}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Usage Donut Component
|
||||||
|
const UsageDonut = ({ size = 120 }: { size?: number }) => {
|
||||||
|
const radius = (size - 16) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const strokeDashoffset = circumference - (usagePercentage / 100) * circumference;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
<svg width={size} height={size} className="transform -rotate-90">
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgb(241 245 249)"
|
||||||
|
strokeWidth="8"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgb(59 130 246)"
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
className="transition-all duration-300"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute text-center">
|
||||||
|
<div className="text-3xl font-semibold text-slate-900">{remainingGB.toFixed(1)}</div>
|
||||||
|
<div className="text-sm text-slate-500 -mt-1">GB remaining</div>
|
||||||
|
<div className="text-xs text-slate-400 mt-1">{usagePercentage.toFixed(1)}% used</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${embedded ? "" : "bg-white shadow-md rounded-xl border border-gray-100"}`}>
|
||||||
|
{/* Compact Header Bar */}
|
||||||
|
<div className={`${embedded ? "" : "px-6 py-4 border-b border-gray-200"}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex px-3 py-1 text-xs font-medium rounded-full ${getStatusColor(simDetails.status)}`}
|
||||||
|
>
|
||||||
|
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-semibold text-slate-900">
|
||||||
|
{formatPlan(simDetails.planCode)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-600 mt-1">{simDetails.msisdn}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${embedded ? "" : "px-6 py-6"}`}>
|
||||||
|
{/* Usage Visualization */}
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<UsageDonut size={160} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 pt-4">
|
||||||
|
<h4 className="text-sm font-medium text-slate-900 mb-3">Recent Usage History</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ date: "Sep 29", usage: "0 MB" },
|
||||||
|
{ date: "Sep 28", usage: "0 MB" },
|
||||||
|
{ date: "Sep 27", usage: "0 MB" },
|
||||||
|
].map((entry, index) => (
|
||||||
|
<div key={index} className="flex justify-between items-center text-xs">
|
||||||
|
<span className="text-slate-600">{entry.date}</span>
|
||||||
|
<span className="text-slate-900">{entry.usage}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default view for physical SIM cards
|
||||||
|
return (
|
||||||
|
<div className={`${embedded ? "" : "bg-white shadow-md rounded-xl border border-gray-100"}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`${embedded ? "" : "px-6 py-4 border-b border-gray-200"}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="text-2xl mr-3">
|
||||||
|
<DevicePhoneMobileIcon className="h-8 w-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{formatPlan(simDetails.planCode)} • {`${simDetails.size} SIM`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{getStatusIcon(simDetails.status)}
|
||||||
|
<span
|
||||||
|
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)}`}
|
||||||
|
>
|
||||||
|
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className={`${embedded ? "" : "px-6 py-4"}`}>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* SIM Information */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||||
|
SIM Information
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500">Phone Number</label>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{simDetails.simType === "physical" && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500">ICCID</label>
|
||||||
|
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{simDetails.eid && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500">EID (eSIM)</label>
|
||||||
|
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.eid}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{simDetails.imsi && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500">IMSI</label>
|
||||||
|
<p className="text-sm font-mono text-gray-900">{simDetails.imsi}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{simDetails.startDate && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500">Service Start Date</label>
|
||||||
|
<p className="text-sm text-gray-900">{formatDate(simDetails.startDate)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service Features */}
|
||||||
|
{showFeaturesSummary && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||||
|
Service Features
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500">Data Remaining</label>
|
||||||
|
<p className="text-lg font-semibold text-green-600">
|
||||||
|
{formatQuota(simDetails.remainingQuotaMb)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<SignalIcon
|
||||||
|
className={`h-4 w-4 mr-1 ${simDetails.hasVoice ? "text-green-500" : "text-gray-400"}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${simDetails.hasVoice ? "text-green-600" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
Voice {simDetails.hasVoice ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DevicePhoneMobileIcon
|
||||||
|
className={`h-4 w-4 mr-1 ${simDetails.hasSms ? "text-green-500" : "text-gray-400"}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${simDetails.hasSms ? "text-green-600" : "text-gray-500"}`}
|
||||||
|
>
|
||||||
|
SMS {simDetails.hasSms ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(simDetails.ipv4 || simDetails.ipv6) && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500">IP Address</label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{simDetails.ipv4 && (
|
||||||
|
<p className="text-sm font-mono text-gray-900">IPv4: {simDetails.ipv4}</p>
|
||||||
|
)}
|
||||||
|
{simDetails.ipv6 && (
|
||||||
|
<p className="text-sm font-mono text-gray-900">IPv6: {simDetails.ipv6}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pending Operations */}
|
||||||
|
{simDetails.pendingOperations && simDetails.pendingOperations.length > 0 && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
||||||
|
Pending Operations
|
||||||
|
</h4>
|
||||||
|
<div className="bg-blue-50 rounded-lg p-4">
|
||||||
|
{simDetails.pendingOperations.map((operation, index) => (
|
||||||
|
<div key={index} className="flex items-center text-sm">
|
||||||
|
<ClockIcon className="h-4 w-4 text-blue-500 mr-2" />
|
||||||
|
<span className="text-blue-800">
|
||||||
|
{operation.operation} scheduled for {formatDate(operation.scheduledDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
349
sim-manager-migration/frontend/components/SimFeatureToggles.tsx
Normal file
349
sim-manager-migration/frontend/components/SimFeatureToggles.tsx
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
|
||||||
|
interface SimFeatureTogglesProps {
|
||||||
|
subscriptionId: number;
|
||||||
|
voiceMailEnabled?: boolean;
|
||||||
|
callWaitingEnabled?: boolean;
|
||||||
|
internationalRoamingEnabled?: boolean;
|
||||||
|
networkType?: string; // '4G' | '5G'
|
||||||
|
onChanged?: () => void;
|
||||||
|
embedded?: boolean; // when true, render without outer card wrappers
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimFeatureToggles({
|
||||||
|
subscriptionId,
|
||||||
|
voiceMailEnabled,
|
||||||
|
callWaitingEnabled,
|
||||||
|
internationalRoamingEnabled,
|
||||||
|
networkType,
|
||||||
|
onChanged,
|
||||||
|
embedded = false,
|
||||||
|
}: SimFeatureTogglesProps) {
|
||||||
|
// Initial values
|
||||||
|
const initial = useMemo(
|
||||||
|
() => ({
|
||||||
|
vm: !!voiceMailEnabled,
|
||||||
|
cw: !!callWaitingEnabled,
|
||||||
|
ir: !!internationalRoamingEnabled,
|
||||||
|
nt: networkType === "5G" ? "5G" : "4G",
|
||||||
|
}),
|
||||||
|
[voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Working values
|
||||||
|
const [vm, setVm] = useState(initial.vm);
|
||||||
|
const [cw, setCw] = useState(initial.cw);
|
||||||
|
const [ir, setIr] = useState(initial.ir);
|
||||||
|
const [nt, setNt] = useState<"4G" | "5G">(initial.nt as "4G" | "5G");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVm(initial.vm);
|
||||||
|
setCw(initial.cw);
|
||||||
|
setIr(initial.ir);
|
||||||
|
setNt(initial.nt as "4G" | "5G");
|
||||||
|
}, [initial.vm, initial.cw, initial.ir, initial.nt]);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setVm(initial.vm);
|
||||||
|
setCw(initial.cw);
|
||||||
|
setIr(initial.ir);
|
||||||
|
setNt(initial.nt as "4G" | "5G");
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyChanges = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(null);
|
||||||
|
try {
|
||||||
|
const featurePayload: {
|
||||||
|
voiceMailEnabled?: boolean;
|
||||||
|
callWaitingEnabled?: boolean;
|
||||||
|
internationalRoamingEnabled?: boolean;
|
||||||
|
networkType?: "4G" | "5G";
|
||||||
|
} = {};
|
||||||
|
if (vm !== initial.vm) featurePayload.voiceMailEnabled = vm;
|
||||||
|
if (cw !== initial.cw) featurePayload.callWaitingEnabled = cw;
|
||||||
|
if (ir !== initial.ir) featurePayload.internationalRoamingEnabled = ir;
|
||||||
|
if (nt !== initial.nt) featurePayload.networkType = nt;
|
||||||
|
|
||||||
|
if (Object.keys(featurePayload).length > 0) {
|
||||||
|
await apiClient.POST("/api/subscriptions/{id}/sim/features", {
|
||||||
|
params: { path: { id: subscriptionId } },
|
||||||
|
body: featurePayload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess("Changes submitted successfully");
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to submit changes");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Service Options */}
|
||||||
|
<div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-100 shadow-md"}`}>
|
||||||
|
<div className={`${embedded ? "" : "p-6"} space-y-4`}>
|
||||||
|
{/* Voice Mail */}
|
||||||
|
<div className="flex items-center justify-between py-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-slate-900">Voice Mail</div>
|
||||||
|
<div className="text-xs text-slate-500">¥300/month</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={vm}
|
||||||
|
onClick={() => setVm(!vm)}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
|
||||||
|
vm ? "bg-blue-600" : "bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
vm ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Call Waiting */}
|
||||||
|
<div className="flex items-center justify-between py-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-slate-900">Call Waiting</div>
|
||||||
|
<div className="text-xs text-slate-500">¥300/month</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={cw}
|
||||||
|
onClick={() => setCw(!cw)}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
|
||||||
|
cw ? "bg-blue-600" : "bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
cw ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* International Roaming */}
|
||||||
|
<div className="flex items-center justify-between py-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-slate-900">International Roaming</div>
|
||||||
|
<div className="text-xs text-slate-500">Global connectivity</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={ir}
|
||||||
|
onClick={() => setIr(!ir)}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
|
||||||
|
ir ? "bg-blue-600" : "bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||||
|
ir ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 pt-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="text-sm font-medium text-slate-900 mb-1">Network Type</div>
|
||||||
|
<div className="text-xs text-slate-500">Choose your preferred connectivity</div>
|
||||||
|
<div className="text-xs text-red-600 mt-1">
|
||||||
|
Voice, network, and plan changes must be requested at least 30 minutes apart. If you just changed another option, you may need to wait before submitting.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="4g"
|
||||||
|
name="networkType"
|
||||||
|
value="4G"
|
||||||
|
checked={nt === "4G"}
|
||||||
|
onChange={() => setNt("4G")}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||||
|
/>
|
||||||
|
<label htmlFor="4g" className="text-sm text-slate-700">
|
||||||
|
4G
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="5g"
|
||||||
|
name="networkType"
|
||||||
|
value="5G"
|
||||||
|
checked={nt === "5G"}
|
||||||
|
onChange={() => setNt("5G")}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||||
|
/>
|
||||||
|
<label htmlFor="5g" className="text-sm text-slate-700">
|
||||||
|
5G
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-2">5G connectivity for enhanced speeds</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes and Actions */}
|
||||||
|
<div className={`${embedded ? "" : "bg-white rounded-xl border border-gray-200 p-6"}`}>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
|
<h4 className="text-sm font-medium text-blue-900 mb-2 flex items-center gap-2">
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Important Notes
|
||||||
|
</h4>
|
||||||
|
<ul className="text-xs text-blue-800 space-y-1">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
|
||||||
|
Changes will take effect instantaneously (approx. 30min)
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
|
||||||
|
May require smartphone device restart after changes are applied
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="w-1 h-1 bg-red-600 rounded-full mt-1.5 flex-shrink-0"></span>
|
||||||
|
<span className="text-red-600">Voice, network, and plan changes must be requested at least 30 minutes apart.</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="w-1 h-1 bg-blue-600 rounded-full mt-1.5 flex-shrink-0"></span>
|
||||||
|
Changes to Voice Mail / Call Waiting must be requested before the 25th of the month
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-green-500 mr-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm font-medium text-green-800">{success}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-red-500 mr-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm font-medium text-red-800">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => void applyChanges()}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 inline-flex items-center justify-center px-6 py-3 border border-transparent rounded-lg text-sm font-semibold text-white bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="animate-spin -ml-1 mr-3 h-4 w-4 text-white"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
className="opacity-25"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
className="opacity-75"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Applying Changes...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Apply Changes
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => reset()}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center justify-center px-6 py-3 border border-gray-300 rounded-lg text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,234 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
DevicePhoneMobileIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
import { SimDetailsCard, type SimDetails } from "./SimDetailsCard";
|
||||||
|
import { SimActions } from "./SimActions";
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
import { SimFeatureToggles } from "./SimFeatureToggles";
|
||||||
|
|
||||||
|
interface SimManagementSectionProps {
|
||||||
|
subscriptionId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimInfo {
|
||||||
|
details: SimDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimManagementSection({ subscriptionId }: SimManagementSectionProps) {
|
||||||
|
const [simInfo, setSimInfo] = useState<SimInfo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchSimInfo = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await apiClient.GET("/api/subscriptions/{id}/sim", {
|
||||||
|
params: { path: { id: subscriptionId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = response.data as { details: SimDetails; usage: any } | undefined;
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error("Failed to load SIM information");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only use the details part, ignore usage data
|
||||||
|
setSimInfo({ details: payload.details });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const hasStatus = (v: unknown): v is { status: number } =>
|
||||||
|
typeof v === "object" &&
|
||||||
|
v !== null &&
|
||||||
|
"status" in v &&
|
||||||
|
typeof (v as { status: unknown }).status === "number";
|
||||||
|
if (hasStatus(err) && err.status === 400) {
|
||||||
|
// Not a SIM subscription - this component shouldn't be shown
|
||||||
|
setError("This subscription is not a SIM service");
|
||||||
|
} else {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load SIM information");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [subscriptionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchSimInfo();
|
||||||
|
}, [fetchSimInfo]);
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
setLoading(true);
|
||||||
|
void fetchSimInfo();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActionSuccess = () => {
|
||||||
|
// Refresh SIM info after any successful action
|
||||||
|
void fetchSimInfo();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-8">
|
||||||
|
<div className="flex items-center mb-6">
|
||||||
|
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
||||||
|
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">SIM Management</h2>
|
||||||
|
<p className="text-gray-600 mt-1">Loading your SIM service details...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="h-6 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-3/4"></div>
|
||||||
|
<div className="h-5 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-1/2"></div>
|
||||||
|
<div className="h-48 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="h-32 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div>
|
||||||
|
<div className="h-32 bg-gradient-to-r from-gray-200 to-gray-300 rounded-xl"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white shadow-lg rounded-xl border border-red-100 p-8">
|
||||||
|
<div className="flex items-center mb-6">
|
||||||
|
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
||||||
|
<DevicePhoneMobileIcon className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">SIM Management</h2>
|
||||||
|
<p className="text-gray-600 mt-1">Unable to load SIM information</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="bg-red-50 rounded-full p-4 w-20 h-20 mx-auto mb-6">
|
||||||
|
<ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">
|
||||||
|
Unable to Load SIM Information
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 mb-8 max-w-md mx-auto">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
className="inline-flex items-center px-6 py-3 border border-transparent text-sm font-semibold rounded-xl text-white bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 hover:shadow-lg hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="h-5 w-5 mr-2" />
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!simInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="sim-management" className="space-y-6">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{simInfo.details.simType === "esim" ? "eSIM" : "Physical SIM"} Service
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Subscription ID {subscriptionId}</p>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
||||||
|
{simInfo.details.status.charAt(0).toUpperCase() + simInfo.details.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two Column Layout */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Left Column - Main Actions */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Subscription Details Card */}
|
||||||
|
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Subscription Details</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-500 uppercase tracking-wide">Monthly Cost</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">¥3,100</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-500 uppercase tracking-wide">Next Billing</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">Jul 1, 2024</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-500 uppercase tracking-wide">Registration</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900">Aug 2, 2023</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SIM Management Actions Card */}
|
||||||
|
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">SIM Management Actions</h2>
|
||||||
|
</div>
|
||||||
|
<SimActions
|
||||||
|
subscriptionId={subscriptionId}
|
||||||
|
simType={simInfo.details.simType}
|
||||||
|
status={simInfo.details.status}
|
||||||
|
currentPlanCode={simInfo.details.planCode}
|
||||||
|
onTopUpSuccess={handleActionSuccess}
|
||||||
|
onPlanChangeSuccess={handleActionSuccess}
|
||||||
|
onCancelSuccess={handleActionSuccess}
|
||||||
|
onReissueSuccess={handleActionSuccess}
|
||||||
|
embedded={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voice Status Card */}
|
||||||
|
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Voice Status</h2>
|
||||||
|
</div>
|
||||||
|
<SimFeatureToggles
|
||||||
|
subscriptionId={subscriptionId}
|
||||||
|
voiceMailEnabled={simInfo.details.voiceMailEnabled}
|
||||||
|
callWaitingEnabled={simInfo.details.callWaitingEnabled}
|
||||||
|
internationalRoamingEnabled={simInfo.details.internationalRoamingEnabled}
|
||||||
|
networkType={simInfo.details.networkType}
|
||||||
|
onChanged={handleActionSuccess}
|
||||||
|
embedded
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - SIM Details & Usage */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* SIM Details Card */}
|
||||||
|
<div className="bg-white shadow-sm rounded-lg border border-gray-200 p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">SIM Details</h2>
|
||||||
|
</div>
|
||||||
|
<SimDetailsCard
|
||||||
|
simDetails={simInfo.details}
|
||||||
|
isLoading={false}
|
||||||
|
error={null}
|
||||||
|
embedded={true}
|
||||||
|
showFeaturesSummary={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
sim-manager-migration/frontend/components/TopUpModal.tsx
Normal file
173
sim-manager-migration/frontend/components/TopUpModal.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
|
||||||
|
interface TopUpModalProps {
|
||||||
|
subscriptionId: number;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) {
|
||||||
|
const [gbAmount, setGbAmount] = useState<string>("1");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const getCurrentAmountMb = () => {
|
||||||
|
const gb = parseInt(gbAmount, 10);
|
||||||
|
return isNaN(gb) ? 0 : gb * 1000;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidAmount = () => {
|
||||||
|
const gb = Number(gbAmount);
|
||||||
|
return Number.isInteger(gb) && gb >= 1 && gb <= 50; // 1-50 GB, whole numbers only (Freebit API limit)
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateCost = () => {
|
||||||
|
const gb = parseInt(gbAmount, 10);
|
||||||
|
return isNaN(gb) ? 0 : gb * 500; // 1GB = 500 JPY
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!isValidAmount()) {
|
||||||
|
onError("Please enter a whole number between 1 GB and 100 GB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const requestBody = {
|
||||||
|
quotaMb: getCurrentAmountMb(),
|
||||||
|
amount: calculateCost(),
|
||||||
|
currency: "JPY",
|
||||||
|
};
|
||||||
|
|
||||||
|
await apiClient.POST("/api/subscriptions/{id}/sim/top-up", {
|
||||||
|
params: { path: { id: subscriptionId } },
|
||||||
|
body: requestBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
onError(error instanceof Error ? error.message : "Failed to top up SIM");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-y-auto" onClick={handleBackdropClick}>
|
||||||
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
|
||||||
|
|
||||||
|
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||||
|
<PlusIcon className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<h3 className="text-lg leading-6 font-medium text-gray-900">Top Up Data</h3>
|
||||||
|
<p className="text-sm text-gray-500">Add data quota to your SIM service</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={e => void handleSubmit(e)}>
|
||||||
|
{/* Amount Input */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Amount (GB)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={gbAmount}
|
||||||
|
onChange={e => setGbAmount(e.target.value)}
|
||||||
|
placeholder="Enter amount in GB"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
step="1"
|
||||||
|
className="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 pr-12"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
|
<span className="text-gray-500 text-sm">GB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Enter the amount of data you want to add (1 - 50 GB, whole numbers)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cost Display */}
|
||||||
|
<div className="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-blue-900">
|
||||||
|
{gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : "0 GB"}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-blue-700">= {getCurrentAmountMb()} MB</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-lg font-bold text-blue-900">
|
||||||
|
¥{calculateCost().toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-blue-700">(1GB = ¥500)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation Warning */}
|
||||||
|
{!isValidAmount() && gbAmount && (
|
||||||
|
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ExclamationTriangleIcon className="h-4 w-4 text-red-500 mr-2" />
|
||||||
|
<p className="text-sm text-red-800">
|
||||||
|
Amount must be a whole number between 1 GB and 50 GB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3 space-y-3 space-y-reverse sm:space-y-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full sm:w-auto px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !isValidAmount()}
|
||||||
|
className="w-full sm:w-auto px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
sim-manager-migration/frontend/index.ts
Normal file
6
sim-manager-migration/frontend/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export { SimManagementSection } from "./components/SimManagementSection";
|
||||||
|
export { SimDetailsCard } from "./components/SimDetailsCard";
|
||||||
|
export { DataUsageChart } from "./components/DataUsageChart";
|
||||||
|
export { SimActions } from "./components/SimActions";
|
||||||
|
export { TopUpModal } from "./components/TopUpModal";
|
||||||
|
export { SimFeatureToggles } from "./components/SimFeatureToggles";
|
||||||
62
sim-manager-migration/frontend/utils/plan.ts
Normal file
62
sim-manager-migration/frontend/utils/plan.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// Generic plan code formatter for SIM plans
|
||||||
|
// Examples:
|
||||||
|
// - PASI_10G -> 10G
|
||||||
|
// - PASI_25G -> 25G
|
||||||
|
// - ANY_PREFIX_50GB -> 50G
|
||||||
|
// - Fallback: return the original code when unknown
|
||||||
|
|
||||||
|
export function formatPlanShort(planCode?: string): string {
|
||||||
|
if (!planCode) return "—";
|
||||||
|
const m = planCode.match(/(?:^|[_-])(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
|
||||||
|
if (m && m[1]) {
|
||||||
|
return `${m[1]}G`;
|
||||||
|
}
|
||||||
|
// Try extracting trailing number+G anywhere in the string
|
||||||
|
const m2 = planCode.match(/(\d+(?:\.\d+)?)\s*G(?:B)?\b/i);
|
||||||
|
if (m2 && m2[1]) {
|
||||||
|
return `${m2[1]}G`;
|
||||||
|
}
|
||||||
|
return planCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapping between Freebit plan codes and Salesforce product SKUs used by the portal
|
||||||
|
export const SIM_PLAN_SKU_BY_CODE: Record<string, string> = {
|
||||||
|
PASI_5G: "SIM-DATA-VOICE-5GB",
|
||||||
|
PASI_10G: "SIM-DATA-VOICE-10GB",
|
||||||
|
PASI_25G: "SIM-DATA-VOICE-25GB",
|
||||||
|
PASI_50G: "SIM-DATA-VOICE-50GB",
|
||||||
|
PASI_5G_DATA: "SIM-DATA-ONLY-5GB",
|
||||||
|
PASI_10G_DATA: "SIM-DATA-ONLY-10GB",
|
||||||
|
PASI_25G_DATA: "SIM-DATA-ONLY-25GB",
|
||||||
|
PASI_50G_DATA: "SIM-DATA-ONLY-50GB",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSimPlanSku(planCode?: string): string | undefined {
|
||||||
|
if (!planCode) return undefined;
|
||||||
|
return SIM_PLAN_SKU_BY_CODE[planCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Freebit plan codes to simplified format for API requests
|
||||||
|
* Converts PASI_5G -> 5GB, PASI_25G -> 25GB, etc.
|
||||||
|
*/
|
||||||
|
export function mapToSimplifiedFormat(planCode?: string): string {
|
||||||
|
if (!planCode) return "";
|
||||||
|
|
||||||
|
// Handle Freebit format (PASI_5G, PASI_25G, etc.)
|
||||||
|
if (planCode.startsWith("PASI_")) {
|
||||||
|
const match = planCode.match(/PASI_(\d+)G/);
|
||||||
|
if (match) {
|
||||||
|
return `${match[1]}GB`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other formats that might end with G or GB
|
||||||
|
const match = planCode.match(/(\d+)\s*G(?:B)?\b/i);
|
||||||
|
if (match) {
|
||||||
|
return `${match[1]}GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return as-is if no pattern matches
|
||||||
|
return planCode;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user