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:
tema 2025-11-21 17:12:34 +09:00
parent 833ff24645
commit 675f7d5cfd
47 changed files with 8630 additions and 29 deletions

View File

@ -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.';

View File

@ -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

View File

@ -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");

View File

@ -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");
}
}

View File

@ -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;
}

View File

@ -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) {

View File

@ -36,6 +36,7 @@
"./toolkit/*": "./dist/toolkit/*.js"
},
"scripts": {
"prebuild": "pnpm run clean",
"build": "tsc",
"dev": "tsc -w --preserveWatchOutput",
"clean": "rm -rf dist",

View File

@ -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);
}

View 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.

View 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

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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;
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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.";
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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();
}
}

View File

@ -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";

View 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";

View File

@ -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;
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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.";
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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 "";
}
}

View File

@ -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 });
}
}
}

View File

@ -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 {}

View File

@ -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);
}
}

View File

@ -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";
}

View 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!**

View 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 TopUp Payment Flow (WHMCS invoice + autocapture 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` (1100000)
- 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) (100MB51200MB)
- `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!

View 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">
&#8203;
</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>
);
}

View 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>
);
}

View 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">
Well 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. Youll 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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>
);
}

View 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>
);
}

View 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";

View 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;
}