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(), EMAIL_TEMPLATE_WELCOME: z.string().optional(),
// Freebit API Configuration // 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"), FREEBIT_OEM_ID: z.string().default("PASI"),
// Optional in schema so dev can boot without it; service warns/guards at runtime // Optional in schema so dev can boot without it; service warns/guards at runtime
FREEBIT_OEM_KEY: z.string().optional(), FREEBIT_OEM_KEY: z.string().optional(),

View File

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

View File

@ -42,7 +42,7 @@ export class FreebititService {
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
) { ) {
this.config = { 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', oemId: this.configService.get<string>('FREEBIT_OEM_ID') || 'PASI',
oemKey: this.configService.get<string>('FREEBIT_OEM_KEY') || '', oemKey: this.configService.get<string>('FREEBIT_OEM_KEY') || '',
timeout: this.configService.get<number>('FREEBIT_TIMEOUT') || 30000, timeout: this.configService.get<number>('FREEBIT_TIMEOUT') || 30000,
@ -284,7 +284,7 @@ export class FreebititService {
hasVoice: simData.talk === 10, hasVoice: simData.talk === 10,
hasSms: simData.sms === 10, hasSms: simData.sms === 10,
remainingQuotaKb: typeof simData.quota === 'number' ? simData.quota : 0, 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, startDate,
ipv4: simData.ipv4, ipv4: simData.ipv4,
ipv6: simData.ipv6, ipv6: simData.ipv6,
@ -327,13 +327,13 @@ export class FreebititService {
const recentDaysData = response.traffic.inRecentDays.split(',').map((usage, index) => ({ const recentDaysData = response.traffic.inRecentDays.split(',').map((usage, index) => ({
date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split('T')[0], date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
usageKb: parseInt(usage, 10) || 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 = { const simUsage: SimUsage = {
account, account,
todayUsageKb, todayUsageKb,
todayUsageMb: Math.round(todayUsageKb / 1024 * 100) / 100, todayUsageMb: Math.round(todayUsageKb / 1000 * 100) / 100,
recentDaysUsage: recentDaysData, recentDaysUsage: recentDaysData,
isBlacklisted: response.traffic.blackList === '10', isBlacklisted: response.traffic.blackList === '10',
}; };
@ -360,7 +360,7 @@ export class FreebititService {
scheduledAt?: string; scheduledAt?: string;
} = {}): Promise<void> { } = {}): Promise<void> {
try { try {
const quotaKb = quotaMb * 1024; const quotaKb = quotaMb * 1000;
const request: Omit<FreebititTopUpRequest, 'authKey'> = { const request: Omit<FreebititTopUpRequest, 'authKey'> = {
account, account,
@ -417,7 +417,7 @@ export class FreebititService {
additionCount: response.count, additionCount: response.count,
history: response.quotaHistory.map(item => ({ history: response.quotaHistory.map(item => ({
quotaKb: parseInt(item.quota, 10), 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, addedDate: item.date,
expiryDate: item.expire, expiryDate: item.expire,
campaignCode: item.quotaCode, campaignCode: item.quotaCode,

View File

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

View File

@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"predev": "node ./scripts/dev-prep.mjs",
"dev": "next dev -p ${NEXT_PORT:-3000}", "dev": "next dev -p ${NEXT_PORT:-3000}",
"build": "next build", "build": "next build",
"build:turbo": "next build --turbopack", "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 { DashboardLayout } from "@/components/layout/dashboard-layout";
import { authenticatedApi } from "@/lib/api"; import { authenticatedApi } from "@/lib/api";
const PRESETS = [1024, 2048, 5120, 10240, 20480, 51200];
export default function SimTopUpPage() { export default function SimTopUpPage() {
const params = useParams(); const params = useParams();
const subscriptionId = parseInt(params.id as string); const subscriptionId = parseInt(params.id as string);
const [amountMb, setAmountMb] = useState<number>(2048); const [gbAmount, setGbAmount] = useState<string>('1');
const [scheduledAt, setScheduledAt] = useState("");
const [campaignCode, setCampaignCode] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [error, setError] = 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!isValidAmount()) {
setError('Please enter a whole number between 1 GB and 100 GB');
return;
}
setLoading(true); setLoading(true);
setMessage(null); setMessage(null);
setError(null); setError(null);
try { try {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/top-up`, { await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/top-up`, {
quotaMb: amountMb, quotaMb: getCurrentAmountMb(),
campaignCode: campaignCode || undefined,
scheduledAt: scheduledAt ? scheduledAt.replace(/-/g, "") : undefined,
}); });
setMessage("Top-up submitted successfully"); setMessage(`Successfully topped up ${gbAmount} GB for ¥${calculateCost().toLocaleString()}`);
} catch (e: any) { } 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 { } finally {
setLoading(false); setLoading(false);
} }
@ -45,54 +59,100 @@ export default function SimTopUpPage() {
<div className="mb-4"> <div className="mb-4">
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="text-blue-600 hover:text-blue-700"> Back to SIM Management</Link> <Link href={`/subscriptions/${subscriptionId}#sim-management`} className="text-blue-600 hover:text-blue-700"> Back to SIM Management</Link>
</div> </div>
<div className="bg-white rounded-xl border border-gray-200 p-6"> <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> <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> <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>} {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"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Amount Input */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2">Amount</label> <label className="block text-sm font-medium text-gray-700 mb-2">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> Amount (GB)
{PRESETS.map(mb => ( </label>
<button <div className="relative">
key={mb} <input
type="button" type="number"
onClick={() => setAmountMb(mb)} value={gbAmount}
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'}`} onChange={(e) => setGbAmount(e.target.value)}
> placeholder="Enter amount in GB"
{format(mb)} min="1"
</button> 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> </div>
<div> {/* Validation Warning */}
<label className="block text-sm font-medium text-gray-700 mb-2">Campaign Code (optional)</label> {!isValidAmount() && gbAmount && (
<input <div className="bg-red-50 border border-red-200 rounded-lg p-3">
type="text" <div className="flex items-center">
value={campaignCode} <svg className="h-4 w-4 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
onChange={(e) => setCampaignCode(e.target.value)} <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" />
className="w-full px-3 py-2 border border-gray-300 rounded-md" </svg>
placeholder="Enter code" <p className="text-sm text-red-800">
/> Amount must be a whole number between 1 GB and 100 GB
</div> </p>
</div>
<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>
{/* Action Buttons */}
<div className="flex gap-3"> <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> <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> 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> </div>
</form> </form>
</div> </div>

View File

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

View File

@ -100,8 +100,8 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
}; };
const formatQuota = (quotaMb: number) => { const formatQuota = (quotaMb: number) => {
if (quotaMb >= 1024) { if (quotaMb >= 1000) {
return `${(quotaMb / 1024).toFixed(1)} GB`; return `${(quotaMb / 1000).toFixed(1)} GB`;
} }
return `${quotaMb.toFixed(0)} MB`; 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 [loading, setLoading] = useState(false);
const getCurrentAmountMb = () => { const getCurrentAmountMb = () => {
const gb = parseFloat(gbAmount); const gb = parseInt(gbAmount, 10);
return isNaN(gb) ? 0 : Math.round(gb * 1024); return isNaN(gb) ? 0 : gb * 1000;
}; };
const isValidAmount = () => { const isValidAmount = () => {
const gb = parseFloat(gbAmount); const gb = Number(gbAmount);
return gb > 0 && gb <= 100; // Max 100GB return Number.isInteger(gb) && gb >= 1 && gb <= 100; // 1-100 GB, whole numbers only
}; };
const calculateCost = () => { const calculateCost = () => {
const gb = parseFloat(gbAmount); const gb = parseInt(gbAmount, 10);
return isNaN(gb) ? 0 : Math.round(gb * 500); // 1GB = 500 JPY return isNaN(gb) ? 0 : gb * 500; // 1GB = 500 JPY
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!isValidAmount()) { 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; return;
} }
@ -103,9 +103,9 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
value={gbAmount} value={gbAmount}
onChange={(e) => setGbAmount(e.target.value)} onChange={(e) => setGbAmount(e.target.value)}
placeholder="Enter amount in GB" placeholder="Enter amount in GB"
min="0.1" min="1"
max="100" 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" 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"> <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>
</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 (0.1 - 100 GB) Enter the amount of data you want to add (1 - 100 GB, whole numbers)
</p> </p>
</div> </div>
@ -122,7 +122,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<div className="text-sm font-medium text-blue-900"> <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>
<div className="text-xs text-blue-700"> <div className="text-xs text-blue-700">
= {getCurrentAmountMb()} MB = {getCurrentAmountMb()} MB
@ -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 between 0.1 GB and 100 GB Amount must be a whole number between 1 GB and 100 GB
</p> </p>
</div> </div>
</div> </div>
@ -175,4 +175,4 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
</div> </div>
</div> </div>
); );
} }

View File

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

View File

@ -93,16 +93,16 @@ All endpoints are prefixed with `/api/subscriptions/{id}/sim/`
"simType": "physical" "simType": "physical"
}, },
"usage": { "usage": {
"usedMb": 512, "usedMb": 500,
"totalMb": 1024, "totalMb": 1000,
"remainingMb": 512, "remainingMb": 500,
"usagePercentage": 50 "usagePercentage": 50
} }
} }
// POST /api/subscriptions/29951/sim/top-up // POST /api/subscriptions/29951/sim/top-up
{ {
"quotaMb": 1024, "quotaMb": 1000,
"scheduledDate": "2025-01-15" // optional "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 // Freebit API Response → Portal Display
{ {
"account": "08077052946", "account": "08077052946",
"todayUsageKb": 524288, // → "512 MB" (today's usage) "todayUsageKb": 500000, // → "500 MB" (today's usage)
"todayUsageMb": 512, // → Today's usage card "todayUsageMb": 500, // → Today's usage card
"recentDaysUsage": [ // → Recent usage history "recentDaysUsage": [ // → Recent usage history
{ {
"date": "2024-01-14", "date": "2024-01-14",
"usageKb": 1048576, "usageKb": 1000000,
"usageMb": 1024 // → Individual day bars "usageMb": 1000 // → Individual day bars
} }
], ],
"isBlacklisted": false // → Service restriction warning "isBlacklisted": false // → Service restriction warning
@ -498,7 +498,7 @@ Freebit_IPv6__c (Text, 39) - Assigned IPv6 address
-- Data Tracking -- Data Tracking
Freebit_Remaining_Quota_KB__c (Number) - Current remaining data in KB 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_Last_Usage_Sync__c (DateTime) - Last usage data sync
Freebit_Is_Blacklisted__c (Checkbox) - Service restriction status Freebit_Is_Blacklisted__c (Checkbox) - Service restriction status
@ -539,10 +539,10 @@ Add these to your `.env` file:
```bash ```bash
# Freebit API Configuration # Freebit API Configuration
# Production URL # Test URL (default for development/testing)
FREEBIT_BASE_URL=https://i1.mvno.net/emptool/api FREEBIT_BASE_URL=https://i1-q.mvno.net/emptool/api/
# Test URL (for development/testing) # Production URL (uncomment for production)
# FREEBIT_BASE_URL=https://i1-q.mvno.net/emptool/api # FREEBIT_BASE_URL=https://i1.mvno.net/emptool/api
FREEBIT_OEM_ID=PASI FREEBIT_OEM_ID=PASI
FREEBIT_OEM_KEY=6Au3o7wrQNR07JxFHPmf0YfFqN9a31t5 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 \ curl -X POST http://localhost:3001/api/subscriptions/{id}/sim/top-up \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-H "Authorization: Bearer {token}" \ -H "Authorization: Bearer {token}" \
-d '{"quotaMb": 1024}' -d '{"quotaMb": 1000}'
``` ```
### Frontend Testing ### Frontend Testing

View File

@ -280,15 +280,15 @@ Endpoints used
- BFF → Freebit: `PA04-04 Add Spec & Quota` (`/master/addSpec/`) if payment succeeds - BFF → Freebit: `PA04-04 Add Spec & Quota` (`/master/addSpec/`) if payment succeeds
Pricing Pricing
- Amount in JPY = ceil(quotaMb / 1024) × 500 - Amount in JPY = ceil(quotaMb / 1000) × 500
- Example: 1024MB → ¥500, 3072MB → ¥1,500 - Example: 1000MB → ¥500, 3000MB → ¥1,500
Happy-path sequence Happy-path sequence
``` ```
Frontend BFF WHMCS Freebit Frontend BFF WHMCS Freebit
────────── ──────────────── ──────────────── ──────────────── ────────── ──────────────── ──────────────── ────────────────
TopUpModal ───────▶ POST /sim/top-up ───────▶ createInvoice ─────▶ TopUpModal ───────▶ POST /sim/top-up ───────▶ createInvoice ─────▶
(quotaMb) (validate + map) (amount=ceil(MB/1024)*500) (quotaMb) (validate + map) (amount=ceil(MB/1000)*500)
│ │ │ │
│ invoiceId │ invoiceId
▼ │ ▼ │
@ -308,7 +308,7 @@ 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*1024` and optional `expire` - On success, call Freebit AddSpec with `quota=quotaMb*1000` 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