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:
parent
ac259ce902
commit
5a0c5272e0
@ -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(),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"],
|
||||
},
|
||||
|
||||
12
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
12
apps/bff/src/vendors/freebit/freebit.service.ts
vendored
@ -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,
|
||||
|
||||
@ -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+)
|
||||
|
||||
@ -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",
|
||||
|
||||
29
apps/portal/scripts/dev-prep.mjs
Normal file
29
apps/portal/scripts/dev-prep.mjs
Normal 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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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`;
|
||||
};
|
||||
|
||||
@ -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`;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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` (1–100000)
|
||||
- Price computation and invoice line creation (description includes quota)
|
||||
- Attempt payment capture (stored method or SSO handoff)
|
||||
- On success, call Freebit AddSpec with `quota=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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user