Update SIM management calculations and configurations

- Changed the base URL for Freebit API in environment validation to the new test URL.
- Updated data usage calculations in SimManagementService and related components to use 1000 instead of 1024 for MB to GB conversions.
- Adjusted the top-up modal and related components to reflect the new GB input method and validation rules.
- Enhanced documentation to align with the updated API and usage metrics.
This commit is contained in:
tema 2025-09-06 17:02:20 +09:00
parent ac259ce902
commit 5a0c5272e0
14 changed files with 197 additions and 98 deletions

View File

@ -64,7 +64,7 @@ export const envSchema = z.object({
EMAIL_TEMPLATE_WELCOME: z.string().optional(),
// Freebit API Configuration
FREEBIT_BASE_URL: z.string().url().default("https://i1.mvno.net/emptool/api"),
FREEBIT_BASE_URL: z.string().url().default("https://i1-q.mvno.net/emptool/api/"),
FREEBIT_OEM_ID: z.string().default("PASI"),
// Optional in schema so dev can boot without it; service warns/guards at runtime
FREEBIT_OEM_KEY: z.string().optional(),

View File

@ -194,7 +194,7 @@ export class SimManagementService {
if (stored.length > 0) {
simUsage.recentDaysUsage = stored.map(d => ({
date: d.date,
usageKb: Math.round(d.usageMb * 1024),
usageKb: Math.round(d.usageMb * 1000),
usageMb: d.usageMb,
}));
}
@ -235,7 +235,7 @@ export class SimManagementService {
// Calculate cost: 1GB = 500 JPY
const quotaGb = request.quotaMb / 1024;
const quotaGb = request.quotaMb / 1000;
const costJpy = Math.round(quotaGb * 500);
// Get client mapping for WHMCS
@ -547,10 +547,10 @@ export class SimManagementService {
if ((details.remainingQuotaMb === 0 || details.remainingQuotaMb == null) && planCapMatch) {
const capGb = parseInt(planCapMatch[1], 10);
if (!isNaN(capGb) && capGb > 0) {
const capMb = capGb * 1024;
const capMb = capGb * 1000;
const remainingMb = Math.max(capMb - usedMb, 0);
details.remainingQuotaMb = Math.round(remainingMb * 100) / 100;
details.remainingQuotaKb = Math.round(details.remainingQuotaMb * 1024);
details.remainingQuotaKb = Math.round(details.remainingQuotaMb * 1000);
}
}

View File

@ -288,7 +288,7 @@ export class SubscriptionsController {
schema: {
type: "object",
properties: {
quotaMb: { type: "number", description: "Quota in MB", example: 1024 },
quotaMb: { type: "number", description: "Quota in MB", example: 1000 },
},
required: ["quotaMb"],
},

View File

@ -42,7 +42,7 @@ export class FreebititService {
@Inject(Logger) private readonly logger: Logger,
) {
this.config = {
baseUrl: this.configService.get<string>('FREEBIT_BASE_URL') || 'https://i1.mvno.net/emptool/api',
baseUrl: this.configService.get<string>('FREEBIT_BASE_URL') || 'https://i1-q.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,
@ -284,7 +284,7 @@ export class FreebititService {
hasVoice: simData.talk === 10,
hasSms: simData.sms === 10,
remainingQuotaKb: typeof simData.quota === 'number' ? simData.quota : 0,
remainingQuotaMb: typeof simData.quota === 'number' ? Math.round((simData.quota / 1024) * 100) / 100 : 0,
remainingQuotaMb: typeof simData.quota === 'number' ? Math.round((simData.quota / 1000) * 100) / 100 : 0,
startDate,
ipv4: simData.ipv4,
ipv6: simData.ipv6,
@ -327,13 +327,13 @@ export class FreebititService {
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) / 1024 * 100) / 100,
usageMb: Math.round(parseInt(usage, 10) / 1000 * 100) / 100,
}));
const simUsage: SimUsage = {
account,
todayUsageKb,
todayUsageMb: Math.round(todayUsageKb / 1024 * 100) / 100,
todayUsageMb: Math.round(todayUsageKb / 1000 * 100) / 100,
recentDaysUsage: recentDaysData,
isBlacklisted: response.traffic.blackList === '10',
};
@ -360,7 +360,7 @@ export class FreebititService {
scheduledAt?: string;
} = {}): Promise<void> {
try {
const quotaKb = quotaMb * 1024;
const quotaKb = quotaMb * 1000;
const request: Omit<FreebititTopUpRequest, 'authKey'> = {
account,
@ -417,7 +417,7 @@ export class FreebititService {
additionCount: response.count,
history: response.quotaHistory.map(item => ({
quotaKb: parseInt(item.quota, 10),
quotaMb: Math.round(parseInt(item.quota, 10) / 1024 * 100) / 100,
quotaMb: Math.round(parseInt(item.quota, 10) / 1000 * 100) / 100,
addedDate: item.date,
expiryDate: item.expire,
campaignCode: item.quotaCode,

View File

@ -19,6 +19,8 @@ const nextConfig = {
"pino-abstract-transport",
"thread-stream",
"sonic-boom",
// Avoid flaky vendor-chunk resolution during dev for small utils
"tailwind-merge",
],
// Turbopack configuration (Next.js 15.5+)

View File

@ -3,6 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"predev": "node ./scripts/dev-prep.mjs",
"dev": "next dev -p ${NEXT_PORT:-3000}",
"build": "next build",
"build:turbo": "next build --turbopack",

View File

@ -0,0 +1,29 @@
#!/usr/bin/env node
// Ensure dev-time Next.js manifests exist to avoid noisy ENOENT errors
import { mkdirSync, existsSync, writeFileSync } from 'fs';
import { join } from 'path';
const root = new URL('..', import.meta.url).pathname; // apps/portal
const nextDir = join(root, '.next');
const routesManifestPath = join(nextDir, 'routes-manifest.json');
try {
mkdirSync(nextDir, { recursive: true });
if (!existsSync(routesManifestPath)) {
const minimalManifest = {
version: 5,
pages404: true,
basePath: '',
redirects: [],
rewrites: { beforeFiles: [], afterFiles: [], fallback: [] },
headers: [],
};
writeFileSync(routesManifestPath, JSON.stringify(minimalManifest, null, 2));
// eslint-disable-next-line no-console
console.log('[dev-prep] Created minimal .next/routes-manifest.json');
}
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[dev-prep] Failed to prepare Next dev files:', err?.message || err);
}

View File

@ -6,34 +6,48 @@ import { useParams } from "next/navigation";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { authenticatedApi } from "@/lib/api";
const PRESETS = [1024, 2048, 5120, 10240, 20480, 51200];
export default function SimTopUpPage() {
const params = useParams();
const subscriptionId = parseInt(params.id as string);
const [amountMb, setAmountMb] = useState<number>(2048);
const [scheduledAt, setScheduledAt] = useState("");
const [campaignCode, setCampaignCode] = useState("");
const [gbAmount, setGbAmount] = useState<string>('1');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const format = (mb: number) => (mb % 1024 === 0 ? `${mb / 1024} GB` : `${mb} MB`);
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 <= 100; // 1-100 GB in whole numbers
};
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()) {
setError('Please enter a whole number between 1 GB and 100 GB');
return;
}
setLoading(true);
setMessage(null);
setError(null);
try {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/top-up`, {
quotaMb: amountMb,
campaignCode: campaignCode || undefined,
scheduledAt: scheduledAt ? scheduledAt.replace(/-/g, "") : undefined,
quotaMb: getCurrentAmountMb(),
});
setMessage("Top-up submitted successfully");
setMessage(`Successfully topped up ${gbAmount} GB for ¥${calculateCost().toLocaleString()}`);
} catch (e: any) {
setError(e instanceof Error ? e.message : "Failed to submit top-up");
setError(e instanceof Error ? e.message : 'Failed to submit top-up');
} finally {
setLoading(false);
}
@ -45,54 +59,100 @@ export default function SimTopUpPage() {
<div className="mb-4">
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="text-blue-600 hover:text-blue-700"> Back to SIM Management</Link>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h1 className="text-xl font-semibold text-gray-900 mb-1">Top Up Data</h1>
<p className="text-sm text-gray-600 mb-6">Top Up Data: Add additional data quota to your SIM service. You can choose the amount and schedule it for later if needed.</p>
{message && <div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">{message}</div>}
{error && <div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div>}
<p className="text-sm text-gray-600 mb-6">Add additional data quota to your SIM service. Enter the amount of data you want to add.</p>
{message && (
<div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">
{message}
</div>
)}
{error && (
<div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Amount Input */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Amount</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{PRESETS.map(mb => (
<button
key={mb}
type="button"
onClick={() => setAmountMb(mb)}
className={`px-4 py-2 rounded-lg border text-sm ${amountMb === mb ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'}`}
>
{format(mb)}
</button>
))}
<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="100"
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 - 100 GB, whole numbers)
</p>
</div>
{/* Cost Display */}
<div className="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">
(500 JPY per GB)
</div>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Campaign Code (optional)</label>
<input
type="text"
value={campaignCode}
onChange={(e) => setCampaignCode(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
placeholder="Enter code"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Schedule (optional)</label>
<input
type="date"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
<p className="text-xs text-gray-500 mt-1">Leave empty to apply immediately</p>
</div>
{/* Validation Warning */}
{!isValidAmount() && gbAmount && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<div className="flex items-center">
<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" />
</svg>
<p className="text-sm text-red-800">
Amount must be a whole number between 1 GB and 100 GB
</p>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3">
<button type="submit" disabled={loading} className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50">{loading ? 'Processing…' : 'Submit Top-Up'}</button>
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50">Back</Link>
<button
type="submit"
disabled={loading || !isValidAmount()}
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
{loading ? 'Processing...' : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
</button>
<Link
href={`/subscriptions/${subscriptionId}#sim-management`}
className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Back
</Link>
</div>
</form>
</div>

View File

@ -28,8 +28,8 @@ interface DataUsageChartProps {
export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embedded = false }: DataUsageChartProps) {
const formatUsage = (usageMb: number) => {
if (usageMb >= 1024) {
return `${(usageMb / 1024).toFixed(1)} GB`;
if (usageMb >= 1000) {
return `${(usageMb / 1000).toFixed(1)} GB`;
}
return `${usageMb.toFixed(0)} MB`;
};

View File

@ -100,8 +100,8 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
};
const formatQuota = (quotaMb: number) => {
if (quotaMb >= 1024) {
return `${(quotaMb / 1024).toFixed(1)} GB`;
if (quotaMb >= 1000) {
return `${(quotaMb / 1000).toFixed(1)} GB`;
}
return `${quotaMb.toFixed(0)} MB`;
};

View File

@ -20,25 +20,25 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
const [loading, setLoading] = useState(false);
const getCurrentAmountMb = () => {
const gb = parseFloat(gbAmount);
return isNaN(gb) ? 0 : Math.round(gb * 1024);
const gb = parseInt(gbAmount, 10);
return isNaN(gb) ? 0 : gb * 1000;
};
const isValidAmount = () => {
const gb = parseFloat(gbAmount);
return gb > 0 && gb <= 100; // Max 100GB
const gb = Number(gbAmount);
return Number.isInteger(gb) && gb >= 1 && gb <= 100; // 1-100 GB, whole numbers only
};
const calculateCost = () => {
const gb = parseFloat(gbAmount);
return isNaN(gb) ? 0 : Math.round(gb * 500); // 1GB = 500 JPY
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 valid amount between 0.1 GB and 100 GB');
onError('Please enter a whole number between 1 GB and 100 GB');
return;
}
@ -103,9 +103,9 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
value={gbAmount}
onChange={(e) => setGbAmount(e.target.value)}
placeholder="Enter amount in GB"
min="0.1"
min="1"
max="100"
step="0.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"
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
@ -113,7 +113,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
</div>
</div>
<p className="text-xs text-gray-500 mt-1">
Enter the amount of data you want to add (0.1 - 100 GB)
Enter the amount of data you want to add (1 - 100 GB, whole numbers)
</p>
</div>
@ -122,7 +122,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
<div className="flex justify-between items-center">
<div>
<div className="text-sm font-medium text-blue-900">
{gbAmount && !isNaN(parseFloat(gbAmount)) ? `${gbAmount} GB` : '0 GB'}
{gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : '0 GB'}
</div>
<div className="text-xs text-blue-700">
= {getCurrentAmountMb()} MB
@ -145,7 +145,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
<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 between 0.1 GB and 100 GB
Amount must be a whole number between 1 GB and 100 GB
</p>
</div>
</div>
@ -175,4 +175,4 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
</div>
</div>
);
}
}

View File

@ -1,7 +1,7 @@
"use client";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import dynamic from "next/dynamic";
import { queryClient } from "@/lib/query-client";
interface QueryProviderProps {
@ -11,10 +11,17 @@ interface QueryProviderProps {
export function QueryProvider({ children }: QueryProviderProps) {
const enableDevtools =
process.env.NEXT_PUBLIC_ENABLE_DEVTOOLS === "true" && process.env.NODE_ENV !== "production";
const ReactQueryDevtools = enableDevtools
? dynamic(() => import("@tanstack/react-query-devtools").then(m => m.ReactQueryDevtools), {
ssr: false,
})
: null;
return (
<QueryClientProvider client={queryClient}>
{children}
{enableDevtools ? <ReactQueryDevtools initialIsOpen={false} /> : null}
{enableDevtools && ReactQueryDevtools ? (
<ReactQueryDevtools initialIsOpen={false} />
) : null}
</QueryClientProvider>
);
}

View File

@ -93,16 +93,16 @@ All endpoints are prefixed with `/api/subscriptions/{id}/sim/`
"simType": "physical"
},
"usage": {
"usedMb": 512,
"totalMb": 1024,
"remainingMb": 512,
"usedMb": 500,
"totalMb": 1000,
"remainingMb": 500,
"usagePercentage": 50
}
}
// POST /api/subscriptions/29951/sim/top-up
{
"quotaMb": 1024,
"quotaMb": 1000,
"scheduledDate": "2025-01-15" // optional
}
```
@ -226,13 +226,13 @@ This section provides a detailed breakdown of every element on the SIM managemen
// Freebit API Response → Portal Display
{
"account": "08077052946",
"todayUsageKb": 524288, // → "512 MB" (today's usage)
"todayUsageMb": 512, // → Today's usage card
"todayUsageKb": 500000, // → "500 MB" (today's usage)
"todayUsageMb": 500, // → Today's usage card
"recentDaysUsage": [ // → Recent usage history
{
"date": "2024-01-14",
"usageKb": 1048576,
"usageMb": 1024 // → Individual day bars
"usageKb": 1000000,
"usageMb": 1000 // → Individual day bars
}
],
"isBlacklisted": false // → Service restriction warning
@ -498,7 +498,7 @@ 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 / 1024
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
@ -539,10 +539,10 @@ Add these to your `.env` file:
```bash
# Freebit API Configuration
# Production URL
FREEBIT_BASE_URL=https://i1.mvno.net/emptool/api
# Test URL (for development/testing)
# FREEBIT_BASE_URL=https://i1-q.mvno.net/emptool/api
# 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
@ -586,7 +586,7 @@ curl -X POST http://localhost:3001/api/subscriptions/{id}/sim/details \
curl -X POST http://localhost:3001/api/subscriptions/{id}/sim/top-up \
-H "Content-Type: application/json" \
-H "Authorization: Bearer {token}" \
-d '{"quotaMb": 1024}'
-d '{"quotaMb": 1000}'
```
### Frontend Testing

View File

@ -280,15 +280,15 @@ Endpoints used
- BFF → Freebit: `PA04-04 Add Spec & Quota` (`/master/addSpec/`) if payment succeeds
Pricing
- Amount in JPY = ceil(quotaMb / 1024) × 500
- Example: 1024MB → ¥500, 3072MB → ¥1,500
- 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/1024)*500)
(quotaMb) (validate + map) (amount=ceil(MB/1000)*500)
│ │
│ invoiceId
▼ │
@ -308,7 +308,7 @@ 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=quotaMb*1024` and optional `expire`
- On success, call Freebit AddSpec with `quota=quotaMb*1000` and optional `expire`
- Return success to UI and refresh SIM info
Freebit PA04-04 (Add Spec & Quota) request fields