diff --git a/apps/bff/src/auth/services/token-blacklist.service.ts b/apps/bff/src/auth/services/token-blacklist.service.ts index 89b4b48e..993aee50 100644 --- a/apps/bff/src/auth/services/token-blacklist.service.ts +++ b/apps/bff/src/auth/services/token-blacklist.service.ts @@ -30,9 +30,12 @@ export class TokenBlacklistService { const defaultTtl = this.parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d")); await this.redis.setex(`blacklist:${token}`, defaultTtl, "1"); } catch (err) { - this.logger.warn("Failed to write token to Redis blacklist; proceeding without persistence", { - error: err instanceof Error ? err.message : String(err), - }); + this.logger.warn( + "Failed to write token to Redis blacklist; proceeding without persistence", + { + error: err instanceof Error ? err.message : String(err), + } + ); } } } diff --git a/apps/bff/src/common/config/env.validation.ts b/apps/bff/src/common/config/env.validation.ts index 1a45e846..8b531af4 100644 --- a/apps/bff/src/common/config/env.validation.ts +++ b/apps/bff/src/common/config/env.validation.ts @@ -45,9 +45,7 @@ export const envSchema = z.object({ // Salesforce Platform Events (Async Provisioning) SF_EVENTS_ENABLED: z.enum(["true", "false"]).default("false"), - SF_PROVISION_EVENT_CHANNEL: z - .string() - .default("/event/Order_Fulfilment_Requested__e"), + SF_PROVISION_EVENT_CHANNEL: z.string().default("/event/Order_Fulfilment_Requested__e"), SF_EVENTS_REPLAY: z.enum(["LATEST", "ALL"]).default("LATEST"), SF_PUBSUB_ENDPOINT: z.string().default("api.pubsub.salesforce.com:7443"), SF_PUBSUB_NUM_REQUESTED: z.string().default("50"), diff --git a/apps/bff/src/common/config/field-map.ts b/apps/bff/src/common/config/field-map.ts index eb51d573..8f180a24 100644 --- a/apps/bff/src/common/config/field-map.ts +++ b/apps/bff/src/common/config/field-map.ts @@ -188,13 +188,11 @@ export function getSalesforceFieldMap(): SalesforceFieldMap { // Billing address snapshot fields — single source of truth: Billing* fields on Order billing: { - street: process.env.ORDER_BILLING_STREET_FIELD || "BillingStreet", city: process.env.ORDER_BILLING_CITY_FIELD || "BillingCity", state: process.env.ORDER_BILLING_STATE_FIELD || "BillingState", postalCode: process.env.ORDER_BILLING_POSTAL_CODE_FIELD || "BillingPostalCode", country: process.env.ORDER_BILLING_COUNTRY_FIELD || "BillingCountry", - }, }, orderItem: { diff --git a/apps/bff/src/subscriptions/sim-management.service.ts b/apps/bff/src/subscriptions/sim-management.service.ts index fda505e6..1f73d5e0 100644 --- a/apps/bff/src/subscriptions/sim-management.service.ts +++ b/apps/bff/src/subscriptions/sim-management.service.ts @@ -1,12 +1,12 @@ -import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common'; -import { Logger } from 'nestjs-pino'; -import { FreebititService } from '../vendors/freebit/freebit.service'; -import { WhmcsService } from '../vendors/whmcs/whmcs.service'; -import { MappingsService } from '../mappings/mappings.service'; -import { SubscriptionsService } from './subscriptions.service'; -import { SimDetails, SimUsage, SimTopUpHistory } from '../vendors/freebit/interfaces/freebit.types'; -import { SimUsageStoreService } from './sim-usage-store.service'; -import { getErrorMessage } from '../common/utils/error.util'; +import { Injectable, Inject, BadRequestException, NotFoundException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { FreebititService } from "../vendors/freebit/freebit.service"; +import { WhmcsService } from "../vendors/whmcs/whmcs.service"; +import { MappingsService } from "../mappings/mappings.service"; +import { SubscriptionsService } from "./subscriptions.service"; +import { SimDetails, SimUsage, SimTopUpHistory } from "../vendors/freebit/interfaces/freebit.types"; +import { SimUsageStoreService } from "./sim-usage-store.service"; +import { getErrorMessage } from "../common/utils/error.util"; export interface SimTopUpRequest { quotaMb: number; @@ -22,14 +22,14 @@ export interface SimCancelRequest { export interface SimTopUpHistoryRequest { fromDate: string; // YYYYMMDD - toDate: string; // YYYYMMDD + toDate: string; // YYYYMMDD } export interface SimFeaturesUpdateRequest { voiceMailEnabled?: boolean; callWaitingEnabled?: boolean; internationalRoamingEnabled?: boolean; - networkType?: '4G' | '5G'; + networkType?: "4G" | "5G"; } @Injectable() @@ -40,7 +40,7 @@ export class SimManagementService { private readonly mappingsService: MappingsService, private readonly subscriptionsService: SubscriptionsService, @Inject(Logger) private readonly logger: Logger, - private readonly usageStore: SimUsageStoreService, + private readonly usageStore: SimUsageStoreService ) {} /** @@ -48,37 +48,43 @@ export class SimManagementService { */ async debugSimSubscription(userId: string, subscriptionId: number): Promise { try { - const subscription = await this.subscriptionsService.getSubscriptionById(userId, subscriptionId); - + const subscription = await this.subscriptionsService.getSubscriptionById( + userId, + subscriptionId + ); + // Check for specific SIM data - const expectedSimNumber = '02000331144508'; - const expectedEid = '89049032000001000000043598005455'; - + const expectedSimNumber = "02000331144508"; + const expectedEid = "89049032000001000000043598005455"; + const simNumberField = Object.entries(subscription.customFields || {}).find( ([key, value]) => value && value.toString().includes(expectedSimNumber) ); - + const eidField = Object.entries(subscription.customFields || {}).find( ([key, value]) => value && value.toString().includes(expectedEid) ); - + return { subscriptionId, productName: subscription.productName, domain: subscription.domain, orderNumber: subscription.orderNumber, customFields: subscription.customFields, - isSimService: subscription.productName.toLowerCase().includes('sim') || - subscription.groupName?.toLowerCase().includes('sim'), + isSimService: + subscription.productName.toLowerCase().includes("sim") || + subscription.groupName?.toLowerCase().includes("sim"), groupName: subscription.groupName, status: subscription.status, // Specific SIM data checks expectedSimNumber, expectedEid, - foundSimNumber: simNumberField ? { field: simNumberField[0], value: simNumberField[1] } : null, + foundSimNumber: simNumberField + ? { field: simNumberField[0], value: simNumberField[1] } + : null, foundEid: eidField ? { field: eidField[0], value: eidField[1] } : null, allCustomFieldKeys: Object.keys(subscription.customFields || {}), - allCustomFieldValues: subscription.customFields + allCustomFieldValues: subscription.customFields, }; } catch (error) { this.logger.error(`Failed to debug subscription ${subscriptionId}`, { @@ -91,44 +97,79 @@ export class SimManagementService { /** * Check if a subscription is a SIM service */ - private async validateSimSubscription(userId: string, subscriptionId: number): Promise<{ account: string }> { + private async validateSimSubscription( + userId: string, + subscriptionId: number + ): Promise<{ account: string }> { try { // Get subscription details to verify it's a SIM service - const subscription = await this.subscriptionsService.getSubscriptionById(userId, subscriptionId); - + const subscription = await this.subscriptionsService.getSubscriptionById( + userId, + subscriptionId + ); + // Check if this is a SIM service (you may need to adjust this logic based on your product naming) - const isSimService = subscription.productName.toLowerCase().includes('sim') || - subscription.groupName?.toLowerCase().includes('sim'); - + const isSimService = + subscription.productName.toLowerCase().includes("sim") || + subscription.groupName?.toLowerCase().includes("sim"); + if (!isSimService) { - throw new BadRequestException('This subscription is not a SIM service'); + throw new BadRequestException("This subscription is not a SIM service"); } // For SIM services, the account identifier (phone number) can be stored in multiple places - let account = ''; - + let account = ""; + // 1. Try domain field first if (subscription.domain && subscription.domain.trim()) { account = subscription.domain.trim(); } - + // 2. If no domain, check custom fields for phone number/MSISDN if (!account && subscription.customFields) { // Common field names for SIM phone numbers in WHMCS const phoneFields = [ - 'phone', 'msisdn', 'phonenumber', 'phone_number', 'mobile', 'sim_phone', - 'Phone Number', 'MSISDN', 'Phone', 'Mobile', 'SIM Phone', 'PhoneNumber', - 'phone_number', 'mobile_number', 'sim_number', 'account_number', - 'Account Number', 'SIM Account', 'Phone Number (SIM)', 'Mobile Number', + "phone", + "msisdn", + "phonenumber", + "phone_number", + "mobile", + "sim_phone", + "Phone Number", + "MSISDN", + "Phone", + "Mobile", + "SIM Phone", + "PhoneNumber", + "phone_number", + "mobile_number", + "sim_number", + "account_number", + "Account Number", + "SIM Account", + "Phone Number (SIM)", + "Mobile Number", // Specific field names that might contain the SIM number - 'SIM Number', 'SIM_Number', 'sim_number', 'SIM_Phone_Number', - 'Phone_Number_SIM', 'Mobile_SIM_Number', 'SIM_Account_Number', - 'ICCID', 'iccid', 'IMSI', 'imsi', 'EID', 'eid', + "SIM Number", + "SIM_Number", + "sim_number", + "SIM_Phone_Number", + "Phone_Number_SIM", + "Mobile_SIM_Number", + "SIM_Account_Number", + "ICCID", + "iccid", + "IMSI", + "imsi", + "EID", + "eid", // Additional variations - '02000331144508', // Direct match for your specific SIM number - 'SIM_Data', 'SIM_Info', 'SIM_Details' + "02000331144508", // Direct match for your specific SIM number + "SIM_Data", + "SIM_Info", + "SIM_Details", ]; - + for (const fieldName of phoneFields) { if (subscription.customFields[fieldName]) { account = subscription.customFields[fieldName]; @@ -136,35 +177,40 @@ export class SimManagementService { userId, subscriptionId, fieldName, - account + account, }); break; } } - + // If still no account found, log all available custom fields for debugging if (!account) { - this.logger.warn(`No SIM account found in custom fields for subscription ${subscriptionId}`, { - userId, - subscriptionId, - availableFields: Object.keys(subscription.customFields), - customFields: subscription.customFields, - searchedFields: phoneFields - }); - + this.logger.warn( + `No SIM account found in custom fields for subscription ${subscriptionId}`, + { + userId, + subscriptionId, + availableFields: Object.keys(subscription.customFields), + customFields: subscription.customFields, + searchedFields: phoneFields, + } + ); + // Check if any field contains the expected SIM number - const expectedSimNumber = '02000331144508'; + const expectedSimNumber = "02000331144508"; const foundSimNumber = Object.entries(subscription.customFields || {}).find( ([key, value]) => value && value.toString().includes(expectedSimNumber) ); - + if (foundSimNumber) { - this.logger.log(`Found expected SIM number ${expectedSimNumber} in field '${foundSimNumber[0]}': ${foundSimNumber[1]}`); + this.logger.log( + `Found expected SIM number ${expectedSimNumber} in field '${foundSimNumber[0]}': ${foundSimNumber[1]}` + ); account = foundSimNumber[1].toString(); } } } - + // 3. If still no account, check if subscription ID looks like a phone number if (!account && subscription.orderNumber) { const orderNum = subscription.orderNumber.toString(); @@ -172,25 +218,28 @@ export class SimManagementService { account = orderNum; } } - + // 4. Final fallback - for testing, use the known test SIM number if (!account) { // Use the specific test SIM number that should exist in the test environment - account = '02000331144508'; - - this.logger.warn(`No SIM account identifier found for subscription ${subscriptionId}, using known test SIM number: ${account}`, { - userId, - subscriptionId, - productName: subscription.productName, - domain: subscription.domain, - customFields: subscription.customFields ? Object.keys(subscription.customFields) : [], - note: 'Using known test SIM number 02000331144508 - should exist in Freebit test environment' - }); + account = "02000331144508"; + + this.logger.warn( + `No SIM account identifier found for subscription ${subscriptionId}, using known test SIM number: ${account}`, + { + userId, + subscriptionId, + productName: subscription.productName, + domain: subscription.domain, + customFields: subscription.customFields ? Object.keys(subscription.customFields) : [], + note: "Using known test SIM number 02000331144508 - should exist in Freebit test environment", + } + ); } // Clean up the account format (remove hyphens, spaces, etc.) - account = account.replace(/[-\s()]/g, ''); - + account = account.replace(/[-\s()]/g, ""); + // Skip phone number format validation for testing // In production, you might want to add validation back: // const cleanAccount = account.replace(/^\+81/, '0'); // Convert +81 to 0 @@ -198,19 +247,22 @@ export class SimManagementService { // throw new BadRequestException(`Invalid SIM account format: ${account}. Expected Japanese phone number format (10-11 digits starting with 0).`); // } // account = cleanAccount; - + this.logger.log(`Using SIM account for testing: ${account}`, { userId, subscriptionId, account, - note: 'Phone number format validation skipped for testing' + note: "Phone number format validation skipped for testing", }); return { account }; } catch (error) { - this.logger.error(`Failed to validate SIM subscription ${subscriptionId} for user ${userId}`, { - error: getErrorMessage(error), - }); + this.logger.error( + `Failed to validate SIM subscription ${subscriptionId} for user ${userId}`, + { + error: getErrorMessage(error), + } + ); throw error; } } @@ -221,9 +273,9 @@ export class SimManagementService { async getSimDetails(userId: string, subscriptionId: number): Promise { try { const { account } = await this.validateSimSubscription(userId, subscriptionId); - + const simDetails = await this.freebititService.getSimDetails(account); - + this.logger.log(`Retrieved SIM details for subscription ${subscriptionId}`, { userId, subscriptionId, @@ -248,7 +300,7 @@ export class SimManagementService { async getSimUsage(userId: string, subscriptionId: number): Promise { try { const { account } = await this.validateSimSubscription(userId, subscriptionId); - + const simUsage = await this.freebititService.getSimUsage(account); // Persist today's usage for monthly charts and cleanup previous months @@ -264,9 +316,12 @@ export class SimManagementService { })); } } catch (e) { - this.logger.warn('SIM usage persistence failed (non-fatal)', { account, error: getErrorMessage(e) }); + this.logger.warn("SIM usage persistence failed (non-fatal)", { + account, + error: getErrorMessage(e), + }); } - + this.logger.log(`Retrieved SIM usage for subscription ${subscriptionId}`, { userId, subscriptionId, @@ -292,26 +347,28 @@ export class SimManagementService { async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise { try { const { account } = await this.validateSimSubscription(userId, subscriptionId); - + // Validate quota amount if (request.quotaMb <= 0 || request.quotaMb > 100000) { - throw new BadRequestException('Quota must be between 1MB and 100GB'); + throw new BadRequestException("Quota must be between 1MB and 100GB"); } // Calculate cost: 1GB = 500 JPY (rounded up to nearest GB) const quotaGb = request.quotaMb / 1000; const units = Math.ceil(quotaGb); const costJpy = units * 500; - + // Validate quota against Freebit API limits (100MB - 51200MB) if (request.quotaMb < 100 || request.quotaMb > 51200) { - throw new BadRequestException('Quota must be between 100MB and 51200MB (50GB) for Freebit API compatibility'); + throw new BadRequestException( + "Quota must be between 100MB and 51200MB (50GB) for Freebit API compatibility" + ); } // Get client mapping for WHMCS const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { - throw new BadRequestException('WHMCS client mapping not found'); + throw new BadRequestException("WHMCS client mapping not found"); } this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, { @@ -328,7 +385,7 @@ export class SimManagementService { clientId: mapping.whmcsClientId, description: `SIM Data Top-up: ${units}GB for ${account}`, amount: costJpy, - currency: 'JPY', + currency: "JPY", dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now notes: `Subscription ID: ${subscriptionId}, Phone: ${account}`, }); @@ -349,7 +406,7 @@ export class SimManagementService { const paymentResult = await this.whmcsService.capturePayment({ invoiceId: invoice.id, amount: costJpy, - currency: 'JPY', + currency: "JPY", }); if (!paymentResult.success) { @@ -363,19 +420,19 @@ export class SimManagementService { try { await this.whmcsService.updateInvoice({ invoiceId: invoice.id, - status: 'Cancelled', - notes: `Payment capture failed: ${paymentResult.error}. Invoice cancelled automatically.` + status: "Cancelled", + notes: `Payment capture failed: ${paymentResult.error}. Invoice cancelled automatically.`, }); - + this.logger.log(`Cancelled invoice ${invoice.id} due to payment failure`, { invoiceId: invoice.id, - reason: 'Payment capture failed' + reason: "Payment capture failed", }); } catch (cancelError) { this.logger.error(`Failed to cancel invoice ${invoice.id} after payment failure`, { invoiceId: invoice.id, cancelError: getErrorMessage(cancelError), - originalError: paymentResult.error + originalError: paymentResult.error, }); } @@ -392,7 +449,7 @@ export class SimManagementService { try { // Step 3: Only if payment successful, add data via Freebit await this.freebititService.topUpSim(account, request.quotaMb, {}); - + this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, { userId, subscriptionId, @@ -408,36 +465,39 @@ export class SimManagementService { // 1. Create a refund/credit // 2. Send notification to admin // 3. Queue for retry - this.logger.error(`Freebit API failed after successful payment for subscription ${subscriptionId}`, { - error: getErrorMessage(freebititError), - userId, - subscriptionId, - account, - quotaMb: request.quotaMb, - invoiceId: invoice.id, - transactionId: paymentResult.transactionId, - paymentCaptured: true, - }); - + this.logger.error( + `Freebit API failed after successful payment for subscription ${subscriptionId}`, + { + error: getErrorMessage(freebititError), + userId, + subscriptionId, + account, + quotaMb: request.quotaMb, + invoiceId: invoice.id, + transactionId: paymentResult.transactionId, + paymentCaptured: true, + } + ); + // Add a note to the invoice about the Freebit failure try { await this.whmcsService.updateInvoice({ invoiceId: invoice.id, - notes: `Payment successful but SIM top-up failed: ${getErrorMessage(freebititError)}. Manual intervention required.` + notes: `Payment successful but SIM top-up failed: ${getErrorMessage(freebititError)}. Manual intervention required.`, }); - + this.logger.log(`Added failure note to invoice ${invoice.id}`, { invoiceId: invoice.id, - reason: 'Freebit API failure after payment' + reason: "Freebit API failure after payment", }); } catch (updateError) { this.logger.error(`Failed to update invoice ${invoice.id} with failure note`, { invoiceId: invoice.id, updateError: getErrorMessage(updateError), - originalError: getErrorMessage(freebititError) + originalError: getErrorMessage(freebititError), }); } - + // TODO: Implement refund logic here // await this.whmcsService.addCredit({ // clientId: mapping.whmcsClientId, @@ -445,7 +505,7 @@ export class SimManagementService { // amount: costJpy, // type: 'refund' // }); - + throw new Error( `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.` ); @@ -465,24 +525,24 @@ export class SimManagementService { * Get SIM top-up history */ async getSimTopUpHistory( - userId: string, - subscriptionId: number, + userId: string, + subscriptionId: number, request: SimTopUpHistoryRequest ): Promise { try { const { account } = await this.validateSimSubscription(userId, subscriptionId); - + // Validate date format if (!/^\d{8}$/.test(request.fromDate) || !/^\d{8}$/.test(request.toDate)) { - throw new BadRequestException('Dates must be in YYYYMMDD format'); + throw new BadRequestException("Dates must be in YYYYMMDD format"); } const history = await this.freebititService.getSimTopUpHistory( - account, - request.fromDate, + account, + request.fromDate, request.toDate ); - + this.logger.log(`Retrieved SIM top-up history for subscription ${subscriptionId}`, { userId, subscriptionId, @@ -505,29 +565,29 @@ export class SimManagementService { * Change SIM plan */ async changeSimPlan( - userId: string, - subscriptionId: number, + userId: string, + subscriptionId: number, request: SimPlanChangeRequest ): Promise<{ ipv4?: string; ipv6?: string }> { try { const { account } = await this.validateSimSubscription(userId, subscriptionId); - + // Validate plan code format if (!request.newPlanCode || request.newPlanCode.length < 3) { - throw new BadRequestException('Invalid plan code'); + throw new BadRequestException("Invalid plan code"); } // Automatically set to 1st of next month const nextMonth = new Date(); nextMonth.setMonth(nextMonth.getMonth() + 1); nextMonth.setDate(1); // Set to 1st of the month - + // Format as YYYYMMDD for Freebit API const year = nextMonth.getFullYear(); - const month = String(nextMonth.getMonth() + 1).padStart(2, '0'); - const day = String(nextMonth.getDate()).padStart(2, '0'); + const month = String(nextMonth.getMonth() + 1).padStart(2, "0"); + const day = String(nextMonth.getDate()).padStart(2, "0"); const scheduledAt = `${year}${month}${day}`; - + this.logger.log(`Auto-scheduled plan change to 1st of next month: ${scheduledAt}`, { userId, subscriptionId, @@ -539,7 +599,7 @@ export class SimManagementService { assignGlobalIp: false, // Default to no global IP scheduledAt: scheduledAt, }); - + this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, { userId, subscriptionId, @@ -573,7 +633,7 @@ export class SimManagementService { const { account } = await this.validateSimSubscription(userId, subscriptionId); // Validate network type if provided - if (request.networkType && !['4G', '5G'].includes(request.networkType)) { + if (request.networkType && !["4G", "5G"].includes(request.networkType)) { throw new BadRequestException('networkType must be either "4G" or "5G"'); } @@ -599,17 +659,21 @@ export class SimManagementService { /** * Cancel SIM service */ - async cancelSim(userId: string, subscriptionId: number, request: SimCancelRequest = {}): Promise { + async cancelSim( + userId: string, + subscriptionId: number, + request: SimCancelRequest = {} + ): Promise { try { const { account } = await this.validateSimSubscription(userId, subscriptionId); - + // Validate scheduled date if provided if (request.scheduledAt && !/^\d{8}$/.test(request.scheduledAt)) { - throw new BadRequestException('Scheduled date must be in YYYYMMDD format'); + throw new BadRequestException("Scheduled date must be in YYYYMMDD format"); } await this.freebititService.cancelSim(account, request.scheduledAt); - + this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, { userId, subscriptionId, @@ -631,15 +695,15 @@ export class SimManagementService { async reissueEsimProfile(userId: string, subscriptionId: number): Promise { try { const { account } = await this.validateSimSubscription(userId, subscriptionId); - + // First check if this is actually an eSIM const simDetails = await this.freebititService.getSimDetails(account); - if (simDetails.simType !== 'esim') { - throw new BadRequestException('This operation is only available for eSIM subscriptions'); + if (simDetails.simType !== "esim") { + throw new BadRequestException("This operation is only available for eSIM subscriptions"); } await this.freebititService.reissueEsimProfile(account); - + this.logger.log(`Successfully reissued eSIM profile for subscription ${subscriptionId}`, { userId, subscriptionId, @@ -658,7 +722,10 @@ export class SimManagementService { /** * Get comprehensive SIM information (details + usage combined) */ - async getSimInfo(userId: string, subscriptionId: number): Promise<{ + async getSimInfo( + userId: string, + subscriptionId: number + ): Promise<{ details: SimDetails; usage: SimUsage; }> { @@ -671,9 +738,11 @@ export class SimManagementService { // If Freebit doesn't return remaining quota, derive it from plan code (e.g., PASI_50G) // by subtracting measured usage (today + recentDays) from the plan cap. const normalizeNumber = (n: number) => (isFinite(n) && n > 0 ? n : 0); - const usedMb = normalizeNumber(usage.todayUsageMb) + usage.recentDaysUsage.reduce((sum, d) => sum + normalizeNumber(d.usageMb), 0); + const usedMb = + normalizeNumber(usage.todayUsageMb) + + usage.recentDaysUsage.reduce((sum, d) => sum + normalizeNumber(d.usageMb), 0); - const planCapMatch = (details.planCode || '').match(/(\d+)\s*G/i); + const planCapMatch = (details.planCode || "").match(/(\d+)\s*G/i); if ((details.remainingQuotaMb === 0 || details.remainingQuotaMb == null) && planCapMatch) { const capGb = parseInt(planCapMatch[1], 10); if (!isNaN(capGb) && capGb > 0) { @@ -706,25 +775,25 @@ export class SimManagementService { const errorLower = technicalError.toLowerCase(); // Freebit API errors - if (errorLower.includes('api error: ng') || errorLower.includes('account not found')) { + if (errorLower.includes("api error: ng") || errorLower.includes("account not found")) { return "SIM account not found. Please contact support to verify your SIM configuration."; } - if (errorLower.includes('authentication failed') || errorLower.includes('auth')) { + if (errorLower.includes("authentication failed") || errorLower.includes("auth")) { return "SIM service is temporarily unavailable. Please try again later."; } - if (errorLower.includes('timeout') || errorLower.includes('network')) { + if (errorLower.includes("timeout") || errorLower.includes("network")) { return "SIM service request timed out. Please try again."; } // WHMCS errors - if (errorLower.includes('invalid permissions') || errorLower.includes('not allowed')) { + if (errorLower.includes("invalid permissions") || errorLower.includes("not allowed")) { return "SIM service is temporarily unavailable. Please contact support for assistance."; } // Generic errors - if (errorLower.includes('failed') || errorLower.includes('error')) { + if (errorLower.includes("failed") || errorLower.includes("error")) { return "SIM operation failed. Please try again or contact support."; } diff --git a/apps/bff/src/subscriptions/sim-usage-store.service.ts b/apps/bff/src/subscriptions/sim-usage-store.service.ts index af72a980..3628602f 100644 --- a/apps/bff/src/subscriptions/sim-usage-store.service.ts +++ b/apps/bff/src/subscriptions/sim-usage-store.service.ts @@ -6,14 +6,14 @@ import { Logger } from "nestjs-pino"; export class SimUsageStoreService { constructor( private readonly prisma: PrismaService, - @Inject(Logger) private readonly logger: Logger, + @Inject(Logger) private readonly logger: Logger ) {} private normalizeDate(date?: Date): Date { const d = date ? new Date(date) : new Date(); // strip time to YYYY-MM-DD - const iso = d.toISOString().split('T')[0]; - return new Date(iso + 'T00:00:00.000Z'); + const iso = d.toISOString().split("T")[0]; + return new Date(iso + "T00:00:00.000Z"); } async upsertToday(account: string, usageMb: number, date?: Date): Promise { @@ -29,21 +29,26 @@ export class SimUsageStoreService { } } - async getLastNDays(account: string, days = 30): Promise> { + async getLastNDays( + account: string, + days = 30 + ): Promise> { const end = this.normalizeDate(); const start = new Date(end); start.setUTCDate(end.getUTCDate() - (days - 1)); - const rows = await (this.prisma as any).simUsageDaily.findMany({ + const rows = (await (this.prisma as any).simUsageDaily.findMany({ where: { account, date: { gte: start, lte: end } }, - orderBy: { date: 'desc' }, - }) as Array<{ date: Date; usageMb: number }>; - return rows.map((r) => ({ date: r.date.toISOString().split('T')[0], usageMb: r.usageMb })); + orderBy: { date: "desc" }, + })) as Array<{ date: Date; usageMb: number }>; + return rows.map(r => ({ date: r.date.toISOString().split("T")[0], usageMb: r.usageMb })); } async cleanupPreviousMonths(): Promise { const now = new Date(); const firstOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); - const result = await (this.prisma as any).simUsageDaily.deleteMany({ where: { date: { lt: firstOfMonth } } }); + const result = await (this.prisma as any).simUsageDaily.deleteMany({ + where: { date: { lt: firstOfMonth } }, + }); return result.count; } } diff --git a/apps/bff/src/subscriptions/subscriptions.controller.ts b/apps/bff/src/subscriptions/subscriptions.controller.ts index 800da2c1..7a4de412 100644 --- a/apps/bff/src/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/subscriptions/subscriptions.controller.ts @@ -30,7 +30,7 @@ import type { RequestWithUser } from "../auth/auth.types"; export class SubscriptionsController { constructor( private readonly subscriptionsService: SubscriptionsService, - private readonly simManagementService: SimManagementService, + private readonly simManagementService: SimManagementService ) {} @Get() @@ -270,7 +270,7 @@ export class SubscriptionsController { if (!fromDate || !toDate) { throw new BadRequestException("fromDate and toDate are required"); } - + return this.simManagementService.getSimTopUpHistory(req.user.id, subscriptionId, { fromDate, toDate, @@ -297,7 +297,8 @@ export class SubscriptionsController { async topUpSim( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Body() body: { + @Body() + body: { quotaMb: number; } ) { @@ -308,7 +309,8 @@ export class SubscriptionsController { @Post(":id/sim/change-plan") @ApiOperation({ summary: "Change SIM plan", - description: "Change the SIM service plan. The change will be automatically scheduled for the 1st of the next month.", + description: + "Change the SIM service plan. The change will be automatically scheduled for the 1st of the next month.", }) @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) @ApiBody({ @@ -325,15 +327,16 @@ export class SubscriptionsController { async changeSimPlan( @Request() req: RequestWithUser, @Param("id", ParseIntPipe) subscriptionId: number, - @Body() body: { + @Body() + body: { newPlanCode: string; } ) { const result = await this.simManagementService.changeSimPlan(req.user.id, subscriptionId, body); - return { - success: true, + return { + success: true, message: "SIM plan change completed successfully", - ...result + ...result, }; } @@ -348,7 +351,11 @@ export class SubscriptionsController { schema: { type: "object", properties: { - scheduledAt: { type: "string", description: "Schedule cancellation (YYYYMMDD)", example: "20241231" }, + scheduledAt: { + type: "string", + description: "Schedule cancellation (YYYYMMDD)", + example: "20241231", + }, }, }, required: false, @@ -382,7 +389,8 @@ export class SubscriptionsController { @Post(":id/sim/features") @ApiOperation({ summary: "Update SIM features", - description: "Enable/disable voicemail, call waiting, international roaming, and switch network type (4G/5G)", + description: + "Enable/disable voicemail, call waiting, international roaming, and switch network type (4G/5G)", }) @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) @ApiBody({ @@ -406,7 +414,7 @@ export class SubscriptionsController { voiceMailEnabled?: boolean; callWaitingEnabled?: boolean; internationalRoamingEnabled?: boolean; - networkType?: '4G' | '5G'; + networkType?: "4G" | "5G"; } ) { await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body); diff --git a/apps/bff/src/vendors/freebit/freebit.module.ts b/apps/bff/src/vendors/freebit/freebit.module.ts index ad11d448..94aa4992 100644 --- a/apps/bff/src/vendors/freebit/freebit.module.ts +++ b/apps/bff/src/vendors/freebit/freebit.module.ts @@ -1,5 +1,5 @@ -import { Module } from '@nestjs/common'; -import { FreebititService } from './freebit.service'; +import { Module } from "@nestjs/common"; +import { FreebititService } from "./freebit.service"; @Module({ providers: [FreebititService], diff --git a/apps/bff/src/vendors/freebit/freebit.service.ts b/apps/bff/src/vendors/freebit/freebit.service.ts index 8e4c3745..854ad8db 100644 --- a/apps/bff/src/vendors/freebit/freebit.service.ts +++ b/apps/bff/src/vendors/freebit/freebit.service.ts @@ -1,6 +1,11 @@ -import { Injectable, Inject, BadRequestException, InternalServerErrorException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Logger } from 'nestjs-pino'; +import { + Injectable, + Inject, + BadRequestException, + InternalServerErrorException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Logger } from "nestjs-pino"; import { FreebititConfig, FreebititAuthRequest, @@ -26,8 +31,8 @@ import { SimTopUpHistory, FreebititError, FreebititAddSpecRequest, - FreebititAddSpecResponse -} from './interfaces/freebit.types'; + FreebititAddSpecResponse, +} from "./interfaces/freebit.types"; @Injectable() export class FreebititService { @@ -39,23 +44,25 @@ export class FreebititService { constructor( private readonly configService: ConfigService, - @Inject(Logger) private readonly logger: Logger, + @Inject(Logger) private readonly logger: Logger ) { this.config = { - 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, - retryAttempts: this.configService.get('FREEBIT_RETRY_ATTEMPTS') || 3, - detailsEndpoint: this.configService.get('FREEBIT_DETAILS_ENDPOINT') || '/master/getAcnt/', + 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, + retryAttempts: this.configService.get("FREEBIT_RETRY_ATTEMPTS") || 3, + detailsEndpoint: + this.configService.get("FREEBIT_DETAILS_ENDPOINT") || "/master/getAcnt/", }; // Warn if critical configuration is missing if (!this.config.oemKey) { - this.logger.warn('FREEBIT_OEM_KEY is not configured. SIM management features will not work.'); + this.logger.warn("FREEBIT_OEM_KEY is not configured. SIM management features will not work."); } - this.logger.debug('Freebit service initialized', { + this.logger.debug("Freebit service initialized", { baseUrl: this.config.baseUrl, oemId: this.config.oemId, hasOemKey: !!this.config.oemKey, @@ -65,19 +72,19 @@ export class FreebititService { /** * Map Freebit SIM status to portal status */ - private mapSimStatus(freebititStatus: string): 'active' | 'suspended' | 'cancelled' | 'pending' { + private mapSimStatus(freebititStatus: string): "active" | "suspended" | "cancelled" | "pending" { switch (freebititStatus) { - case 'active': - return 'active'; - case 'suspended': - return 'suspended'; - case 'temporary': - case 'waiting': - return 'pending'; - case 'obsolete': - return 'cancelled'; + case "active": + return "active"; + case "suspended": + return "suspended"; + case "temporary": + case "waiting": + return "pending"; + case "obsolete": + return "cancelled"; default: - return 'pending'; + return "pending"; } } @@ -93,7 +100,7 @@ export class FreebititService { try { // Check if configuration is available if (!this.config.oemKey) { - throw new Error('Freebit API not configured: FREEBIT_OEM_KEY is missing'); + throw new Error("Freebit API not configured: FREEBIT_OEM_KEY is missing"); } const request: FreebititAuthRequest = { @@ -102,9 +109,9 @@ export class FreebititService { }; const response = await fetch(`${this.config.baseUrl}/authOem/`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + "Content-Type": "application/x-www-form-urlencoded", }, body: `json=${JSON.stringify(request)}`, }); @@ -113,9 +120,9 @@ export class FreebititService { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - const data = await response.json() as FreebititAuthResponse; + const data = (await response.json()) as FreebititAuthResponse; - if (data.resultCode !== '100') { + if (data.resultCode !== "100") { throw new FreebititErrorImpl( `Authentication failed: ${data.status.message}`, data.resultCode, @@ -130,30 +137,27 @@ export class FreebititService { expiresAt: Date.now() + 50 * 60 * 1000, }; - this.logger.log('Successfully authenticated with Freebit API'); + this.logger.log("Successfully authenticated with Freebit API"); return data.authKey; } catch (error: any) { - this.logger.error('Failed to authenticate with Freebit API', { error: error.message }); - throw new InternalServerErrorException('Failed to authenticate with Freebit API'); + this.logger.error("Failed to authenticate with Freebit API", { error: error.message }); + throw new InternalServerErrorException("Failed to authenticate with Freebit API"); } } /** * Make authenticated API request with error handling */ - private async makeAuthenticatedRequest( - endpoint: string, - data: any - ): Promise { + private async makeAuthenticatedRequest(endpoint: string, data: any): Promise { const authKey = await this.getAuthKey(); const requestData = { ...data, authKey }; try { const url = `${this.config.baseUrl}${endpoint}`; const response = await fetch(url, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + "Content-Type": "application/x-www-form-urlencoded", }, body: `json=${JSON.stringify(requestData)}`, }); @@ -164,7 +168,7 @@ export class FreebititService { const text = await response.text(); bodySnippet = text ? text.slice(0, 500) : undefined; } catch {} - this.logger.error('Freebit API non-OK response', { + this.logger.error("Freebit API non-OK response", { endpoint, url, status: response.status, @@ -174,31 +178,31 @@ export class FreebititService { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - const responseData = await response.json() as T; - + const responseData = (await response.json()) as T; + // Check for API-level errors - if (responseData && (responseData as any).resultCode !== '100') { + if (responseData && (responseData as any).resultCode !== "100") { const errorData = responseData as any; - const errorMessage = errorData.status?.message || 'Unknown error'; - + const errorMessage = errorData.status?.message || "Unknown error"; + // Provide more specific error messages for common cases let userFriendlyMessage = `API Error: ${errorMessage}`; - if (errorMessage === 'NG') { + if (errorMessage === "NG") { userFriendlyMessage = `Account not found or invalid in Freebit system. Please verify the account number exists and is properly configured.`; - } else if (errorMessage.includes('auth') || errorMessage.includes('Auth')) { + } else if (errorMessage.includes("auth") || errorMessage.includes("Auth")) { userFriendlyMessage = `Authentication failed with Freebit API. Please check API credentials.`; - } else if (errorMessage.includes('timeout') || errorMessage.includes('Timeout')) { + } else if (errorMessage.includes("timeout") || errorMessage.includes("Timeout")) { userFriendlyMessage = `Request timeout to Freebit API. Please try again later.`; } - - this.logger.error('Freebit API error response', { + + this.logger.error("Freebit API error response", { endpoint, resultCode: errorData.resultCode, statusCode: errorData.status?.statusCode, message: errorMessage, - userFriendlyMessage + userFriendlyMessage, }); - + throw new FreebititErrorImpl( userFriendlyMessage, errorData.resultCode, @@ -207,7 +211,7 @@ export class FreebititService { ); } - this.logger.debug('Freebit API Request Success', { + this.logger.debug("Freebit API Request Success", { endpoint, resultCode: (responseData as any).resultCode, }); @@ -217,9 +221,13 @@ export class FreebititService { if (error instanceof FreebititErrorImpl) { throw error; } - - this.logger.error(`Freebit API request failed: ${endpoint}`, { error: (error as any).message }); - throw new InternalServerErrorException(`Freebit API request failed: ${(error as any).message}`); + + this.logger.error(`Freebit API request failed: ${endpoint}`, { + error: (error as any).message, + }); + throw new InternalServerErrorException( + `Freebit API request failed: ${(error as any).message}` + ); } } @@ -228,30 +236,32 @@ export class FreebititService { */ async getSimDetails(account: string): Promise { try { - const request: Omit = { - version: '2', - requestDatas: [{ kind: 'MVNO', account }], + const request: Omit = { + version: "2", + requestDatas: [{ kind: "MVNO", account }], }; - - const configured = this.config.detailsEndpoint || '/master/getAcnt/'; - const candidates = Array.from(new Set([ - configured, - configured.replace(/\/$/, ''), - '/master/getAcnt/', - '/master/getAcnt', - '/mvno/getAccountDetail/', - '/mvno/getAccountDetail', - '/mvno/getAcntDetail/', - '/mvno/getAcntDetail', - '/mvno/getAccountInfo/', - '/mvno/getAccountInfo', - '/mvno/getSubscriberInfo/', - '/mvno/getSubscriberInfo', - '/mvno/getInfo/', - '/mvno/getInfo', - '/master/getDetail/', - '/master/getDetail', - ])); + + const configured = this.config.detailsEndpoint || "/master/getAcnt/"; + const candidates = Array.from( + new Set([ + configured, + configured.replace(/\/$/, ""), + "/master/getAcnt/", + "/master/getAcnt", + "/mvno/getAccountDetail/", + "/mvno/getAccountDetail", + "/mvno/getAcntDetail/", + "/mvno/getAcntDetail", + "/mvno/getAccountInfo/", + "/mvno/getAccountInfo", + "/mvno/getSubscriberInfo/", + "/mvno/getSubscriberInfo", + "/mvno/getInfo/", + "/mvno/getInfo", + "/master/getDetail/", + "/master/getDetail", + ]) + ); let response: FreebititAccountDetailsResponse | undefined; let lastError: any; @@ -260,11 +270,14 @@ export class FreebititService { if (ep !== candidates[0]) { this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`); } - response = await this.makeAuthenticatedRequest(ep, request); + response = await this.makeAuthenticatedRequest( + ep, + request + ); break; // success } catch (err: any) { lastError = err; - if (typeof err?.message === 'string' && err.message.includes('HTTP 404')) { + if (typeof err?.message === "string" && err.message.includes("HTTP 404")) { // try next candidate continue; } @@ -274,22 +287,27 @@ export class FreebititService { } if (!response) { - throw lastError || new InternalServerErrorException('Failed to fetch SIM details: all endpoints failed'); + throw ( + lastError || + new InternalServerErrorException("Failed to fetch SIM details: all endpoints failed") + ); } const datas = (response as any).responseDatas; - const list = Array.isArray(datas) ? datas : (datas ? [datas] : []); + const list = Array.isArray(datas) ? datas : datas ? [datas] : []; if (!list.length) { - throw new BadRequestException('No SIM details found for this account'); + throw new BadRequestException("No SIM details found for this account"); } // Prefer the MVNO entry if present - const mvno = list.find((d: any) => (d.kind || '').toString().toUpperCase() === 'MVNO') || list[0]; - const simData = mvno as any; - + const mvno = + list.find((d: any) => (d.kind || "").toString().toUpperCase() === "MVNO") || list[0]; + const simData = mvno; + const startDateRaw = simData.startDate ? String(simData.startDate) : undefined; - const startDate = startDateRaw && /^\d{8}$/.test(startDateRaw) - ? `${startDateRaw.slice(0,4)}-${startDateRaw.slice(4,6)}-${startDateRaw.slice(6,8)}` - : startDateRaw; + const startDate = + startDateRaw && /^\d{8}$/.test(startDateRaw) + ? `${startDateRaw.slice(0, 4)}-${startDateRaw.slice(4, 6)}-${startDateRaw.slice(6, 8)}` + : startDateRaw; const simDetails: SimDetails = { account: String(simData.account ?? account), @@ -298,13 +316,14 @@ export class FreebititService { imsi: simData.imsi ? String(simData.imsi) : undefined, eid: simData.eid, planCode: simData.planCode, - status: this.mapSimStatus(String(simData.state || 'pending')), - simType: simData.eid ? 'esim' : 'physical', + status: this.mapSimStatus(String(simData.state || "pending")), + simType: simData.eid ? "esim" : "physical", size: simData.size, 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 / 1000) * 100) / 100 : 0, + remainingQuotaKb: typeof simData.quota === "number" ? simData.quota : 0, + remainingQuotaMb: + typeof simData.quota === "number" ? Math.round((simData.quota / 1000) * 100) / 100 : 0, startDate, ipv4: simData.ipv4, ipv6: simData.ipv6, @@ -312,10 +331,14 @@ export class FreebititService { callWaitingEnabled: simData.callwaiting === 10 || simData.callWaiting === 10, internationalRoamingEnabled: simData.worldwing === 10 || simData.worldWing === 10, networkType: simData.contractLine || undefined, - pendingOperations: simData.async ? [{ - operation: simData.async.func, - scheduledDate: String(simData.async.date), - }] : undefined, + pendingOperations: simData.async + ? [ + { + operation: simData.async.func, + scheduledDate: String(simData.async.date), + }, + ] + : undefined, }; this.logger.log(`Retrieved SIM details for account ${account}`, { @@ -326,7 +349,9 @@ export class FreebititService { return simDetails; } catch (error: any) { - this.logger.error(`Failed to get SIM details for account ${account}`, { error: error.message }); + this.logger.error(`Failed to get SIM details for account ${account}`, { + error: error.message, + }); throw error; } } @@ -336,26 +361,26 @@ export class FreebititService { */ async getSimUsage(account: string): Promise { try { - const request: Omit = { account }; - + const request: Omit = { account }; + const response = await this.makeAuthenticatedRequest( - '/mvno/getTrafficInfo/', + "/mvno/getTrafficInfo/", request ); const todayUsageKb = parseInt(response.traffic.today, 10) || 0; - const recentDaysData = response.traffic.inRecentDays.split(',').map((usage, index) => ({ - date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + 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) / 1000 * 100) / 100, + usageMb: Math.round((parseInt(usage, 10) / 1000) * 100) / 100, })); const simUsage: SimUsage = { account, todayUsageKb, - todayUsageMb: Math.round(todayUsageKb / 1000 * 100) / 100, + todayUsageMb: Math.round((todayUsageKb / 1000) * 100) / 100, recentDaysUsage: recentDaysData, - isBlacklisted: response.traffic.blackList === '10', + isBlacklisted: response.traffic.blackList === "10", }; this.logger.log(`Retrieved SIM usage for account ${account}`, { @@ -374,11 +399,15 @@ export class FreebititService { /** * Top up SIM data quota */ - async topUpSim(account: string, quotaMb: number, options: { - campaignCode?: string; - expiryDate?: string; - scheduledAt?: string; - } = {}): Promise { + async topUpSim( + account: string, + quotaMb: number, + options: { + campaignCode?: string; + expiryDate?: string; + scheduledAt?: string; + } = {} + ): Promise { try { // Units per endpoint: // - Immediate (PA04-04 /master/addSpec/): quota in MB (string), requires kind: 'MVNO' @@ -388,9 +417,9 @@ export class FreebititService { const quotaKbStr = String(Math.round(quotaKb)); const isScheduled = !!options.scheduledAt; - const endpoint = isScheduled ? '/mvno/eachQuota/' : '/master/addSpec/'; + const endpoint = isScheduled ? "/mvno/eachQuota/" : "/master/addSpec/"; - let request: Omit; + let request: Omit; if (isScheduled) { // PA05-22: KB + runTime request = { @@ -404,7 +433,7 @@ export class FreebititService { // PA04-04: MB + kind request = { account, - kind: 'MVNO', + kind: "MVNO", quota: quotaMbStr, quotaCode: options.campaignCode, expire: options.expiryDate, @@ -418,12 +447,12 @@ export class FreebititService { endpoint, quotaMb, quotaKb, - units: isScheduled ? 'KB (PA05-22)' : 'MB (PA04-04)', + units: isScheduled ? "KB (PA05-22)" : "MB (PA04-04)", campaignCode: options.campaignCode, scheduled: isScheduled, }); } catch (error: any) { - this.logger.error(`Failed to top up SIM ${account}`, { + this.logger.error(`Failed to top up SIM ${account}`, { error: error.message, account, quotaMb, @@ -435,16 +464,20 @@ export class FreebititService { /** * Get SIM top-up history */ - async getSimTopUpHistory(account: string, fromDate: string, toDate: string): Promise { + async getSimTopUpHistory( + account: string, + fromDate: string, + toDate: string + ): Promise { try { - const request: Omit = { + const request: Omit = { account, fromDate, toDate, }; const response = await this.makeAuthenticatedRequest( - '/mvno/getQuotaHistory/', + "/mvno/getQuotaHistory/", request ); @@ -454,7 +487,7 @@ export class FreebititService { additionCount: response.count, history: response.quotaHistory.map(item => ({ quotaKb: parseInt(item.quota, 10), - quotaMb: Math.round(parseInt(item.quota, 10) / 1000 * 100) / 100, + quotaMb: Math.round((parseInt(item.quota, 10) / 1000) * 100) / 100, addedDate: item.date, expiryDate: item.expire, campaignCode: item.quotaCode, @@ -469,7 +502,9 @@ export class FreebititService { return history; } catch (error: any) { - this.logger.error(`Failed to get SIM top-up history for account ${account}`, { error: error.message }); + this.logger.error(`Failed to get SIM top-up history for account ${account}`, { + error: error.message, + }); throw error; } } @@ -477,20 +512,24 @@ export class FreebititService { /** * Change SIM plan */ - async changeSimPlan(account: string, newPlanCode: string, options: { - assignGlobalIp?: boolean; - scheduledAt?: string; - } = {}): Promise<{ ipv4?: string; ipv6?: string }> { + async changeSimPlan( + account: string, + newPlanCode: string, + options: { + assignGlobalIp?: boolean; + scheduledAt?: string; + } = {} + ): Promise<{ ipv4?: string; ipv6?: string }> { try { - const request: Omit = { + const request: Omit = { account, planCode: newPlanCode, - globalip: options.assignGlobalIp ? '1' : '0', + globalip: options.assignGlobalIp ? "1" : "0", runTime: options.scheduledAt, }; const response = await this.makeAuthenticatedRequest( - '/mvno/changePlan/', + "/mvno/changePlan/", request ); @@ -506,7 +545,7 @@ export class FreebititService { ipv6: response.ipv6, }; } catch (error: any) { - this.logger.error(`Failed to change SIM plan for account ${account}`, { + this.logger.error(`Failed to change SIM plan for account ${account}`, { error: error.message, account, newPlanCode, @@ -519,35 +558,40 @@ export class FreebititService { * Update SIM optional features (voicemail, call waiting, international roaming, network type) * Uses AddSpec endpoint for immediate changes */ - async updateSimFeatures(account: string, features: { - voiceMailEnabled?: boolean; - callWaitingEnabled?: boolean; - internationalRoamingEnabled?: boolean; - networkType?: string; // '4G' | '5G' - }): Promise { + async updateSimFeatures( + account: string, + features: { + voiceMailEnabled?: boolean; + callWaitingEnabled?: boolean; + internationalRoamingEnabled?: boolean; + networkType?: string; // '4G' | '5G' + } + ): Promise { try { - const request: Omit = { + const request: Omit = { account, - kind: 'MVNO', + kind: "MVNO", }; - if (typeof features.voiceMailEnabled === 'boolean') { - request.voiceMail = features.voiceMailEnabled ? '10' as const : '20' as const; + if (typeof features.voiceMailEnabled === "boolean") { + request.voiceMail = features.voiceMailEnabled ? ("10" as const) : ("20" as const); request.voicemail = request.voiceMail; // include alternate casing for compatibility } - if (typeof features.callWaitingEnabled === 'boolean') { - request.callWaiting = features.callWaitingEnabled ? '10' as const : '20' as const; + if (typeof features.callWaitingEnabled === "boolean") { + request.callWaiting = features.callWaitingEnabled ? ("10" as const) : ("20" as const); request.callwaiting = request.callWaiting; } - if (typeof features.internationalRoamingEnabled === 'boolean') { - request.worldWing = features.internationalRoamingEnabled ? '10' as const : '20' as const; + if (typeof features.internationalRoamingEnabled === "boolean") { + request.worldWing = features.internationalRoamingEnabled + ? ("10" as const) + : ("20" as const); request.worldwing = request.worldWing; } if (features.networkType) { request.contractLine = features.networkType; } - await this.makeAuthenticatedRequest('/master/addSpec/', request); + await this.makeAuthenticatedRequest("/master/addSpec/", request); this.logger.log(`Updated SIM features for account ${account}`, { account, @@ -570,13 +614,13 @@ export class FreebititService { */ async cancelSim(account: string, scheduledAt?: string): Promise { try { - const request: Omit = { + const request: Omit = { account, runTime: scheduledAt, }; await this.makeAuthenticatedRequest( - '/mvno/releasePlan/', + "/mvno/releasePlan/", request ); @@ -585,7 +629,7 @@ export class FreebititService { scheduled: !!scheduledAt, }); } catch (error: any) { - this.logger.error(`Failed to cancel SIM for account ${account}`, { + this.logger.error(`Failed to cancel SIM for account ${account}`, { error: error.message, account, }); @@ -603,62 +647,71 @@ export class FreebititService { // Fetch details to get current EID and plan/network where available const details = await this.getSimDetails(account); - if (details.simType !== 'esim') { - throw new BadRequestException('This operation is only available for eSIM subscriptions'); + if (details.simType !== "esim") { + throw new BadRequestException("This operation is only available for eSIM subscriptions"); } if (!details.eid) { - throw new BadRequestException('eSIM EID not found for this account'); + throw new BadRequestException("eSIM EID not found for this account"); } - const payload: import('./interfaces/freebit.types').FreebititEsimAccountActivationRequest = { + const payload: import("./interfaces/freebit.types").FreebititEsimAccountActivationRequest = { authKey, - aladinOperated: '20', - createType: 'reissue', + aladinOperated: "20", + createType: "reissue", eid: details.eid, // existing EID used for reissue account, - simkind: 'esim', - addKind: 'R', + simkind: "esim", + addKind: "R", // Optional enrichments omitted to minimize validation mismatches }; const url = `${this.config.baseUrl}/mvno/esim/addAcct/`; const response = await fetch(url, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json; charset=utf-8', + "Content-Type": "application/json; charset=utf-8", }, body: JSON.stringify(payload), }); if (!response.ok) { - const text = await response.text().catch(() => ''); - this.logger.error('Freebit PA05-41 HTTP error', { url, status: response.status, statusText: response.statusText, body: text?.slice(0, 500) }); + const text = await response.text().catch(() => ""); + this.logger.error("Freebit PA05-41 HTTP error", { + url, + status: response.status, + statusText: response.statusText, + body: text?.slice(0, 500), + }); throw new InternalServerErrorException(`HTTP ${response.status}: ${response.statusText}`); } - const data = await response.json() as import('./interfaces/freebit.types').FreebititEsimAccountActivationResponse; - const rc = typeof data.resultCode === 'number' ? String(data.resultCode) : (data.resultCode || ''); - if (rc !== '100') { - const message = data.message || 'Unknown error'; - this.logger.error('Freebit PA05-41 API error response', { - endpoint: '/mvno/esim/addAcct/', + const data = + (await response.json()) as import("./interfaces/freebit.types").FreebititEsimAccountActivationResponse; + const rc = + typeof data.resultCode === "number" ? String(data.resultCode) : data.resultCode || ""; + if (rc !== "100") { + const message = data.message || "Unknown error"; + this.logger.error("Freebit PA05-41 API error response", { + endpoint: "/mvno/esim/addAcct/", resultCode: data.resultCode, statusCode: data.statusCode, message, }); throw new FreebititErrorImpl( `API Error: ${message}`, - rc || '0', - data.statusCode || '0', + rc || "0", + data.statusCode || "0", message ); } - this.logger.log(`Successfully reissued eSIM profile via PA05-41 for account ${account}`, { account }); + this.logger.log(`Successfully reissued eSIM profile via PA05-41 for account ${account}`, { + account, + }); } catch (error: any) { if (error instanceof BadRequestException) throw error; - this.logger.error(`Failed to reissue eSIM profile via PA05-41 for account ${account}`, { + this.logger.error(`Failed to reissue eSIM profile via PA05-41 for account ${account}`, { error: error.message, account, }); @@ -670,7 +723,7 @@ export class FreebititService { * Reissue eSIM profile using addAcnt endpoint (enhanced method based on Salesforce implementation) */ async reissueEsimProfileEnhanced( - account: string, + account: string, newEid: string, options: { oldProductNumber?: string; @@ -679,11 +732,11 @@ export class FreebititService { } = {} ): Promise { try { - const request: Omit = { - aladinOperated: '20', + const request: Omit = { + aladinOperated: "20", account, eid: newEid, - addKind: 'R', // R = reissue + addKind: "R", // R = reissue reissue: { oldProductNumber: options.oldProductNumber, oldEid: options.oldEid, @@ -696,18 +749,18 @@ export class FreebititService { } await this.makeAuthenticatedRequest( - '/mvno/esim/addAcnt/', + "/mvno/esim/addAcnt/", request ); - this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, { + this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, { account, newEid, oldProductNumber: options.oldProductNumber, oldEid: options.oldEid, }); } catch (error: any) { - this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, { + this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, { error: error.message, account, newEid, @@ -724,7 +777,7 @@ export class FreebititService { await this.getAuthKey(); return true; } catch (error: any) { - this.logger.error('Freebit API health check failed', { error: error.message }); + this.logger.error("Freebit API health check failed", { error: error.message }); return false; } } @@ -736,14 +789,9 @@ class FreebititErrorImpl extends Error { public readonly statusCode: string; public readonly freebititMessage: string; - constructor( - message: string, - resultCode: string, - statusCode: string, - freebititMessage: string - ) { + constructor(message: string, resultCode: string, statusCode: string, freebititMessage: string) { super(message); - this.name = 'FreebititError'; + this.name = "FreebititError"; this.resultCode = resultCode; this.statusCode = statusCode; this.freebititMessage = freebititMessage; diff --git a/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts b/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts index 01958b21..29cf9a4d 100644 --- a/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts +++ b/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts @@ -1,8 +1,8 @@ // Freebit API Type Definitions export interface FreebititAuthRequest { - oemId: string; // 4-char alphanumeric ISP identifier - oemKey: string; // 32-char auth key + oemId: string; // 4-char alphanumeric ISP identifier + oemKey: string; // 32-char auth key } export interface FreebititAuthResponse { @@ -11,14 +11,14 @@ export interface FreebititAuthResponse { message: string; statusCode: string; }; - authKey: string; // Token for subsequent API calls + authKey: string; // Token for subsequent API calls } export interface FreebititAccountDetailsRequest { authKey: string; version?: string | number; // Docs recommend "2" requestDatas: Array<{ - kind: 'MASTER' | 'MVNO' | string; + kind: "MASTER" | "MVNO" | string; account?: string | number; }>; } @@ -33,9 +33,9 @@ export interface FreebititAccountDetailsResponse { // Docs show this can be an array (MASTER + MVNO) or a single object for MVNO responseDatas: | { - kind: 'MASTER' | 'MVNO' | string; + kind: "MASTER" | "MVNO" | string; account: string | number; - state: 'active' | 'suspended' | 'temporary' | 'waiting' | 'obsolete' | string; + state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | string; startDate?: string | number; relationCode?: string; resultCode?: string | number; @@ -44,21 +44,21 @@ export interface FreebititAccountDetailsResponse { imsi?: string | number; eid?: string; contractLine?: string; - size?: 'standard' | 'nano' | 'micro' | 'esim' | string; + size?: "standard" | "nano" | "micro" | "esim" | string; sms?: number; // 10=active, 20=inactive talk?: number; // 10=active, 20=inactive ipv4?: string; ipv6?: string; quota?: number; // Remaining quota (units vary by env) async?: { - func: 'regist' | 'stop' | 'resume' | 'cancel' | 'pinset' | 'pinunset' | string; + func: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string; date: string | number; }; } | Array<{ - kind: 'MASTER' | 'MVNO' | string; + kind: "MASTER" | "MVNO" | string; account: string | number; - state: 'active' | 'suspended' | 'temporary' | 'waiting' | 'obsolete' | string; + state: "active" | "suspended" | "temporary" | "waiting" | "obsolete" | string; startDate?: string | number; relationCode?: string; resultCode?: string | number; @@ -67,17 +67,17 @@ export interface FreebititAccountDetailsResponse { imsi?: string | number; eid?: string; contractLine?: string; - size?: 'standard' | 'nano' | 'micro' | 'esim' | string; + size?: "standard" | "nano" | "micro" | "esim" | string; sms?: number; talk?: number; ipv4?: string; ipv6?: string; quota?: number; async?: { - func: 'regist' | 'stop' | 'resume' | 'cancel' | 'pinset' | 'pinunset' | string; + func: "regist" | "stop" | "resume" | "cancel" | "pinset" | "pinunset" | string; date: string | number; }; - }> + }>; } export interface FreebititTrafficInfoRequest { @@ -93,9 +93,9 @@ export interface FreebititTrafficInfoResponse { }; account: string; traffic: { - today: string; // Today's usage in KB + today: string; // Today's usage in KB inRecentDays: string; // Comma-separated recent days usage - blackList: string; // 10=blacklisted, 20=not blacklisted + blackList: string; // 10=blacklisted, 20=not blacklisted }; } @@ -106,12 +106,12 @@ export interface FreebititTopUpRequest { // - PA04-04 (/master/addSpec/): MB units (string recommended by spec) // - PA05-22 (/mvno/eachQuota/): KB units (string recommended by spec) quota: number | string; - quotaCode?: string; // Campaign code - expire?: string; // YYYYMMDD format + quotaCode?: string; // Campaign code + expire?: string; // YYYYMMDD format // For PA04-04 addSpec - kind?: string; // e.g. 'MVNO' (required by /master/addSpec/) + kind?: string; // e.g. 'MVNO' (required by /master/addSpec/) // For PA05-22 eachQuota - runTime?: string; // YYYYMMDD or YYYYMMDDhhmmss + runTime?: string; // YYYYMMDD or YYYYMMDDhhmmss } export interface FreebititTopUpResponse { @@ -128,12 +128,12 @@ export interface FreebititAddSpecRequest { account: string; kind?: string; // Required by PA04-04 (/master/addSpec/), e.g. 'MVNO' // Feature flags: 10 = enabled, 20 = disabled - voiceMail?: '10' | '20'; - voicemail?: '10' | '20'; - callWaiting?: '10' | '20'; - callwaiting?: '10' | '20'; - worldWing?: '10' | '20'; - worldwing?: '10' | '20'; + voiceMail?: "10" | "20"; + voicemail?: "10" | "20"; + callWaiting?: "10" | "20"; + callwaiting?: "10" | "20"; + worldWing?: "10" | "20"; + worldwing?: "10" | "20"; contractLine?: string; // '4G' or '5G' } @@ -148,8 +148,8 @@ export interface FreebititAddSpecResponse { export interface FreebititQuotaHistoryRequest { authKey: string; account: string; - fromDate: string; // YYYYMMDD - toDate: string; // YYYYMMDD + fromDate: string; // YYYYMMDD + toDate: string; // YYYYMMDD } export interface FreebititQuotaHistoryResponse { @@ -173,8 +173,8 @@ export interface FreebititPlanChangeRequest { authKey: string; account: string; planCode: string; - globalip?: '0' | '1'; // 0=no IP, 1=assign global IP - runTime?: string; // YYYYMMDD - optional, immediate if omitted + globalip?: "0" | "1"; // 0=no IP, 1=assign global IP + runTime?: string; // YYYYMMDD - optional, immediate if omitted } export interface FreebititPlanChangeResponse { @@ -190,7 +190,7 @@ export interface FreebititPlanChangeResponse { export interface FreebititCancelPlanRequest { authKey: string; account: string; - runTime?: string; // YYYYMMDD - optional, immediate if omitted + runTime?: string; // YYYYMMDD - optional, immediate if omitted } export interface FreebititCancelPlanResponse { @@ -219,7 +219,7 @@ export interface FreebititEsimAddAccountRequest { aladinOperated?: string; account: string; eid: string; - addKind: 'N' | 'R'; // N = new, R = reissue + addKind: "N" | "R"; // N = new, R = reissue createType?: string; simKind?: string; planCode?: string; @@ -244,13 +244,13 @@ export interface FreebititEsimAccountActivationRequest { aladinOperated: string; // '10' issue, '20' no-issue masterAccount?: string; masterPassword?: string; - createType: 'new' | 'reissue' | 'exchange' | string; + createType: "new" | "reissue" | "exchange" | string; eid?: string; // required for reissue/exchange per business rules account: string; // MSISDN - simkind: 'esim' | string; + simkind: "esim" | string; repAccount?: string; size?: string; - addKind?: 'N' | 'R' | string; // e.g., 'R' for reissue + addKind?: "N" | "R" | string; // e.g., 'R' for reissue oldEid?: string; oldProductNumber?: string; mnp?: { @@ -285,9 +285,9 @@ export interface SimDetails { imsi?: string; eid?: string; planCode: string; - status: 'active' | 'suspended' | 'cancelled' | 'pending'; - simType: 'physical' | 'esim'; - size: 'standard' | 'nano' | 'micro' | 'esim'; + status: "active" | "suspended" | "cancelled" | "pending"; + simType: "physical" | "esim"; + size: "standard" | "nano" | "micro" | "esim"; hasVoice: boolean; hasSms: boolean; remainingQuotaKb: number; diff --git a/apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts b/apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts index f2ad1450..1021a083 100644 --- a/apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts +++ b/apps/bff/src/vendors/salesforce/events/pubsub.subscriber.ts @@ -128,7 +128,9 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy const event = payloadData as Record; const payload = ((): Record | undefined => { const p = event?.["payload"]; - return typeof p === "object" && p != null ? (p as Record) : undefined; + return typeof p === "object" && p != null + ? (p as Record) + : undefined; })(); // Only check parsed payload diff --git a/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts b/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts index 2bb11fca..212012fa 100644 --- a/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts +++ b/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts @@ -449,7 +449,9 @@ export class WhmcsConnectionService { /** * Add a manual payment to an invoice */ - async addInvoicePayment(params: WhmcsAddInvoicePaymentParams): Promise { + async addInvoicePayment( + params: WhmcsAddInvoicePaymentParams + ): Promise { return this.makeRequest("AddInvoicePayment", params); } } diff --git a/apps/bff/src/vendors/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/vendors/whmcs/services/whmcs-invoice.service.ts index 373b883f..0d80acd7 100644 --- a/apps/bff/src/vendors/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/vendors/whmcs/services/whmcs-invoice.service.ts @@ -5,14 +5,14 @@ import { Invoice, InvoiceList } from "@customer-portal/shared"; import { WhmcsConnectionService } from "./whmcs-connection.service"; import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer"; import { WhmcsCacheService } from "../cache/whmcs-cache.service"; -import { - WhmcsGetInvoicesParams, - WhmcsCreateInvoiceParams, +import { + WhmcsGetInvoicesParams, + WhmcsCreateInvoiceParams, WhmcsCreateInvoiceResponse, WhmcsUpdateInvoiceParams, WhmcsUpdateInvoiceResponse, WhmcsCapturePaymentParams, - WhmcsCapturePaymentResponse + WhmcsCapturePaymentResponse, } from "../types/whmcs-api.types"; export interface InvoiceFilters { @@ -250,9 +250,9 @@ export class WhmcsInvoiceService { notes?: string; }): Promise<{ id: number; number: string; total: number; status: string }> { try { - const dueDateStr = params.dueDate - ? params.dueDate.toISOString().split('T')[0] - : new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; // 7 days from now + const dueDateStr = params.dueDate + ? params.dueDate.toISOString().split("T")[0] + : new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; // 7 days from now const whmcsParams: WhmcsCreateInvoiceParams = { userid: params.clientId, @@ -297,7 +297,14 @@ export class WhmcsInvoiceService { */ async updateInvoice(params: { invoiceId: number; - status?: "Draft" | "Unpaid" | "Paid" | "Cancelled" | "Refunded" | "Collections" | "Payment Pending"; + status?: + | "Draft" + | "Unpaid" + | "Paid" + | "Cancelled" + | "Refunded" + | "Collections" + | "Payment Pending"; dueDate?: Date; notes?: string; }): Promise<{ success: boolean; message?: string }> { @@ -305,7 +312,7 @@ export class WhmcsInvoiceService { const whmcsParams: WhmcsUpdateInvoiceParams = { invoiceid: params.invoiceId, status: params.status, - duedate: params.dueDate ? params.dueDate.toISOString().split('T')[0] : undefined, + duedate: params.dueDate ? params.dueDate.toISOString().split("T")[0] : undefined, notes: params.notes, }; @@ -370,8 +377,10 @@ export class WhmcsInvoiceService { }); // Return user-friendly error message instead of technical API error - const userFriendlyError = this.getUserFriendlyPaymentError(response.message || response.error || 'Unknown payment error'); - + const userFriendlyError = this.getUserFriendlyPaymentError( + response.message || response.error || "Unknown payment error" + ); + return { success: false, error: userFriendlyError, @@ -385,7 +394,7 @@ export class WhmcsInvoiceService { // Return user-friendly error message for exceptions const userFriendlyError = this.getUserFriendlyPaymentError(getErrorMessage(error)); - + return { success: false, error: userFriendlyError, @@ -404,27 +413,39 @@ export class WhmcsInvoiceService { const errorLower = technicalError.toLowerCase(); // WHMCS API permission errors - if (errorLower.includes('invalid permissions') || errorLower.includes('not allowed')) { + if (errorLower.includes("invalid permissions") || errorLower.includes("not allowed")) { return "Payment processing is temporarily unavailable. Please contact support for assistance."; } // Authentication/authorization errors - if (errorLower.includes('unauthorized') || errorLower.includes('forbidden') || errorLower.includes('403')) { + if ( + errorLower.includes("unauthorized") || + errorLower.includes("forbidden") || + errorLower.includes("403") + ) { return "Payment processing is temporarily unavailable. Please contact support for assistance."; } // Network/timeout errors - if (errorLower.includes('timeout') || errorLower.includes('network') || errorLower.includes('connection')) { + if ( + errorLower.includes("timeout") || + errorLower.includes("network") || + errorLower.includes("connection") + ) { return "Payment processing timed out. Please try again."; } // Payment method errors - if (errorLower.includes('payment method') || errorLower.includes('card') || errorLower.includes('insufficient funds')) { + if ( + errorLower.includes("payment method") || + errorLower.includes("card") || + errorLower.includes("insufficient funds") + ) { return "Unable to process payment with your current payment method. Please check your payment details or try a different method."; } // Generic API errors - if (errorLower.includes('api') || errorLower.includes('http') || errorLower.includes('error')) { + if (errorLower.includes("api") || errorLower.includes("http") || errorLower.includes("error")) { return "Payment processing failed. Please try again or contact support if the issue persists."; } diff --git a/apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts b/apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts index 610b1e80..a8479cff 100644 --- a/apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts +++ b/apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts @@ -96,7 +96,9 @@ export class WhmcsDataTransformer { // - Product names often contain "Activation Fee" or "Setup" const nameLower = (whmcsProduct.name || whmcsProduct.productname || "").toLowerCase(); const looksLikeActivation = - nameLower.includes("activation fee") || nameLower.includes("activation") || nameLower.includes("setup"); + nameLower.includes("activation fee") || + nameLower.includes("activation") || + nameLower.includes("setup"); if ((recurringAmount === 0 && firstPaymentAmount > 0) || looksLikeActivation) { normalizedCycle = "One-time"; diff --git a/apps/bff/src/vendors/whmcs/types/whmcs-api.types.ts b/apps/bff/src/vendors/whmcs/types/whmcs-api.types.ts index f52a0ef2..bdfd19b3 100644 --- a/apps/bff/src/vendors/whmcs/types/whmcs-api.types.ts +++ b/apps/bff/src/vendors/whmcs/types/whmcs-api.types.ts @@ -362,7 +362,14 @@ export interface WhmcsPaymentGatewaysResponse { // CreateInvoice API Types export interface WhmcsCreateInvoiceParams { userid: number; - status?: "Draft" | "Unpaid" | "Paid" | "Cancelled" | "Refunded" | "Collections" | "Payment Pending"; + status?: + | "Draft" + | "Unpaid" + | "Paid" + | "Cancelled" + | "Refunded" + | "Collections" + | "Payment Pending"; sendnotification?: boolean; paymentmethod?: string; taxrate?: number; @@ -390,7 +397,14 @@ export interface WhmcsCreateInvoiceResponse { // UpdateInvoice API Types export interface WhmcsUpdateInvoiceParams { invoiceid: number; - status?: "Draft" | "Unpaid" | "Paid" | "Cancelled" | "Refunded" | "Collections" | "Payment Pending"; + status?: + | "Draft" + | "Unpaid" + | "Paid" + | "Cancelled" + | "Refunded" + | "Collections" + | "Payment Pending"; duedate?: string; // YYYY-MM-DD format notes?: string; [key: string]: unknown; @@ -403,7 +417,7 @@ export interface WhmcsUpdateInvoiceResponse { message?: string; } -// CapturePayment API Types +// CapturePayment API Types export interface WhmcsCapturePaymentParams { invoiceid: number; cvv?: string; @@ -460,4 +474,4 @@ export interface WhmcsAddInvoicePaymentParams { export interface WhmcsAddInvoicePaymentResponse { result: "success" | "error"; message?: string; -} \ No newline at end of file +} diff --git a/apps/bff/src/vendors/whmcs/whmcs.service.ts b/apps/bff/src/vendors/whmcs/whmcs.service.ts index 395f5a5e..a925c51c 100644 --- a/apps/bff/src/vendors/whmcs/whmcs.service.ts +++ b/apps/bff/src/vendors/whmcs/whmcs.service.ts @@ -332,7 +332,14 @@ export class WhmcsService { */ async updateInvoice(params: { invoiceId: number; - status?: "Draft" | "Unpaid" | "Paid" | "Cancelled" | "Refunded" | "Collections" | "Payment Pending"; + status?: + | "Draft" + | "Unpaid" + | "Paid" + | "Cancelled" + | "Refunded" + | "Collections" + | "Payment Pending"; dueDate?: Date; notes?: string; }): Promise<{ success: boolean; message?: string }> { diff --git a/apps/portal/scripts/dev-prep.mjs b/apps/portal/scripts/dev-prep.mjs index ef413ba3..085fbe65 100644 --- a/apps/portal/scripts/dev-prep.mjs +++ b/apps/portal/scripts/dev-prep.mjs @@ -1,11 +1,11 @@ #!/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'; +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'); +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 }); @@ -13,17 +13,15 @@ try { const minimalManifest = { version: 5, pages404: true, - basePath: '', + 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'); + + 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); + console.warn("[dev-prep] Failed to prepare Next dev files:", err?.message || err); } - diff --git a/apps/portal/src/app/catalog/page.tsx b/apps/portal/src/app/catalog/page.tsx index ea3fa62b..2c23fdc9 100644 --- a/apps/portal/src/app/catalog/page.tsx +++ b/apps/portal/src/app/catalog/page.tsx @@ -31,8 +31,8 @@ export default function CatalogPage() {

- Discover high-speed internet, wide range of mobile data options, and secure VPN services. Each - solution is personalized based on your location and account eligibility. + Discover high-speed internet, wide range of mobile data options, and secure VPN + services. Each solution is personalized based on your location and account eligibility.

diff --git a/apps/portal/src/app/catalog/sim/page.tsx b/apps/portal/src/app/catalog/sim/page.tsx index 419ca360..97116a34 100644 --- a/apps/portal/src/app/catalog/sim/page.tsx +++ b/apps/portal/src/app/catalog/sim/page.tsx @@ -273,12 +273,16 @@ export default function SimPlansPage() { : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" }`} > - + Data + SMS/Voice {plansByType.DataSmsVoice.length > 0 && ( - + {plansByType.DataSmsVoice.length} )} @@ -291,12 +295,16 @@ export default function SimPlansPage() { : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" }`} > - + Data Only {plansByType.DataOnly.length > 0 && ( - + {plansByType.DataOnly.length} )} @@ -309,12 +317,16 @@ export default function SimPlansPage() { : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" }`} > - + Voice Only {plansByType.VoiceOnly.length > 0 && ( - + {plansByType.VoiceOnly.length} )} @@ -325,11 +337,13 @@ export default function SimPlansPage() { {/* Tab Content */}
-
+
{activeTab === "data-voice" && ( -
+
{activeTab === "data-only" && ( -
+
{activeTab === "voice-only" && (
Contract Period
-

Minimum 3 full billing months required. First month (sign-up to end of month) is free and doesn't count toward contract.

+

+ Minimum 3 full billing months required. First month (sign-up to end of month) is + free and doesn't count toward contract. +

Billing Cycle
-

Monthly billing from 1st to end of month. Regular billing starts on 1st of following month after sign-up.

+

+ Monthly billing from 1st to end of month. Regular billing starts on 1st of + following month after sign-up. +

Cancellation
-

Can be requested online after 3rd month. Service terminates at end of billing cycle.

+

+ Can be requested online after 3rd month. Service terminates at end of billing + cycle. +

Plan Changes
-

Data plan switching is free and takes effect next month. Voice plan changes require new SIM and cancellation policies apply.

+

+ Data plan switching is free and takes effect next month. Voice plan changes + require new SIM and cancellation policies apply. +

Calling/SMS Charges
-

Pay-per-use charges apply separately. Billed 5-6 weeks after usage within billing cycle.

+

+ Pay-per-use charges apply separately. Billed 5-6 weeks after usage within billing + cycle. +

SIM Replacement
-

Reissue fee of 1,500 JPY applies for damaged, lost, or replacement SIM cards.

+

+ Reissue fee of 1,500 JPY applies for damaged, lost, or replacement SIM cards. +

diff --git a/apps/portal/src/app/orders/[id]/page.tsx b/apps/portal/src/app/orders/[id]/page.tsx index 6675179e..57a0ddd0 100644 --- a/apps/portal/src/app/orders/[id]/page.tsx +++ b/apps/portal/src/app/orders/[id]/page.tsx @@ -3,7 +3,21 @@ import { useEffect, useState } from "react"; import { useParams, useSearchParams } from "next/navigation"; import { PageLayout } from "@/components/layout/page-layout"; -import { ClipboardDocumentCheckIcon, CheckCircleIcon, WifiIcon, DevicePhoneMobileIcon, LockClosedIcon, CubeIcon, StarIcon, WrenchScrewdriverIcon, PlusIcon, BoltIcon, ExclamationTriangleIcon, EnvelopeIcon, PhoneIcon } from "@heroicons/react/24/outline"; +import { + ClipboardDocumentCheckIcon, + CheckCircleIcon, + WifiIcon, + DevicePhoneMobileIcon, + LockClosedIcon, + CubeIcon, + StarIcon, + WrenchScrewdriverIcon, + PlusIcon, + BoltIcon, + ExclamationTriangleIcon, + EnvelopeIcon, + PhoneIcon, +} from "@heroicons/react/24/outline"; import { SubCard } from "@/components/ui/sub-card"; import { StatusPill } from "@/components/ui/status-pill"; import { authenticatedApi } from "@/lib/api"; @@ -190,8 +204,8 @@ export default function OrderStatusPage() { Order Submitted Successfully!

- Your order has been created and submitted for processing. We will notify you as - soon as it's approved and ready for activation. + Your order has been created and submitted for processing. We will notify you as soon + as it's approved and ready for activation.

@@ -210,7 +224,7 @@ export default function OrderStatusPage() { )} {/* Status Section - Moved to top */} - {data && ( + {data && (() => { const statusInfo = getDetailedStatusInfo( data.status, @@ -228,11 +242,9 @@ export default function OrderStatusPage() { : "neutral"; return ( - Status - } + header={

Status

} >
{statusInfo.description}
@@ -241,7 +253,7 @@ export default function OrderStatusPage() { variant={statusVariant as "info" | "success" | "warning" | "error"} />
- + {/* Highlighted Next Steps Section */} {statusInfo.nextAction && (
@@ -252,7 +264,7 @@ export default function OrderStatusPage() {

{statusInfo.nextAction}

)} - + {statusInfo.timeline && (

@@ -262,15 +274,16 @@ export default function OrderStatusPage() { )} ); - })() - )} + })()} {/* Combined Service Overview and Products */} {data && (

{/* Service Header */}
-
{getServiceTypeIcon(data.orderType)}
+
+ {getServiceTypeIcon(data.orderType)} +

{data.orderType} Service @@ -341,7 +354,7 @@ export default function OrderStatusPage() { const bIsService = b.product.itemClass === "Service"; const aIsInstallation = a.product.itemClass === "Installation"; const bIsInstallation = b.product.itemClass === "Installation"; - + if (aIsService && !bIsService) return -1; if (!aIsService && bIsService) return 1; if (aIsInstallation && !bIsInstallation) return -1; @@ -349,111 +362,116 @@ export default function OrderStatusPage() { return 0; }) .map(item => { - // Use the actual Item_Class__c values from Salesforce documentation - const itemClass = item.product.itemClass; + // Use the actual Item_Class__c values from Salesforce documentation + const itemClass = item.product.itemClass; - // Get appropriate icon and color based on item type and billing cycle - const getItemTypeInfo = () => { - const isMonthly = item.product.billingCycle === "Monthly"; - const isService = itemClass === "Service"; - const isInstallation = itemClass === "Installation"; - - if (isService && isMonthly) { - // Main service products - Blue theme - return { - icon: , - bg: "bg-blue-50 border-blue-200", - iconBg: "bg-blue-100 text-blue-600", - label: itemClass || "Service", - labelColor: "text-blue-600", - }; - } else if (isInstallation) { - // Installation items - Green theme - return { - icon: , - bg: "bg-green-50 border-green-200", - iconBg: "bg-green-100 text-green-600", - label: itemClass || "Installation", - labelColor: "text-green-600", - }; - } else if (isMonthly) { - // Other monthly products - Blue theme - return { - icon: , - bg: "bg-blue-50 border-blue-200", - iconBg: "bg-blue-100 text-blue-600", - label: itemClass || "Service", - labelColor: "text-blue-600", - }; - } else { - // One-time products - Orange theme - return { - icon: , - bg: "bg-orange-50 border-orange-200", - iconBg: "bg-orange-100 text-orange-600", - label: itemClass || "Add-on", - labelColor: "text-orange-600", - }; - } - }; + // Get appropriate icon and color based on item type and billing cycle + const getItemTypeInfo = () => { + const isMonthly = item.product.billingCycle === "Monthly"; + const isService = itemClass === "Service"; + const isInstallation = itemClass === "Installation"; - const typeInfo = getItemTypeInfo(); + if (isService && isMonthly) { + // Main service products - Blue theme + return { + icon: , + bg: "bg-blue-50 border-blue-200", + iconBg: "bg-blue-100 text-blue-600", + label: itemClass || "Service", + labelColor: "text-blue-600", + }; + } else if (isInstallation) { + // Installation items - Green theme + return { + icon: , + bg: "bg-green-50 border-green-200", + iconBg: "bg-green-100 text-green-600", + label: itemClass || "Installation", + labelColor: "text-green-600", + }; + } else if (isMonthly) { + // Other monthly products - Blue theme + return { + icon: , + bg: "bg-blue-50 border-blue-200", + iconBg: "bg-blue-100 text-blue-600", + label: itemClass || "Service", + labelColor: "text-blue-600", + }; + } else { + // One-time products - Orange theme + return { + icon: , + bg: "bg-orange-50 border-orange-200", + iconBg: "bg-orange-100 text-orange-600", + label: itemClass || "Add-on", + labelColor: "text-orange-600", + }; + } + }; - return ( -
-
-
-
- {typeInfo.icon} -
+ const typeInfo = getItemTypeInfo(); -
-
-

- {item.product.name} -

- - {typeInfo.label} - + return ( +
+
+
+
+ {typeInfo.icon}
-
- {item.product.billingCycle} - {item.quantity > 1 && Qty: {item.quantity}} - {item.product.itemClass && ( - - {item.product.itemClass} +
+
+

+ {item.product.name} +

+ + {typeInfo.label} - )} +
+ +
+ {item.product.billingCycle} + {item.quantity > 1 && Qty: {item.quantity}} + {item.product.itemClass && ( + + {item.product.itemClass} + + )} +
-
-
- {item.totalPrice && ( -
- ¥{item.totalPrice.toLocaleString()} +
+ {item.totalPrice && ( +
+ ¥{item.totalPrice.toLocaleString()} +
+ )} +
+ {item.product.billingCycle === "Monthly" ? "/month" : "one-time"}
- )} -
- {item.product.billingCycle === "Monthly" ? "/month" : "one-time"}
-
- ); - })} - + ); + })} + {/* Additional fees warning */}
-

Additional fees may apply

+

+ Additional fees may apply +

Weekend installation (+¥3,000), express setup, or special configuration charges may be added. We will contact you before applying any additional @@ -468,7 +486,6 @@ export default function OrderStatusPage() {

)} - {/* Support Contact */}
diff --git a/apps/portal/src/app/orders/page.tsx b/apps/portal/src/app/orders/page.tsx index bb4cb3c4..efba0649 100644 --- a/apps/portal/src/app/orders/page.tsx +++ b/apps/portal/src/app/orders/page.tsx @@ -3,7 +3,14 @@ import { useEffect, useState, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { PageLayout } from "@/components/layout/page-layout"; -import { ClipboardDocumentListIcon, CheckCircleIcon, WifiIcon, DevicePhoneMobileIcon, LockClosedIcon, CubeIcon } from "@heroicons/react/24/outline"; +import { + ClipboardDocumentListIcon, + CheckCircleIcon, + WifiIcon, + DevicePhoneMobileIcon, + LockClosedIcon, + CubeIcon, +} from "@heroicons/react/24/outline"; import { StatusPill } from "@/components/ui/status-pill"; import { authenticatedApi } from "@/lib/api"; @@ -153,7 +160,7 @@ export default function OrdersPage() { order.itemsSummary.forEach(item => { const totalPrice = item.totalPrice || 0; const billingCycle = item.billingCycle?.toLowerCase() || ""; - + if (billingCycle === "monthly") { monthlyTotal += totalPrice; } else { diff --git a/apps/portal/src/app/subscriptions/[id]/page.tsx b/apps/portal/src/app/subscriptions/[id]/page.tsx index 773fe38d..447a34ba 100644 --- a/apps/portal/src/app/subscriptions/[id]/page.tsx +++ b/apps/portal/src/app/subscriptions/[id]/page.tsx @@ -42,10 +42,10 @@ export default function SubscriptionDetailPage() { // Control what sections to show based on URL hash useEffect(() => { const updateVisibility = () => { - const hash = typeof window !== 'undefined' ? window.location.hash : ''; - const service = (searchParams.get('service') || '').toLowerCase(); - const isSimContext = hash.includes('sim-management') || service === 'sim'; - + const hash = typeof window !== "undefined" ? window.location.hash : ""; + const service = (searchParams.get("service") || "").toLowerCase(); + const isSimContext = hash.includes("sim-management") || service === "sim"; + if (isSimContext) { // Show only SIM management, hide invoices setShowInvoices(false); @@ -57,9 +57,9 @@ export default function SubscriptionDetailPage() { } }; updateVisibility(); - if (typeof window !== 'undefined') { - window.addEventListener('hashchange', updateVisibility); - return () => window.removeEventListener('hashchange', updateVisibility); + if (typeof window !== "undefined") { + window.addEventListener("hashchange", updateVisibility); + return () => window.removeEventListener("hashchange", updateVisibility); } return; }, [searchParams]); @@ -221,7 +221,6 @@ export default function SubscriptionDetailPage() {
-
@@ -279,21 +278,23 @@ export default function SubscriptionDetailPage() {
{/* Navigation tabs for SIM services - More visible and mobile-friendly */} - {subscription.productName.toLowerCase().includes('sim') && ( + {subscription.productName.toLowerCase().includes("sim") && (

Service Management

-

Switch between billing and SIM management views

+

+ Switch between billing and SIM management views +

@@ -302,9 +303,9 @@ export default function SubscriptionDetailPage() { @@ -317,186 +318,186 @@ export default function SubscriptionDetailPage() { )} {/* SIM Management Section - Only show when in SIM context and for SIM services */} - {showSimManagement && subscription.productName.toLowerCase().includes('sim') && ( + {showSimManagement && subscription.productName.toLowerCase().includes("sim") && ( )} {/* Related Invoices (hidden when viewing SIM management directly) */} {showInvoices && ( -
-
-
- -

Related Invoices

-
-

- Invoices containing charges for this subscription -

-
- - {invoicesLoading ? ( -
-
-

Loading invoices...

-
- ) : invoicesError ? ( -
- -

Error loading invoices

-

- {invoicesError instanceof Error - ? invoicesError.message - : "Failed to load related invoices"} -

-
- ) : invoices.length === 0 ? ( -
- -

No invoices found

-

- No invoices have been generated for this subscription yet. -

-
- ) : ( - <> -
-
- {invoices.map(invoice => ( -
-
-
-
- {getInvoiceStatusIcon(invoice.status)} -
-
-

- Invoice {invoice.number} -

-

- Issued{" "} - {invoice.issuedAt && - format(new Date(invoice.issuedAt), "MMM d, yyyy")} -

-
-
-
- - {invoice.status} - - - {formatCurrency(invoice.total)} - -
-
-
-
- - Due:{" "} - {invoice.dueDate - ? format(new Date(invoice.dueDate), "MMM d, yyyy") - : "N/A"} - -
- -
-
- ))} -
+
+
+
+ +

Related Invoices

+

+ Invoices containing charges for this subscription +

+
- {/* Pagination */} - {pagination && pagination.totalPages > 1 && ( -
-
- - -
-
-
-

- Showing{" "} - - {(currentPage - 1) * itemsPerPage + 1} - {" "} - to{" "} - - {Math.min(currentPage * itemsPerPage, pagination.totalItems)} - {" "} - of {pagination.totalItems} results -

-
-
- -
+ {invoice.status} + + + {formatCurrency(invoice.total)} + +
+
+
+
+ + Due:{" "} + {invoice.dueDate + ? format(new Date(invoice.dueDate), "MMM d, yyyy") + : "N/A"} + +
+ +
+
+ ))}
- )} - - )} -
+ + {/* Pagination */} + {pagination && pagination.totalPages > 1 && ( +
+
+ + +
+
+
+

+ Showing{" "} + + {(currentPage - 1) * itemsPerPage + 1} + {" "} + to{" "} + + {Math.min(currentPage * itemsPerPage, pagination.totalItems)} + {" "} + of {pagination.totalItems} results +

+
+
+ +
+
+
+ )} + + )} +
)}
diff --git a/apps/portal/src/app/subscriptions/[id]/sim/cancel/page.tsx b/apps/portal/src/app/subscriptions/[id]/sim/cancel/page.tsx index d1dffb4a..039ba9d8 100644 --- a/apps/portal/src/app/subscriptions/[id]/sim/cancel/page.tsx +++ b/apps/portal/src/app/subscriptions/[id]/sim/cancel/page.tsx @@ -41,8 +41,8 @@ export default function SimCancelPage() {

Cancel SIM

- Cancel SIM: Permanently cancel your SIM service. This action cannot be - undone and will terminate your service immediately. + Cancel SIM: Permanently cancel your SIM service. This action cannot be undone and will + terminate your service immediately.

{message && ( diff --git a/apps/portal/src/app/subscriptions/[id]/sim/change-plan/page.tsx b/apps/portal/src/app/subscriptions/[id]/sim/change-plan/page.tsx index 8823c2ee..7eb1f7b2 100644 --- a/apps/portal/src/app/subscriptions/[id]/sim/change-plan/page.tsx +++ b/apps/portal/src/app/subscriptions/[id]/sim/change-plan/page.tsx @@ -7,7 +7,7 @@ import { DashboardLayout } from "@/components/layout/dashboard-layout"; import { authenticatedApi } from "@/lib/api"; const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const; -type PlanCode = typeof PLAN_CODES[number]; +type PlanCode = (typeof PLAN_CODES)[number]; const PLAN_LABELS: Record = { PASI_5G: "5GB", PASI_10G: "10GB", @@ -24,7 +24,10 @@ export default function SimChangePlanPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - const options = useMemo(() => (PLAN_CODES as readonly PlanCode[]).filter(c => c !== (currentPlanCode as PlanCode)), [currentPlanCode]); + const options = useMemo( + () => (PLAN_CODES as readonly PlanCode[]).filter(c => c !== (currentPlanCode as PlanCode)), + [currentPlanCode] + ); const submit = async (e: React.FormEvent) => { e.preventDefault(); @@ -51,32 +54,62 @@ export default function SimChangePlanPage() {
- ← Back to SIM Management + + ← Back to SIM Management +

Change Plan

-

Change Plan: Switch to a different data plan. Important: Plan changes must be requested before the 25th of the month. Changes will take effect on the 1st of the following month.

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

+ Change Plan: Switch to a different data plan. Important: Plan changes must be requested + before the 25th of the month. Changes will take effect on the 1st of the following + month. +

+ {message && ( +
+ {message} +
+ )} + {error && ( +
+ {error} +
+ )}
- - Back + + + Back +
diff --git a/apps/portal/src/app/subscriptions/[id]/sim/reissue/page.tsx b/apps/portal/src/app/subscriptions/[id]/sim/reissue/page.tsx index 78ad84bf..67e08591 100644 --- a/apps/portal/src/app/subscriptions/[id]/sim/reissue/page.tsx +++ b/apps/portal/src/app/subscriptions/[id]/sim/reissue/page.tsx @@ -1 +1,3 @@ -export default function Page(){return null} +export default function Page() { + return null; +} 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 6c7c0876..49ceba01 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 @@ -9,7 +9,7 @@ import { authenticatedApi } from "@/lib/api"; export default function SimTopUpPage() { const params = useParams(); const subscriptionId = parseInt(params.id as string); - const [gbAmount, setGbAmount] = useState('1'); + const [gbAmount, setGbAmount] = useState("1"); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(null); const [error, setError] = useState(null); @@ -31,23 +31,23 @@ export default function SimTopUpPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - + if (!isValidAmount()) { - setError('Please enter a whole number between 1 GB and 100 GB'); + 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: getCurrentAmountMb(), }); 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); } @@ -57,19 +57,26 @@ export default function SimTopUpPage() {
- ← Back to SIM Management + + ← Back to SIM Management +
- +

Top Up Data

-

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

- +

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

+ {message && (
{message}
)} - + {error && (
{error} @@ -79,17 +86,15 @@ export default function SimTopUpPage() {
{/* Amount Input */}
- +
setGbAmount(e.target.value)} + onChange={e => setGbAmount(e.target.value)} placeholder="Enter amount in GB" min="1" - max="50" + max="50" 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" /> @@ -97,9 +102,9 @@ export default function SimTopUpPage() { GB
-

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

+

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

{/* Cost Display */} @@ -107,19 +112,15 @@ export default function SimTopUpPage() {
- {gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : '0 GB'} -
-
- = {getCurrentAmountMb()} MB + {gbAmount && !isNaN(parseInt(gbAmount, 10)) ? `${gbAmount} GB` : "0 GB"}
+
= {getCurrentAmountMb()} MB
¥{calculateCost().toLocaleString()}
-
- (1GB = ¥500) -
+
(1GB = ¥500)
@@ -128,12 +129,20 @@ export default function SimTopUpPage() { {!isValidAmount() && gbAmount && (
- - + + -

- Amount must be a whole number between 1 GB and 50 GB -

+

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

)} @@ -145,7 +154,7 @@ export default function SimTopUpPage() { 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()}`} + {loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`} { - const name = (subscription.productName || '').toLowerCase(); + const name = (subscription.productName || "").toLowerCase(); const looksLikeActivation = - name.includes('activation fee') || name.includes('activation') || name.includes('setup'); - const displayCycle = looksLikeActivation ? 'One-time' : subscription.cycle; + name.includes("activation fee") || name.includes("activation") || name.includes("setup"); + const displayCycle = looksLikeActivation ? "One-time" : subscription.cycle; return {displayCycle}; }, }, diff --git a/apps/portal/src/components/layout/dashboard-layout.tsx b/apps/portal/src/components/layout/dashboard-layout.tsx index b3bbd357..4e9e608c 100644 --- a/apps/portal/src/components/layout/dashboard-layout.tsx +++ b/apps/portal/src/components/layout/dashboard-layout.tsx @@ -343,7 +343,9 @@ function NavigationItem({ const hasChildren = item.children && item.children.length > 0; const isActive = hasChildren - ? item.children?.some((child: NavigationChild) => pathname.startsWith((child.href || "").split(/[?#]/)[0])) || false + ? item.children?.some((child: NavigationChild) => + pathname.startsWith((child.href || "").split(/[?#]/)[0]) + ) || false : item.href ? pathname === item.href : false; diff --git a/apps/portal/src/features/service-management/components/ServiceManagementSection.tsx b/apps/portal/src/features/service-management/components/ServiceManagementSection.tsx index cb4ee179..ea91fdb2 100644 --- a/apps/portal/src/features/service-management/components/ServiceManagementSection.tsx +++ b/apps/portal/src/features/service-management/components/ServiceManagementSection.tsx @@ -22,10 +22,7 @@ export function ServiceManagementSection({ subscriptionId, productName, }: ServiceManagementSectionProps) { - const isSimService = useMemo( - () => productName?.toLowerCase().includes("sim"), - [productName] - ); + const isSimService = useMemo(() => productName?.toLowerCase().includes("sim"), [productName]); const [selectedService, setSelectedService] = useState( isSimService ? "SIM" : "INTERNET" @@ -59,7 +56,7 @@ export function ServiceManagementSection({ id="service-selector" className="block w-48 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm" value={selectedService} - onChange={(e) => setSelectedService(e.target.value as ServiceKey)} + onChange={e => setSelectedService(e.target.value as ServiceKey)} > @@ -99,12 +96,8 @@ export function ServiceManagementSection({ ) : (
-

- SIM management not available -

-

- This subscription is not a SIM service. -

+

SIM management not available

+

This subscription is not a SIM service.

) ) : selectedService === "INTERNET" ? ( diff --git a/apps/portal/src/features/service-management/index.ts b/apps/portal/src/features/service-management/index.ts index 917b2cfa..2bf00ae4 100644 --- a/apps/portal/src/features/service-management/index.ts +++ b/apps/portal/src/features/service-management/index.ts @@ -1 +1 @@ -export { ServiceManagementSection } from './components/ServiceManagementSection'; +export { ServiceManagementSection } from "./components/ServiceManagementSection"; diff --git a/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx index fef89f7a..5986dd76 100644 --- a/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx +++ b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx @@ -12,9 +12,15 @@ interface ChangePlanModalProps { onError: (message: string) => void; } -export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSuccess, onError }: ChangePlanModalProps) { +export function ChangePlanModal({ + subscriptionId, + currentPlanCode, + onClose, + onSuccess, + onError, +}: ChangePlanModalProps) { const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const; - type PlanCode = typeof PLAN_CODES[number]; + type PlanCode = (typeof PLAN_CODES)[number]; const PLAN_LABELS: Record = { PASI_5G: "5GB", PASI_10G: "10GB", @@ -22,7 +28,9 @@ export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSu PASI_50G: "50GB", }; - const allowedPlans = (PLAN_CODES as readonly PlanCode[]).filter(code => code !== (currentPlanCode || '')); + const allowedPlans = (PLAN_CODES as readonly PlanCode[]).filter( + code => code !== (currentPlanCode || "") + ); const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>(""); const [loading, setLoading] = useState(false); @@ -48,9 +56,14 @@ export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSu return (
- + - +
@@ -63,18 +76,25 @@ export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSu
- + -

Only plans different from your current plan are listed. The change will be scheduled for the 1st of the next month.

+

+ Only plans different from your current plan are listed. The change will be + scheduled for the 1st of the next month. +

diff --git a/apps/portal/src/features/sim-management/components/DataUsageChart.tsx b/apps/portal/src/features/sim-management/components/DataUsageChart.tsx index f140fea6..9e0f8d8a 100644 --- a/apps/portal/src/features/sim-management/components/DataUsageChart.tsx +++ b/apps/portal/src/features/sim-management/components/DataUsageChart.tsx @@ -1,10 +1,7 @@ "use client"; -import React from 'react'; -import { - ChartBarIcon, - ExclamationTriangleIcon -} from '@heroicons/react/24/outline'; +import React from "react"; +import { ChartBarIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; export interface SimUsage { account: string; @@ -26,7 +23,13 @@ interface DataUsageChartProps { embedded?: boolean; // when true, render content without card container } -export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embedded = false }: DataUsageChartProps) { +export function DataUsageChart({ + usage, + remainingQuotaMb, + isLoading, + error, + embedded = false, +}: DataUsageChartProps) { const formatUsage = (usageMb: number) => { if (usageMb >= 1000) { return `${(usageMb / 1000).toFixed(1)} GB`; @@ -35,22 +38,22 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe }; const getUsageColor = (percentage: number) => { - if (percentage >= 90) return 'bg-red-500'; - if (percentage >= 75) return 'bg-yellow-500'; - if (percentage >= 50) return 'bg-orange-500'; - return 'bg-green-500'; + if (percentage >= 90) return "bg-red-500"; + if (percentage >= 75) return "bg-yellow-500"; + if (percentage >= 50) return "bg-orange-500"; + return "bg-green-500"; }; const getUsageTextColor = (percentage: number) => { - if (percentage >= 90) return 'text-red-600'; - if (percentage >= 75) return 'text-yellow-600'; - if (percentage >= 50) return 'text-orange-600'; - return 'text-green-600'; + if (percentage >= 90) return "text-red-600"; + if (percentage >= 75) return "text-yellow-600"; + if (percentage >= 50) return "text-orange-600"; + return "text-green-600"; }; if (isLoading) { return ( -
+
@@ -66,7 +69,7 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe if (error) { return ( -
+

Error Loading Usage Data

@@ -77,14 +80,17 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe } // Calculate total usage from recent days (assume it includes today) - const totalRecentUsage = usage.recentDaysUsage.reduce((sum, day) => sum + day.usageMb, 0) + usage.todayUsageMb; + const totalRecentUsage = + usage.recentDaysUsage.reduce((sum, day) => sum + day.usageMb, 0) + usage.todayUsageMb; const totalQuota = remainingQuotaMb + totalRecentUsage; const usagePercentage = totalQuota > 0 ? (totalRecentUsage / totalQuota) * 100 : 0; return ( -
+
{/* Header */} -
+
@@ -97,7 +103,7 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
{/* Content */} -
+
{/* Current Usage Overview */}
@@ -106,15 +112,15 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe {formatUsage(totalRecentUsage)} of {formatUsage(totalQuota)}
- + {/* Progress Bar */}
-
- +
0% @@ -135,13 +141,23 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
Used today
- - + +
- +
@@ -151,8 +167,18 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe
Remaining
- - + +
@@ -171,14 +197,14 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe return (
- {new Date(day.date).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', + {new Date(day.date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", })}
-
@@ -216,7 +242,8 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe

High Usage Warning

- You've used {usagePercentage.toFixed(1)}% of your data quota. Consider topping up to avoid service interruption. + You've used {usagePercentage.toFixed(1)}% of your data quota. Consider topping up + to avoid service interruption.

@@ -230,7 +257,8 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embe

Usage Notice

- You've used {usagePercentage.toFixed(1)}% of your data quota. Consider monitoring your usage. + You've used {usagePercentage.toFixed(1)}% of your data quota. Consider monitoring + your usage.

diff --git a/apps/portal/src/features/sim-management/components/SimActions.tsx b/apps/portal/src/features/sim-management/components/SimActions.tsx index 433384d0..74aef3b4 100644 --- a/apps/portal/src/features/sim-management/components/SimActions.tsx +++ b/apps/portal/src/features/sim-management/components/SimActions.tsx @@ -1,21 +1,21 @@ "use client"; -import React, { useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { - PlusIcon, - ArrowPathIcon, +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { + PlusIcon, + ArrowPathIcon, XMarkIcon, ExclamationTriangleIcon, - CheckCircleIcon -} from '@heroicons/react/24/outline'; -import { TopUpModal } from './TopUpModal'; -import { ChangePlanModal } from './ChangePlanModal'; -import { authenticatedApi } from '@/lib/api'; + CheckCircleIcon, +} from "@heroicons/react/24/outline"; +import { TopUpModal } from "./TopUpModal"; +import { ChangePlanModal } from "./ChangePlanModal"; +import { authenticatedApi } from "@/lib/api"; interface SimActionsProps { subscriptionId: number; - simType: 'physical' | 'esim'; + simType: "physical" | "esim"; status: string; onTopUpSuccess?: () => void; onPlanChangeSuccess?: () => void; @@ -25,16 +25,16 @@ interface SimActionsProps { currentPlanCode?: string; } -export function SimActions({ - subscriptionId, - simType, +export function SimActions({ + subscriptionId, + simType, status, onTopUpSuccess, onPlanChangeSuccess, onCancelSuccess, onReissueSuccess, embedded = false, - currentPlanCode + currentPlanCode, }: SimActionsProps) { const router = useRouter(); const [showTopUpModal, setShowTopUpModal] = useState(false); @@ -45,43 +45,43 @@ export function SimActions({ const [success, setSuccess] = useState(null); const [showChangePlanModal, setShowChangePlanModal] = useState(false); const [activeInfo, setActiveInfo] = useState< - 'topup' | 'reissue' | 'cancel' | 'changePlan' | null + "topup" | "reissue" | "cancel" | "changePlan" | null >(null); - const isActive = status === 'active'; + const isActive = status === "active"; const canTopUp = isActive; - const canReissue = isActive && simType === 'esim'; + const canReissue = isActive && simType === "esim"; const canCancel = isActive; const handleReissueEsim = async () => { - setLoading('reissue'); + setLoading("reissue"); setError(null); - + try { await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`); - - setSuccess('eSIM profile reissued successfully'); + + setSuccess("eSIM profile reissued successfully"); setShowReissueConfirm(false); onReissueSuccess?.(); } catch (error: any) { - setError(error instanceof Error ? error.message : 'Failed to reissue eSIM profile'); + setError(error instanceof Error ? error.message : "Failed to reissue eSIM profile"); } finally { setLoading(null); } }; const handleCancelSim = async () => { - setLoading('cancel'); + setLoading("cancel"); setError(null); - + try { await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/cancel`, {}); - - setSuccess('SIM service cancelled successfully'); + + setSuccess("SIM service cancelled successfully"); setShowCancelConfirm(false); onCancelSuccess?.(); } catch (error: any) { - setError(error instanceof Error ? error.message : 'Failed to cancel SIM service'); + setError(error instanceof Error ? error.message : "Failed to cancel SIM service"); } finally { setLoading(null); } @@ -100,13 +100,26 @@ export function SimActions({ }, [success, error]); return ( -
+
{/* Header */} -
+
- - + +
@@ -117,7 +130,7 @@ export function SimActions({
{/* Content */} -
+
{/* Status Messages */} {success && (
@@ -149,11 +162,11 @@ export function SimActions({ )} {/* Action Buttons */} -
+
{/* Top Up Data - Primary Action */} {/* Reissue eSIM (only for eSIMs) */} - {simType === 'esim' && ( + {simType === "esim" && ( )} @@ -205,7 +218,7 @@ export function SimActions({ {/* Cancel SIM - Destructive Action */} {/* Change Plan - Secondary Action */}
-

Cancel SIM Service

+

+ Cancel SIM Service +

- Are you sure you want to cancel this SIM service? This action cannot be undone and will permanently terminate your service. + Are you sure you want to cancel this SIM service? This action cannot be + undone and will permanently terminate your service.

@@ -395,15 +450,18 @@ export function SimActions({

- +

{simDetails.msisdn}

- -

{formatQuota(simDetails.remainingQuotaMb)}

+ +

+ {formatQuota(simDetails.remainingQuotaMb)} +

@@ -195,26 +217,32 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
Voice Mail (¥300/month) - - {simDetails.voiceMailEnabled ? 'Enabled' : 'Disabled'} + + {simDetails.voiceMailEnabled ? "Enabled" : "Disabled"}
Call Waiting (¥300/month) - - {simDetails.callWaitingEnabled ? 'Enabled' : 'Disabled'} + + {simDetails.callWaitingEnabled ? "Enabled" : "Disabled"}
International Roaming - - {simDetails.internationalRoamingEnabled ? 'Enabled' : 'Disabled'} + + {simDetails.internationalRoamingEnabled ? "Enabled" : "Disabled"}
4G/5G - {simDetails.networkType || '5G'} + {simDetails.networkType || "5G"}
@@ -227,9 +255,9 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false, } return ( -
+
{/* Header */} -
+
@@ -244,7 +272,9 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
{getStatusIcon(simDetails.status)} - + {simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
@@ -252,7 +282,7 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
{/* Content */} -
+
{/* SIM Information */}
@@ -264,8 +294,8 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,

{simDetails.msisdn}

- - {simDetails.simType === 'physical' && ( + + {simDetails.simType === "physical" && (

{simDetails.iccid}

@@ -304,20 +334,30 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
-

{formatQuota(simDetails.remainingQuotaMb)}

+

+ {formatQuota(simDetails.remainingQuotaMb)} +

- - - Voice {simDetails.hasVoice ? 'Enabled' : 'Disabled'} + + + Voice {simDetails.hasVoice ? "Enabled" : "Disabled"}
- - - SMS {simDetails.hasSms ? 'Enabled' : 'Disabled'} + + + SMS {simDetails.hasSms ? "Enabled" : "Disabled"}
diff --git a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx index 159bd7e3..6e2e6830 100644 --- a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx +++ b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx @@ -23,18 +23,21 @@ export function SimFeatureToggles({ embedded = false, }: SimFeatureTogglesProps) { // Initial values - const initial = useMemo(() => ({ - vm: !!voiceMailEnabled, - cw: !!callWaitingEnabled, - ir: !!internationalRoamingEnabled, - nt: networkType === '5G' ? '5G' : '4G', - }), [voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]); + const initial = useMemo( + () => ({ + vm: !!voiceMailEnabled, + cw: !!callWaitingEnabled, + ir: !!internationalRoamingEnabled, + nt: networkType === "5G" ? "5G" : "4G", + }), + [voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType] + ); // Working values const [vm, setVm] = useState(initial.vm); const [cw, setCw] = useState(initial.cw); const [ir, setIr] = useState(initial.ir); - const [nt, setNt] = useState<'4G' | '5G'>(initial.nt as '4G' | '5G'); + const [nt, setNt] = useState<"4G" | "5G">(initial.nt as "4G" | "5G"); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); @@ -43,14 +46,14 @@ export function SimFeatureToggles({ setVm(initial.vm); setCw(initial.cw); setIr(initial.ir); - setNt(initial.nt as '4G' | '5G'); + setNt(initial.nt as "4G" | "5G"); }, [initial.vm, initial.cw, initial.ir, initial.nt]); const reset = () => { setVm(initial.vm); setCw(initial.cw); setIr(initial.ir); - setNt(initial.nt as '4G' | '5G'); + setNt(initial.nt as "4G" | "5G"); setError(null); setSuccess(null); }; @@ -67,13 +70,16 @@ export function SimFeatureToggles({ if (nt !== initial.nt) featurePayload.networkType = nt; if (Object.keys(featurePayload).length > 0) { - await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/features`, featurePayload); + await authenticatedApi.post( + `/subscriptions/${subscriptionId}/sim/features`, + featurePayload + ); } - setSuccess('Changes submitted successfully'); + setSuccess("Changes submitted successfully"); onChanged?.(); } catch (e: any) { - setError(e instanceof Error ? e.message : 'Failed to submit changes'); + setError(e instanceof Error ? e.message : "Failed to submit changes"); } finally { setLoading(false); setTimeout(() => setSuccess(null), 3000); @@ -82,18 +88,28 @@ export function SimFeatureToggles({ return (
- {/* Service Options */} -
- -
+
+
{/* Voice Mail */}
- - + +
@@ -105,14 +121,14 @@ export function SimFeatureToggles({
Current: - - {initial.vm ? 'Enabled' : 'Disabled'} + + {initial.vm ? "Enabled" : "Disabled"}
setCw(e.target.value === 'Enabled')} + value={cw ? "Enabled" : "Disabled"} + onChange={e => setCw(e.target.value === "Enabled")} className="block w-32 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300" > @@ -160,8 +186,18 @@ export function SimFeatureToggles({
- - + +
@@ -173,14 +209,14 @@ export function SimFeatureToggles({
Current: - - {initial.ir ? 'Enabled' : 'Disabled'} + + {initial.ir ? "Enabled" : "Disabled"}
setNt(e.target.value as '4G' | '5G')} + onChange={e => setNt(e.target.value as "4G" | "5G")} className="block w-20 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none transition-colors hover:border-gray-300" > @@ -224,19 +270,34 @@ export function SimFeatureToggles({
{/* Notes and Actions */} -
+
- - + +
-

Important Notes:

+

+ Important Notes: +

  • Changes will take effect instantaneously (approx. 30min)
  • May require smartphone/device restart after changes are applied
  • 5G requires a compatible smartphone/device. Will not function on 4G devices
  • -
  • Changes to Voice Mail / Call Waiting must be requested before the 25th of the month
  • +
  • + Changes to Voice Mail / Call Waiting must be requested before the 25th of the + month +
@@ -245,8 +306,18 @@ export function SimFeatureToggles({ {success && (
- - + +

{success}

@@ -256,8 +327,18 @@ export function SimFeatureToggles({ {error && (
- - + +

{error}

@@ -272,16 +353,36 @@ export function SimFeatureToggles({ > {loading ? ( <> - - - + + + Applying Changes... ) : ( <> - + Apply Changes @@ -293,7 +394,12 @@ export function SimFeatureToggles({ className="inline-flex items-center justify-center px-6 py-3 border border-gray-300 rounded-lg text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200" > - + Reset diff --git a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx index dec50c0f..86250a7b 100644 --- a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx @@ -1,16 +1,16 @@ "use client"; -import React, { useState, useEffect } from 'react'; -import { +import React, { useState, useEffect } from "react"; +import { DevicePhoneMobileIcon, ExclamationTriangleIcon, - ArrowPathIcon -} from '@heroicons/react/24/outline'; -import { SimDetailsCard, type SimDetails } from './SimDetailsCard'; -import { DataUsageChart, type SimUsage } from './DataUsageChart'; -import { SimActions } from './SimActions'; -import { authenticatedApi } from '@/lib/api'; -import { SimFeatureToggles } from './SimFeatureToggles'; + ArrowPathIcon, +} from "@heroicons/react/24/outline"; +import { SimDetailsCard, type SimDetails } from "./SimDetailsCard"; +import { DataUsageChart, type SimUsage } from "./DataUsageChart"; +import { SimActions } from "./SimActions"; +import { authenticatedApi } from "@/lib/api"; +import { SimFeatureToggles } from "./SimFeatureToggles"; interface SimManagementSectionProps { subscriptionId: number; @@ -29,19 +29,19 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro const fetchSimInfo = async () => { try { setError(null); - + const data = await authenticatedApi.get<{ details: SimDetails; usage: SimUsage; }>(`/subscriptions/${subscriptionId}/sim`); - + setSimInfo(data); } catch (error: any) { if (error.status === 400) { // Not a SIM subscription - this component shouldn't be shown - setError('This subscription is not a SIM service'); + setError("This subscription is not a SIM service"); } else { - setError(error instanceof Error ? error.message : 'Failed to load SIM information'); + setError(error instanceof Error ? error.message : "Failed to load SIM information"); } } finally { setLoading(false); @@ -105,7 +105,9 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
-

Unable to Load SIM Information

+

+ Unable to Load SIM Information +

{error}

diff --git a/apps/portal/src/features/sim-management/index.ts b/apps/portal/src/features/sim-management/index.ts index f5cb6a5c..59032d7a 100644 --- a/apps/portal/src/features/sim-management/index.ts +++ b/apps/portal/src/features/sim-management/index.ts @@ -1,9 +1,9 @@ -export { SimManagementSection } from './components/SimManagementSection'; -export { SimDetailsCard } from './components/SimDetailsCard'; -export { DataUsageChart } from './components/DataUsageChart'; -export { SimActions } from './components/SimActions'; -export { TopUpModal } from './components/TopUpModal'; -export { SimFeatureToggles } from './components/SimFeatureToggles'; +export { SimManagementSection } from "./components/SimManagementSection"; +export { SimDetailsCard } from "./components/SimDetailsCard"; +export { DataUsageChart } from "./components/DataUsageChart"; +export { SimActions } from "./components/SimActions"; +export { TopUpModal } from "./components/TopUpModal"; +export { SimFeatureToggles } from "./components/SimFeatureToggles"; -export type { SimDetails } from './components/SimDetailsCard'; -export type { SimUsage } from './components/DataUsageChart'; +export type { SimDetails } from "./components/SimDetailsCard"; +export type { SimUsage } from "./components/DataUsageChart"; diff --git a/apps/portal/src/providers/query-provider.tsx b/apps/portal/src/providers/query-provider.tsx index 6de89308..1418e4bf 100644 --- a/apps/portal/src/providers/query-provider.tsx +++ b/apps/portal/src/providers/query-provider.tsx @@ -19,9 +19,7 @@ export function QueryProvider({ children }: QueryProviderProps) { return ( {children} - {enableDevtools && ReactQueryDevtools ? ( - - ) : null} + {enableDevtools && ReactQueryDevtools ? : null} ); }