Update SIM management service and UI components for Freebit API compliance

- Adjusted quota validation in SimManagementService to enforce limits of 100MB to 51200MB for Freebit API compatibility.
- Updated cost calculation to round up GB usage for billing, ensuring accurate invoice generation.
- Modified top-up modal and related UI components to reflect new limits of 1-50 GB, aligning with Freebit API constraints.
- Enhanced documentation to clarify pricing structure and API data flow adjustments.
This commit is contained in:
tema 2025-09-09 15:40:13 +09:00
parent 5c6057bf2e
commit 425ef83dba
7 changed files with 78 additions and 44 deletions

View File

@ -298,10 +298,15 @@ export class SimManagementService {
throw new BadRequestException('Quota must be between 1MB and 100GB'); throw new BadRequestException('Quota must be between 1MB and 100GB');
} }
// Calculate cost: 1GB = 500 JPY (rounded up to nearest GB)
// Calculate cost: 1GB = 500 JPY
const quotaGb = request.quotaMb / 1000; const quotaGb = request.quotaMb / 1000;
const costJpy = Math.round(quotaGb * 500); const units = Math.ceil(quotaGb);
const costJpy = units * 500;
// 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 // Get client mapping for WHMCS
const mapping = await this.mappingsService.findByUserId(userId); const mapping = await this.mappingsService.findByUserId(userId);
@ -321,7 +326,7 @@ export class SimManagementService {
// Step 1: Create WHMCS invoice // Step 1: Create WHMCS invoice
const invoice = await this.whmcsService.createInvoice({ const invoice = await this.whmcsService.createInvoice({
clientId: mapping.whmcsClientId, clientId: mapping.whmcsClientId,
description: `SIM Data Top-up: ${quotaGb.toFixed(1)}GB for ${account}`, description: `SIM Data Top-up: ${units}GB for ${account}`,
amount: costJpy, amount: costJpy,
currency: 'JPY', currency: 'JPY',
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
@ -336,6 +341,11 @@ export class SimManagementService {
}); });
// Step 2: Capture payment // Step 2: Capture payment
this.logger.log(`Attempting payment capture`, {
invoiceId: invoice.id,
amount: costJpy,
});
const paymentResult = await this.whmcsService.capturePayment({ const paymentResult = await this.whmcsService.capturePayment({
invoiceId: invoice.id, invoiceId: invoice.id,
amount: costJpy, amount: costJpy,

View File

@ -380,30 +380,47 @@ export class FreebititService {
scheduledAt?: string; scheduledAt?: string;
} = {}): Promise<void> { } = {}): Promise<void> {
try { try {
const quotaKb = quotaMb * 1000; // Units per endpoint:
// - Immediate (PA04-04 /master/addSpec/): quota in MB (string), requires kind: 'MVNO'
const request: Omit<FreebititTopUpRequest, 'authKey'> = { // - Scheduled (PA05-22 /mvno/eachQuota/): quota in KB (string), accepts runTime
account, const quotaKb = quotaMb * 1000; // KB using decimal base to align with Freebit examples
quota: quotaKb, const quotaMbStr = String(Math.round(quotaMb));
quotaCode: options.campaignCode, const quotaKbStr = String(Math.round(quotaKb));
expire: options.expiryDate,
};
// Use PA05-22 for scheduled top-ups, PA04-04 for immediate const isScheduled = !!options.scheduledAt;
const endpoint = options.scheduledAt ? '/mvno/eachQuota/' : '/master/addSpec/'; const endpoint = isScheduled ? '/mvno/eachQuota/' : '/master/addSpec/';
if (options.scheduledAt && endpoint === '/mvno/eachQuota/') { let request: Omit<FreebititTopUpRequest, 'authKey'>;
(request as any).runTime = options.scheduledAt; if (isScheduled) {
// PA05-22: KB + runTime
request = {
account,
quota: quotaKbStr,
quotaCode: options.campaignCode,
expire: options.expiryDate,
runTime: options.scheduledAt,
};
} else {
// PA04-04: MB + kind
request = {
account,
kind: 'MVNO',
quota: quotaMbStr,
quotaCode: options.campaignCode,
expire: options.expiryDate,
};
} }
await this.makeAuthenticatedRequest<FreebititTopUpResponse>(endpoint, request); await this.makeAuthenticatedRequest<FreebititTopUpResponse>(endpoint, request);
this.logger.log(`Successfully topped up SIM ${account}`, { this.logger.log(`Successfully topped up SIM ${account}`, {
account, account,
endpoint,
quotaMb, quotaMb,
quotaKb, quotaKb,
units: isScheduled ? 'KB (PA05-22)' : 'MB (PA04-04)',
campaignCode: options.campaignCode, campaignCode: options.campaignCode,
scheduled: !!options.scheduledAt, scheduled: isScheduled,
}); });
} catch (error: any) { } catch (error: any) {
this.logger.error(`Failed to top up SIM ${account}`, { this.logger.error(`Failed to top up SIM ${account}`, {
@ -511,6 +528,7 @@ export class FreebititService {
try { try {
const request: Omit<FreebititAddSpecRequest, 'authKey'> = { const request: Omit<FreebititAddSpecRequest, 'authKey'> = {
account, account,
kind: 'MVNO',
}; };
if (typeof features.voiceMailEnabled === 'boolean') { if (typeof features.voiceMailEnabled === 'boolean') {

View File

@ -102,9 +102,16 @@ export interface FreebititTrafficInfoResponse {
export interface FreebititTopUpRequest { export interface FreebititTopUpRequest {
authKey: string; authKey: string;
account: string; account: string;
quota: number; // KB units (e.g., 102400 for 100MB) // NOTE: quota units vary by endpoint
// - PA04-04 (/master/addSpec/): MB units (string recommended by spec)
// - PA05-22 (/mvno/eachQuota/): KB units (string recommended by spec)
quota: number | string;
quotaCode?: string; // Campaign code quotaCode?: string; // Campaign code
expire?: string; // YYYYMMDD format expire?: string; // YYYYMMDD format
// For PA04-04 addSpec
kind?: string; // e.g. 'MVNO' (required by /master/addSpec/)
// For PA05-22 eachQuota
runTime?: string; // YYYYMMDD or YYYYMMDDhhmmss
} }
export interface FreebititTopUpResponse { export interface FreebititTopUpResponse {
@ -119,6 +126,7 @@ export interface FreebititTopUpResponse {
export interface FreebititAddSpecRequest { export interface FreebititAddSpecRequest {
authKey: string; authKey: string;
account: string; account: string;
kind?: string; // Required by PA04-04 (/master/addSpec/), e.g. 'MVNO'
// Feature flags: 10 = enabled, 20 = disabled // Feature flags: 10 = enabled, 20 = disabled
voiceMail?: '10' | '20'; voiceMail?: '10' | '20';
voicemail?: '10' | '20'; voicemail?: '10' | '20';

View File

@ -21,7 +21,7 @@ export default function SimTopUpPage() {
const isValidAmount = () => { const isValidAmount = () => {
const gb = Number(gbAmount); const gb = Number(gbAmount);
return Number.isInteger(gb) && gb >= 1 && gb <= 100; // 1-100 GB in whole numbers return Number.isInteger(gb) && gb >= 1 && gb <= 50; // 1-50 GB in whole numbers (Freebit API limit)
}; };
const calculateCost = () => { const calculateCost = () => {
@ -89,7 +89,7 @@ export default function SimTopUpPage() {
onChange={(e) => setGbAmount(e.target.value)} onChange={(e) => setGbAmount(e.target.value)}
placeholder="Enter amount in GB" placeholder="Enter amount in GB"
min="1" min="1"
max="100" max="50"
step="1" 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" 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"
/> />
@ -97,9 +97,9 @@ export default function SimTopUpPage() {
<span className="text-gray-500 text-sm">GB</span> <span className="text-gray-500 text-sm">GB</span>
</div> </div>
</div> </div>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
Enter the amount of data you want to add (1 - 100 GB, whole numbers) Enter the amount of data you want to add (1 - 50 GB, whole numbers)
</p> </p>
</div> </div>
{/* Cost Display */} {/* Cost Display */}
@ -118,7 +118,7 @@ export default function SimTopUpPage() {
¥{calculateCost().toLocaleString()} ¥{calculateCost().toLocaleString()}
</div> </div>
<div className="text-xs text-blue-700"> <div className="text-xs text-blue-700">
(500 JPY per GB) (1GB = ¥500)
</div> </div>
</div> </div>
</div> </div>
@ -131,9 +131,9 @@ export default function SimTopUpPage() {
<svg className="h-4 w-4 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-4 w-4 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" /> <path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg> </svg>
<p className="text-sm text-red-800"> <p className="text-sm text-red-800">
Amount must be a whole number between 1 GB and 100 GB Amount must be a whole number between 1 GB and 50 GB
</p> </p>
</div> </div>
</div> </div>
)} )}

View File

@ -26,7 +26,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
const isValidAmount = () => { const isValidAmount = () => {
const gb = Number(gbAmount); const gb = Number(gbAmount);
return Number.isInteger(gb) && gb >= 1 && gb <= 100; // 1-100 GB, whole numbers only return Number.isInteger(gb) && gb >= 1 && gb <= 50; // 1-50 GB, whole numbers only (Freebit API limit)
}; };
const calculateCost = () => { const calculateCost = () => {
@ -104,7 +104,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
onChange={(e) => setGbAmount(e.target.value)} onChange={(e) => setGbAmount(e.target.value)}
placeholder="Enter amount in GB" placeholder="Enter amount in GB"
min="1" min="1"
max="100" max="50"
step="1" 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" 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"
/> />
@ -113,7 +113,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
</div> </div>
</div> </div>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
Enter the amount of data you want to add (1 - 100 GB, whole numbers) Enter the amount of data you want to add (1 - 50 GB, whole numbers)
</p> </p>
</div> </div>
@ -133,7 +133,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
¥{calculateCost().toLocaleString()} ¥{calculateCost().toLocaleString()}
</div> </div>
<div className="text-xs text-blue-700"> <div className="text-xs text-blue-700">
(500 JPY per GB) (1GB = ¥500)
</div> </div>
</div> </div>
</div> </div>
@ -145,7 +145,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
<div className="flex items-center"> <div className="flex items-center">
<ExclamationTriangleIcon className="h-4 w-4 text-red-500 mr-2" /> <ExclamationTriangleIcon className="h-4 w-4 text-red-500 mr-2" />
<p className="text-sm text-red-800"> <p className="text-sm text-red-800">
Amount must be a whole number between 1 GB and 100 GB Amount must be a whole number between 1 GB and 50 GB
</p> </p>
</div> </div>
</div> </div>

View File

@ -344,7 +344,7 @@ This section provides a detailed breakdown of every element on the SIM managemen
#### Top Up Data (Complete Payment Flow): #### Top Up Data (Complete Payment Flow):
1. User clicks "Top Up Data" → Opens `TopUpModal` 1. User clicks "Top Up Data" → Opens `TopUpModal`
2. User selects amount (1GB = 500 JPY) → `POST /api/subscriptions/29951/sim/top-up` 2. User selects amount (1GB = 500 JPY) → `POST /api/subscriptions/29951/sim/top-up`
3. Backend: Calculate cost (quotaGb * 500 JPY) 3. Backend: Calculate cost (ceil(GB) × ¥500)
4. Backend: WHMCS `CreateInvoice` → Generate invoice for payment 4. Backend: WHMCS `CreateInvoice` → Generate invoice for payment
5. Backend: WHMCS `CapturePayment` → Process payment with invoice 5. Backend: WHMCS `CapturePayment` → Process payment with invoice
6. Backend: If payment successful → Freebit `PA04-04: Add Specs & Quota` 6. Backend: If payment successful → Freebit `PA04-04: Add Specs & Quota`
@ -813,13 +813,11 @@ The Freebit SIM management system is now fully implemented and ready for deploym
User Action → Cost Calculation → Invoice Creation → Payment Capture → Data Addition User Action → Cost Calculation → Invoice Creation → Payment Capture → Data Addition
``` ```
### 📊 **Pricing Structure**: ### 📊 **Pricing Structure**
- **1 GB = 500 JPY** - **1 GB = ¥500**
- **2 GB = 1,000 JPY** - **2 GB = ¥1,000**
- **5 GB = 2,500 JPY** - **5 GB = ¥2,500**
- **10 GB = 5,000 JPY** - **10 GB = ¥5,000**
- **20 GB = 10,000 JPY**
- **50 GB = 25,000 JPY**
### ⚠️ **Error Handling**: ### ⚠️ **Error Handling**:
- **Payment Failed**: No data added, user notified - **Payment Failed**: No data added, user notified

View File

@ -295,7 +295,7 @@ TopUpModal ───────▶ POST /sim/top-up ───────▶
capturePayment ───────────────▶ │ capturePayment ───────────────▶ │
│ paid (or failed) │ paid (or failed)
├── on success ─────────────────────────────▶ /master/addSpec/ ├── on success ─────────────────────────────▶ /master/addSpec/
│ (quota in KB) │ (quota in MB)
└── on failure ──┐ └── on failure ──┐
└──── return error (no Freebit call) └──── return error (no Freebit call)
``` ```
@ -308,12 +308,12 @@ BFF responsibilities
- Validate `quotaMb` (1100000) - Validate `quotaMb` (1100000)
- Price computation and invoice line creation (description includes quota) - Price computation and invoice line creation (description includes quota)
- Attempt payment capture (stored method or SSO handoff) - Attempt payment capture (stored method or SSO handoff)
- On success, call Freebit AddSpec with `quota=quotaMb*1000` and optional `expire` - On success, call Freebit AddSpec with `quota` in MB (string) and optional `expire`
- Return success to UI and refresh SIM info - Return success to UI and refresh SIM info
Freebit PA04-04 (Add Spec & Quota) request fields Freebit PA04-04 (Add Spec & Quota) request fields
- `account`: MSISDN (phone number) - `account`: MSISDN (phone number)
- `quota`: integer KB (100MB51200MB in screenshot spec; environment-dependent) - `quota`: integer MB (string) (100MB51200MB)
- `quotaCode` (optional): campaign code - `quotaCode` (optional): campaign code
- `expire` (optional): YYYYMMDD - `expire` (optional): YYYYMMDD