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/check-password-needed",
|
||||
"/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",
|
||||
"/docs",
|
||||
"/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 { JwtService } from "@nestjs/jwt";
|
||||
import { Logger } from "nestjs-pino";
|
||||
@ -57,7 +63,23 @@ export class PasswordWorkflowService {
|
||||
const saltRounds =
|
||||
typeof saltRoundsConfig === "string" ? Number(saltRoundsConfig) : saltRoundsConfig;
|
||||
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);
|
||||
if (!prismaUser) {
|
||||
throw new Error("Failed to load user after password setup");
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
Inject,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { UsersFacade } from "@bff/modules/users/application/users.facade";
|
||||
@ -141,11 +142,14 @@ export class WhmcsLinkWorkflowService {
|
||||
);
|
||||
}
|
||||
|
||||
const createdUser = await this.usersFacade.create({
|
||||
email,
|
||||
passwordHash: null,
|
||||
emailVerified: true,
|
||||
});
|
||||
const createdUser = await this.usersFacade.create(
|
||||
{
|
||||
email,
|
||||
passwordHash: null,
|
||||
emailVerified: true,
|
||||
},
|
||||
{ includeProfile: false }
|
||||
);
|
||||
|
||||
await this.mappingsService.createMapping({
|
||||
userId: createdUser.id,
|
||||
@ -178,10 +182,25 @@ export class WhmcsLinkWorkflowService {
|
||||
needsPasswordSet: true,
|
||||
};
|
||||
} 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) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 type { User as PrismaUser } from "@prisma/client";
|
||||
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 { UserProfileService } from "../infra/user-profile.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
||||
|
||||
type AuthUpdateData = Partial<
|
||||
Pick<PrismaUser, "passwordHash" | "failedLoginAttempts" | "lastLoginAt" | "lockedUntil">
|
||||
@ -70,10 +76,17 @@ export class UsersFacade {
|
||||
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 {
|
||||
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) {
|
||||
this.logger.error("Failed to create user", {
|
||||
error: getErrorMessage(error),
|
||||
@ -87,11 +100,24 @@ export class UsersFacade {
|
||||
|
||||
try {
|
||||
await this.authRepository.updateAuthState(id, sanitized);
|
||||
return this.profileService.getProfile(id);
|
||||
return await this.profileService.getProfile(id);
|
||||
} 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", {
|
||||
userId: id,
|
||||
error: getErrorMessage(error),
|
||||
error: message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
@ -134,7 +134,10 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
login: async credentials => {
|
||||
set({ loading: true, error: null });
|
||||
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);
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error.issues?.[0]?.message ?? "Login failed");
|
||||
@ -150,7 +153,10 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
signup: async data => {
|
||||
set({ loading: true, error: null });
|
||||
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);
|
||||
if (!parsed.success) {
|
||||
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) => {
|
||||
set({ loading: true, error: null });
|
||||
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 });
|
||||
} catch (error) {
|
||||
set({
|
||||
@ -205,6 +214,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
try {
|
||||
const response = await apiClient.POST("/api/auth/reset-password", {
|
||||
body: { token, password },
|
||||
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
||||
});
|
||||
const parsed = authResponseSchema.safeParse(response.data);
|
||||
if (!parsed.success) {
|
||||
@ -245,6 +255,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
try {
|
||||
const response = await apiClient.POST("/api/auth/check-password-needed", {
|
||||
body: { email },
|
||||
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
||||
});
|
||||
|
||||
const parsed = checkPasswordNeededResponseSchema.safeParse(response.data);
|
||||
@ -268,6 +279,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
try {
|
||||
const response = await apiClient.POST("/api/auth/link-whmcs", {
|
||||
body: linkRequest,
|
||||
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
||||
});
|
||||
|
||||
const parsed = linkWhmcsResponseSchema.safeParse(response.data);
|
||||
@ -291,6 +303,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
try {
|
||||
const response = await apiClient.POST("/api/auth/set-password", {
|
||||
body: { email, password },
|
||||
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
||||
});
|
||||
const parsed = authResponseSchema.safeParse(response.data);
|
||||
if (!parsed.success) {
|
||||
|
||||
@ -36,6 +36,7 @@
|
||||
"./toolkit/*": "./dist/toolkit/*.js"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "pnpm run clean",
|
||||
"build": "tsc",
|
||||
"dev": "tsc -w --preserveWatchOutput",
|
||||
"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);
|
||||
setSubmitError(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
|
||||
throw error;
|
||||
} finally {
|
||||
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