From 5a0c5272e012d524cef09016577cf266dac661e5 Mon Sep 17 00:00:00 2001 From: tema Date: Sat, 6 Sep 2025 17:02:20 +0900 Subject: [PATCH] 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. --- apps/bff/src/common/config/env.validation.ts | 2 +- .../subscriptions/sim-management.service.ts | 8 +- .../subscriptions/subscriptions.controller.ts | 2 +- .../src/vendors/freebit/freebit.service.ts | 12 +- apps/portal/next.config.mjs | 2 + apps/portal/package.json | 1 + apps/portal/scripts/dev-prep.mjs | 29 ++++ .../subscriptions/[id]/sim/top-up/page.tsx | 158 ++++++++++++------ .../components/DataUsageChart.tsx | 4 +- .../components/SimDetailsCard.tsx | 4 +- .../sim-management/components/TopUpModal.tsx | 26 +-- apps/portal/src/providers/query-provider.tsx | 11 +- docs/FREEBIT-SIM-MANAGEMENT.md | 28 ++-- docs/SIM-MANAGEMENT-API-DATA-FLOW.md | 8 +- 14 files changed, 197 insertions(+), 98 deletions(-) create mode 100644 apps/portal/scripts/dev-prep.mjs diff --git a/apps/bff/src/common/config/env.validation.ts b/apps/bff/src/common/config/env.validation.ts index 91ffaa24..1a45e846 100644 --- a/apps/bff/src/common/config/env.validation.ts +++ b/apps/bff/src/common/config/env.validation.ts @@ -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(), diff --git a/apps/bff/src/subscriptions/sim-management.service.ts b/apps/bff/src/subscriptions/sim-management.service.ts index 8d6afb2a..cbd35212 100644 --- a/apps/bff/src/subscriptions/sim-management.service.ts +++ b/apps/bff/src/subscriptions/sim-management.service.ts @@ -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); } } diff --git a/apps/bff/src/subscriptions/subscriptions.controller.ts b/apps/bff/src/subscriptions/subscriptions.controller.ts index 773cc4d1..7def34af 100644 --- a/apps/bff/src/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/subscriptions/subscriptions.controller.ts @@ -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"], }, diff --git a/apps/bff/src/vendors/freebit/freebit.service.ts b/apps/bff/src/vendors/freebit/freebit.service.ts index 61e223cd..9990e375 100644 --- a/apps/bff/src/vendors/freebit/freebit.service.ts +++ b/apps/bff/src/vendors/freebit/freebit.service.ts @@ -42,7 +42,7 @@ export class FreebititService { @Inject(Logger) private readonly logger: Logger, ) { this.config = { - baseUrl: this.configService.get('FREEBIT_BASE_URL') || 'https://i1.mvno.net/emptool/api', + baseUrl: this.configService.get('FREEBIT_BASE_URL') || 'https://i1-q.mvno.net/emptool/api/', oemId: this.configService.get('FREEBIT_OEM_ID') || 'PASI', oemKey: this.configService.get('FREEBIT_OEM_KEY') || '', timeout: this.configService.get('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 { try { - const quotaKb = quotaMb * 1024; + const quotaKb = quotaMb * 1000; const request: Omit = { 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, diff --git a/apps/portal/next.config.mjs b/apps/portal/next.config.mjs index 4aae8d2b..1ef54a7b 100644 --- a/apps/portal/next.config.mjs +++ b/apps/portal/next.config.mjs @@ -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+) diff --git a/apps/portal/package.json b/apps/portal/package.json index 5fd7dc56..2444ab40 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -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", diff --git a/apps/portal/scripts/dev-prep.mjs b/apps/portal/scripts/dev-prep.mjs new file mode 100644 index 00000000..ef413ba3 --- /dev/null +++ b/apps/portal/scripts/dev-prep.mjs @@ -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); +} + diff --git a/apps/portal/src/app/subscriptions/[id]/sim/top-up/page.tsx b/apps/portal/src/app/subscriptions/[id]/sim/top-up/page.tsx index 7d83e93f..8efa8fea 100644 --- a/apps/portal/src/app/subscriptions/[id]/sim/top-up/page.tsx +++ b/apps/portal/src/app/subscriptions/[id]/sim/top-up/page.tsx @@ -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(2048); - const [scheduledAt, setScheduledAt] = useState(""); - const [campaignCode, setCampaignCode] = useState(""); + const [gbAmount, setGbAmount] = useState('1'); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(null); const [error, setError] = useState(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() {
← Back to SIM Management
+

Top Up Data

-

Top Up Data: Add additional data quota to your SIM service. You can choose the amount and schedule it for later if needed.

- {message &&
{message}
} - {error &&
{error}
} +

Add additional data quota to your SIM service. Enter the amount of data you want to add.

+ + {message && ( +
+ {message} +
+ )} + + {error && ( +
+ {error} +
+ )}
+ {/* Amount Input */}
- -
- {PRESETS.map(mb => ( - - ))} + +
+ 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" + /> +
+ GB +
+
+

+ Enter the amount of data you want to add (1 - 100 GB, whole numbers) +

+
+ + {/* Cost Display */} +
+
+
+
+ {gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : '0 GB'} +
+
+ = {getCurrentAmountMb()} MB +
+
+
+
+ ¥{calculateCost().toLocaleString()} +
+
+ (500 JPY per GB) +
+
-
- - setCampaignCode(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md" - placeholder="Enter code" - /> -
- -
- - setScheduledAt(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md" - /> -

Leave empty to apply immediately

-
+ {/* Validation Warning */} + {!isValidAmount() && gbAmount && ( +
+
+ + + +

+ Amount must be a whole number between 1 GB and 100 GB +

+
+
+ )} + {/* Action Buttons */}
- - Back + + + Back +
diff --git a/apps/portal/src/features/sim-management/components/DataUsageChart.tsx b/apps/portal/src/features/sim-management/components/DataUsageChart.tsx index acd83c93..f140fea6 100644 --- a/apps/portal/src/features/sim-management/components/DataUsageChart.tsx +++ b/apps/portal/src/features/sim-management/components/DataUsageChart.tsx @@ -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`; }; diff --git a/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx b/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx index 2fefa8b3..3b23465d 100644 --- a/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx +++ b/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx @@ -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`; }; diff --git a/apps/portal/src/features/sim-management/components/TopUpModal.tsx b/apps/portal/src/features/sim-management/components/TopUpModal.tsx index e96ab3f5..42e78edf 100644 --- a/apps/portal/src/features/sim-management/components/TopUpModal.tsx +++ b/apps/portal/src/features/sim-management/components/TopUpModal.tsx @@ -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" />
@@ -113,7 +113,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU

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

@@ -122,7 +122,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
- {gbAmount && !isNaN(parseFloat(gbAmount)) ? `${gbAmount} GB` : '0 GB'} + {gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : '0 GB'}
= {getCurrentAmountMb()} MB @@ -145,7 +145,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU

- Amount must be between 0.1 GB and 100 GB + Amount must be a whole number between 1 GB and 100 GB

@@ -175,4 +175,4 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
); -} \ No newline at end of file +} diff --git a/apps/portal/src/providers/query-provider.tsx b/apps/portal/src/providers/query-provider.tsx index 3af75db0..6de89308 100644 --- a/apps/portal/src/providers/query-provider.tsx +++ b/apps/portal/src/providers/query-provider.tsx @@ -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 ( {children} - {enableDevtools ? : null} + {enableDevtools && ReactQueryDevtools ? ( + + ) : null} ); } diff --git a/docs/FREEBIT-SIM-MANAGEMENT.md b/docs/FREEBIT-SIM-MANAGEMENT.md index eb213897..9748ddc4 100644 --- a/docs/FREEBIT-SIM-MANAGEMENT.md +++ b/docs/FREEBIT-SIM-MANAGEMENT.md @@ -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 diff --git a/docs/SIM-MANAGEMENT-API-DATA-FLOW.md b/docs/SIM-MANAGEMENT-API-DATA-FLOW.md index 5d0deb08..82d49eab 100644 --- a/docs/SIM-MANAGEMENT-API-DATA-FLOW.md +++ b/docs/SIM-MANAGEMENT-API-DATA-FLOW.md @@ -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