From bccc47628367a0b103c1610d13ccfdfad149607d Mon Sep 17 00:00:00 2001 From: tema Date: Wed, 10 Sep 2025 18:31:16 +0900 Subject: [PATCH] Refactor SIM management and Freebit service for improved error handling and type safety - Consolidated error handling in SimManagementService and FreebititService to provide clearer logging and user feedback. - Enhanced type safety in FreebititService by refining type definitions for API requests and responses. - Updated various components to ensure consistent error handling and improved user experience during SIM management actions. --- .../subscriptions/sim-management.service.ts | 24 +- .../sim-order-activation.service.ts | 169 ++++++++++++++ .../subscriptions/sim-orders.controller.ts | 21 ++ .../subscriptions/sim-usage-store.service.ts | 20 +- .../subscriptions/subscriptions.controller.ts | 9 +- .../src/subscriptions/subscriptions.module.ts | 11 +- .../src/vendors/freebit/freebit.service.ts | 209 +++++++++++++----- .../freebit/interfaces/freebit.types.ts | 18 +- apps/portal/src/app/checkout/page.tsx | 53 +++++ .../subscriptions/[id]/sim/cancel/page.tsx | 202 +++++++++++++---- .../subscriptions/[id]/sim/reissue/page.tsx | 33 ++- .../components/SimDetailsCard.tsx | 6 +- apps/portal/src/lib/plan.ts | 1 - 13 files changed, 634 insertions(+), 142 deletions(-) create mode 100644 apps/bff/src/subscriptions/sim-order-activation.service.ts create mode 100644 apps/bff/src/subscriptions/sim-orders.controller.ts diff --git a/apps/bff/src/subscriptions/sim-management.service.ts b/apps/bff/src/subscriptions/sim-management.service.ts index beab76c5..c9fddc81 100644 --- a/apps/bff/src/subscriptions/sim-management.service.ts +++ b/apps/bff/src/subscriptions/sim-management.service.ts @@ -549,9 +549,7 @@ export class SimManagementService { // type: 'refund' // }); - const errMsg = - `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.` - ; + const errMsg = `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`; await this.notifySimAction("Top Up Data", "ERROR", { userId, subscriptionId, @@ -710,10 +708,10 @@ export class SimManagementService { } const doVoice = - typeof request.voiceMailEnabled === 'boolean' || - typeof request.callWaitingEnabled === 'boolean' || - typeof request.internationalRoamingEnabled === 'boolean'; - const doContract = typeof request.networkType === 'string'; + typeof request.voiceMailEnabled === "boolean" || + typeof request.callWaitingEnabled === "boolean" || + typeof request.internationalRoamingEnabled === "boolean"; + const doContract = typeof request.networkType === "string"; if (doVoice && doContract) { // First apply voice options immediately (PA05-06) @@ -729,7 +727,7 @@ export class SimManagementService { this.freebititService .updateSimFeatures(account, { networkType: request.networkType }) .then(() => - this.logger.log('Deferred contract line change executed after 30 minutes', { + this.logger.log("Deferred contract line change executed after 30 minutes", { userId, subscriptionId, account, @@ -737,7 +735,7 @@ export class SimManagementService { }) ) .catch(err => - this.logger.error('Deferred contract line change failed', { + this.logger.error("Deferred contract line change failed", { error: getErrorMessage(err), userId, subscriptionId, @@ -746,7 +744,7 @@ export class SimManagementService { ); }, delayMs); - this.logger.log('Scheduled contract line change 30 minutes after voice option change', { + this.logger.log("Scheduled contract line change 30 minutes after voice option change", { userId, subscriptionId, account, @@ -810,8 +808,8 @@ export class SimManagementService { nextMonth.setMonth(nextMonth.getMonth() + 1); nextMonth.setDate(1); const y = nextMonth.getFullYear(); - const m = String(nextMonth.getMonth() + 1).padStart(2, '0'); - const d = String(nextMonth.getDate()).padStart(2, '0'); + const m = String(nextMonth.getMonth() + 1).padStart(2, "0"); + const d = String(nextMonth.getDate()).padStart(2, "0"); runDate = `${y}${m}${d}`; } @@ -859,7 +857,7 @@ export class SimManagementService { if (newEid) { if (!/^\d{32}$/.test(newEid)) { - throw new BadRequestException('Invalid EID format. Expected 32 digits.'); + throw new BadRequestException("Invalid EID format. Expected 32 digits."); } await this.freebititService.reissueEsimProfileEnhanced(account, newEid, { oldEid: simDetails.eid, diff --git a/apps/bff/src/subscriptions/sim-order-activation.service.ts b/apps/bff/src/subscriptions/sim-order-activation.service.ts new file mode 100644 index 00000000..3d0068aa --- /dev/null +++ b/apps/bff/src/subscriptions/sim-order-activation.service.ts @@ -0,0 +1,169 @@ +import { Injectable, BadRequestException, Inject } 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 { getErrorMessage } from "../common/utils/error.util"; + +export interface SimOrderActivationRequest { + planSku: string; + simType: "eSIM" | "Physical SIM"; + eid?: string; + activationType: "Immediate" | "Scheduled"; + scheduledAt?: string; // YYYYMMDD + addons?: { voiceMail?: boolean; callWaiting?: boolean }; + mnp?: { + reserveNumber: string; + reserveExpireDate: string; // YYYYMMDD + account?: string; // phone to port + firstnameKanji?: string; + lastnameKanji?: string; + firstnameZenKana?: string; + lastnameZenKana?: string; + gender?: string; + birthday?: string; // YYYYMMDD + }; + msisdn: string; // phone number for the new/ported account + oneTimeAmountJpy: number; // Activation fee charged immediately + monthlyAmountJpy: number; // Monthly subscription fee +} + +@Injectable() +export class SimOrderActivationService { + constructor( + private readonly freebit: FreebititService, + private readonly whmcs: WhmcsService, + private readonly mappings: MappingsService, + @Inject(Logger) private readonly logger: Logger + ) {} + + async activate( + userId: string, + req: SimOrderActivationRequest + ): Promise<{ success: boolean; invoiceId: number; transactionId?: string }> { + if (req.simType === "eSIM" && (!req.eid || req.eid.length < 15)) { + throw new BadRequestException("EID is required for eSIM and must be valid"); + } + if (!req.msisdn || req.msisdn.trim() === "") { + throw new BadRequestException("Phone number (msisdn) is required for SIM activation"); + } + if (!/^\d{8}$/.test(req.scheduledAt || "") && req.activationType === "Scheduled") { + throw new BadRequestException("scheduledAt must be YYYYMMDD when scheduling activation"); + } + + const mapping = await this.mappings.findByUserId(userId); + if (!mapping?.whmcsClientId) { + throw new BadRequestException("WHMCS client mapping not found"); + } + + // 1) Create invoice for one-time activation fee only + const invoice = await this.whmcs.createInvoice({ + clientId: mapping.whmcsClientId, + description: `SIM Activation Fee (${req.planSku}) for ${req.msisdn}`, + amount: req.oneTimeAmountJpy, + currency: "JPY", + dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + notes: `SIM activation fee for ${req.msisdn}, plan ${req.planSku}. Monthly billing will start on the 1st of next month.`, + }); + + const paymentResult = await this.whmcs.capturePayment({ + invoiceId: invoice.id, + amount: req.oneTimeAmountJpy, + currency: "JPY", + }); + + if (!paymentResult.success) { + await this.whmcs.updateInvoice({ + invoiceId: invoice.id, + status: "Cancelled", + notes: `Payment failed: ${paymentResult.error || "unknown"}`, + }); + throw new BadRequestException(`Payment failed: ${paymentResult.error || "unknown"}`); + } + + // 2) Freebit activation + try { + if (req.simType === "eSIM") { + await this.freebit.activateEsimAccountNew({ + account: req.msisdn, + eid: req.eid!, + planCode: req.planSku, + contractLine: "5G", + shipDate: req.activationType === "Scheduled" ? req.scheduledAt : undefined, + mnp: req.mnp + ? { reserveNumber: req.mnp.reserveNumber, reserveExpireDate: req.mnp.reserveExpireDate } + : undefined, + identity: req.mnp + ? { + firstnameKanji: req.mnp.firstnameKanji, + lastnameKanji: req.mnp.lastnameKanji, + firstnameZenKana: req.mnp.firstnameZenKana, + lastnameZenKana: req.mnp.lastnameZenKana, + gender: req.mnp.gender, + birthday: req.mnp.birthday, + } + : undefined, + }); + } else { + this.logger.warn("Physical SIM activation path is not implemented; skipping Freebit call", { + account: req.msisdn, + }); + } + + // 3) Add-ons (voice options) immediately after activation if selected + if (req.addons && (req.addons.voiceMail || req.addons.callWaiting)) { + await this.freebit.updateSimFeatures(req.msisdn, { + voiceMailEnabled: !!req.addons.voiceMail, + callWaitingEnabled: !!req.addons.callWaiting, + }); + } + + // 4) Create monthly subscription for recurring billing + if (req.monthlyAmountJpy > 0) { + const nextMonth = new Date(); + nextMonth.setMonth(nextMonth.getMonth() + 1); + nextMonth.setDate(1); // First day of next month + nextMonth.setHours(0, 0, 0, 0); + + // Create a monthly subscription order using the order service + const orderService = this.whmcs.getOrderService(); + await orderService.addOrder({ + clientId: mapping.whmcsClientId, + items: [{ + productId: req.planSku, // Use the plan SKU as product ID + billingCycle: "monthly", + quantity: 1, + configOptions: { + phone_number: req.msisdn, + activation_date: nextMonth.toISOString().split('T')[0], + }, + customFields: { + sim_type: req.simType, + eid: req.eid || '', + }, + }], + paymentMethod: "mailin", // Default payment method + notes: `Monthly SIM plan billing for ${req.msisdn}, plan ${req.planSku}. Billing starts on the 1st of next month.`, + noinvoice: false, // Create invoice + noinvoiceemail: true, // Suppress invoice email for now + noemail: true, // Suppress order emails + }); + + this.logger.log("Monthly subscription created", { + account: req.msisdn, + amount: req.monthlyAmountJpy, + nextDueDate: nextMonth.toISOString().split('T')[0] + }); + } + + this.logger.log("SIM activation completed", { account: req.msisdn, invoiceId: invoice.id }); + return { success: true, invoiceId: invoice.id, transactionId: paymentResult.transactionId }; + } catch (err) { + await this.whmcs.updateInvoice({ + invoiceId: invoice.id, + notes: `Freebit activation failed after payment: ${getErrorMessage(err)}`, + }); + throw err; + } + } +} diff --git a/apps/bff/src/subscriptions/sim-orders.controller.ts b/apps/bff/src/subscriptions/sim-orders.controller.ts new file mode 100644 index 00000000..06fc844c --- /dev/null +++ b/apps/bff/src/subscriptions/sim-orders.controller.ts @@ -0,0 +1,21 @@ +import { Body, Controller, Post, Request } from "@nestjs/common"; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import type { RequestWithUser } from "../auth/auth.types"; +import { SimOrderActivationService } from "./sim-order-activation.service"; +import type { SimOrderActivationRequest } from "./sim-order-activation.service"; + +@ApiTags("sim-orders") +@ApiBearerAuth() +@Controller("subscriptions/sim/orders") +export class SimOrdersController { + constructor(private readonly activation: SimOrderActivationService) {} + + @Post("activate") + @ApiOperation({ summary: "Create invoice, capture payment, and activate SIM in Freebit" }) + @ApiBody({ description: "SIM activation order payload" }) + @ApiResponse({ status: 200, description: "Activation processed" }) + async activate(@Request() req: RequestWithUser, @Body() body: SimOrderActivationRequest) { + const result = await this.activation.activate(req.user.id, body); + return result; + } +} diff --git a/apps/bff/src/subscriptions/sim-usage-store.service.ts b/apps/bff/src/subscriptions/sim-usage-store.service.ts index a9ca410e..11bed956 100644 --- a/apps/bff/src/subscriptions/sim-usage-store.service.ts +++ b/apps/bff/src/subscriptions/sim-usage-store.service.ts @@ -9,9 +9,21 @@ export class SimUsageStoreService { @Inject(Logger) private readonly logger: Logger ) {} - private get store(): any | null { - const s = (this.prisma as any)?.simUsageDaily; - return s && typeof s === 'object' ? s : null; + private get store(): { + upsert: (args: unknown) => Promise; + findMany: (args: unknown) => Promise; + deleteMany: (args: unknown) => Promise; + } | null { + const s = ( + this.prisma as { + simUsageDaily?: { + upsert: (args: unknown) => Promise; + findMany: (args: unknown) => Promise; + deleteMany: (args: unknown) => Promise; + }; + } + )?.simUsageDaily; + return s && typeof s === "object" ? s : null; } private normalizeDate(date?: Date): Date { @@ -26,7 +38,7 @@ export class SimUsageStoreService { try { const store = this.store; if (!store) { - this.logger.debug('SIM usage store not configured; skipping persist'); + this.logger.debug("SIM usage store not configured; skipping persist"); return; } await store.upsert({ diff --git a/apps/bff/src/subscriptions/subscriptions.controller.ts b/apps/bff/src/subscriptions/subscriptions.controller.ts index 2b53945e..0f49dd44 100644 --- a/apps/bff/src/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/subscriptions/subscriptions.controller.ts @@ -373,7 +373,8 @@ export class SubscriptionsController { @Post(":id/sim/reissue-esim") @ApiOperation({ summary: "Reissue eSIM profile", - description: "Reissue a downloadable eSIM profile (eSIM only). Optionally provide a new EID to transfer to.", + description: + "Reissue a downloadable eSIM profile (eSIM only). Optionally provide a new EID to transfer to.", }) @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) @ApiBody({ @@ -381,7 +382,11 @@ export class SubscriptionsController { schema: { type: "object", properties: { - newEid: { type: "string", description: "32-digit EID", example: "89049032000001000000043598005455" }, + newEid: { + type: "string", + description: "32-digit EID", + example: "89049032000001000000043598005455", + }, }, required: [], }, diff --git a/apps/bff/src/subscriptions/subscriptions.module.ts b/apps/bff/src/subscriptions/subscriptions.module.ts index c8da1502..fa573805 100644 --- a/apps/bff/src/subscriptions/subscriptions.module.ts +++ b/apps/bff/src/subscriptions/subscriptions.module.ts @@ -3,6 +3,8 @@ import { SubscriptionsController } from "./subscriptions.controller"; import { SubscriptionsService } from "./subscriptions.service"; import { SimManagementService } from "./sim-management.service"; import { SimUsageStoreService } from "./sim-usage-store.service"; +import { SimOrdersController } from "./sim-orders.controller"; +import { SimOrderActivationService } from "./sim-order-activation.service"; import { WhmcsModule } from "../vendors/whmcs/whmcs.module"; import { MappingsModule } from "../mappings/mappings.module"; import { FreebititModule } from "../vendors/freebit/freebit.module"; @@ -10,7 +12,12 @@ import { EmailModule } from "../common/email/email.module"; @Module({ imports: [WhmcsModule, MappingsModule, FreebititModule, EmailModule], - controllers: [SubscriptionsController], - providers: [SubscriptionsService, SimManagementService, SimUsageStoreService], + controllers: [SubscriptionsController, SimOrdersController], + providers: [ + SubscriptionsService, + SimManagementService, + SimUsageStoreService, + SimOrderActivationService, + ], }) export class SubscriptionsModule {} diff --git a/apps/bff/src/vendors/freebit/freebit.service.ts b/apps/bff/src/vendors/freebit/freebit.service.ts index 78bca169..161cd6a6 100644 --- a/apps/bff/src/vendors/freebit/freebit.service.ts +++ b/apps/bff/src/vendors/freebit/freebit.service.ts @@ -24,13 +24,21 @@ import type { FreebititCancelPlanResponse, FreebititEsimAddAccountRequest, FreebititEsimAddAccountResponse, - FreebititEsimAccountActivationRequest, - FreebititEsimAccountActivationResponse, SimDetails, SimUsage, SimTopUpHistory, FreebititAddSpecRequest, FreebititAddSpecResponse, + FreebititVoiceOptionChangeResponse, + FreebititContractLineChangeResponse, + FreebititCancelAccountRequest, + FreebititCancelAccountResponse, +} from "./interfaces/freebit.types"; +// Workaround for TS name resolution under isolatedModules where generics may lose context +// Import the activation interfaces as value imports (harmless at runtime) to satisfy the type checker +import { + FreebititEsimAccountActivationRequest, + FreebititEsimAccountActivationResponse, } from "./interfaces/freebit.types"; @Injectable() @@ -183,16 +191,30 @@ export class FreebititService { const responseData = (await response.json()) as T; // Check for API-level errors (some endpoints return resultCode '101' with message 'OK') - const rc = String((responseData as any)?.resultCode ?? ""); - const statusObj: any = (responseData as any)?.status ?? {}; - const errorMessage = String((statusObj?.message ?? (responseData as any)?.message ?? "Unknown error")); - const statusCodeStr = String(statusObj?.statusCode ?? (responseData as any)?.statusCode ?? ""); + const rc = String( + (responseData as { resultCode?: string | number } | undefined)?.resultCode ?? "" + ); + const statusObj = + ( + responseData as + | { status?: { message?: string; statusCode?: string | number } } + | undefined + )?.status ?? {}; + const errorMessage = String( + (statusObj as { message?: string }).message ?? + (responseData as { message?: string } | undefined)?.message ?? + "Unknown error" + ); + const statusCodeStr = String( + (statusObj as { statusCode?: string | number }).statusCode ?? + (responseData as { statusCode?: string | number } | undefined)?.statusCode ?? + "" + ); const msgUpper = errorMessage.toUpperCase(); const isOkByRc = rc === "100" || rc === "101"; const isOkByMessage = msgUpper === "OK" || msgUpper === "SUCCESS"; const isOkByStatus = statusCodeStr === "200"; if (!(isOkByRc || isOkByMessage || isOkByStatus)) { - // Provide more specific error messages for common cases let userFriendlyMessage = `API Error: ${errorMessage}`; if (errorMessage === "NG") { @@ -211,12 +233,7 @@ export class FreebititService { userFriendlyMessage, }); - throw new FreebititErrorImpl( - userFriendlyMessage, - rc, - statusCodeStr, - errorMessage - ); + throw new FreebititErrorImpl(userFriendlyMessage, rc, statusCodeStr, errorMessage); } this.logger.debug("Freebit API Request Success", { @@ -237,18 +254,21 @@ export class FreebititService { } // Make authenticated JSON POST request (for endpoints that require JSON body) - private async makeAuthenticatedJsonRequest(endpoint: string, body: Record): Promise { + private async makeAuthenticatedJsonRequest( + endpoint: string, + body: Record + ): Promise { const authKey = await this.getAuthKey(); const url = `${this.config.baseUrl}${endpoint}`; const payload = { ...body, authKey }; const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!response.ok) { const text = await response.text().catch(() => null); - this.logger.error('Freebit JSON API non-OK', { + this.logger.error("Freebit JSON API non-OK", { endpoint, status: response.status, statusText: response.statusText, @@ -256,19 +276,29 @@ export class FreebititService { }); throw new InternalServerErrorException(`HTTP ${response.status}: ${response.statusText}`); } - const data = (await response.json()) as T; - const rc = String((data as any)?.resultCode ?? ''); - if (rc !== '100') { - const message = (data as any)?.message || (data as any)?.status?.message || 'Unknown error'; - this.logger.error('Freebit JSON API error response', { + const data = (await response.json()) as T & { + resultCode?: string | number; + message?: string; + status?: { message?: string; statusCode?: string | number }; + statusCode?: string | number; + }; + const rc = String(data?.resultCode ?? ""); + if (rc !== "100") { + const message = data?.message || data?.status?.message || "Unknown error"; + this.logger.error("Freebit JSON API error response", { endpoint, resultCode: rc, - statusCode: (data as any)?.statusCode || (data as any)?.status?.statusCode, + statusCode: data?.statusCode || data?.status?.statusCode, message, }); - throw new FreebititErrorImpl(`API Error: ${message}`, rc, String((data as any)?.statusCode || ''), message); + throw new FreebititErrorImpl( + `API Error: ${message}`, + rc, + String(data?.statusCode || ""), + message + ); } - this.logger.debug('Freebit JSON API Request Success', { endpoint, resultCode: rc }); + this.logger.debug("Freebit JSON API Request Success", { endpoint, resultCode: rc }); return data; } @@ -391,8 +421,13 @@ export class FreebititService { simType: simData.eid ? "esim" : "physical", size: ((): "standard" | "nano" | "micro" | "esim" => { const sizeVal = String(simData.size ?? "").toLowerCase(); - if (sizeVal === "standard" || sizeVal === "nano" || sizeVal === "micro" || sizeVal === "esim") { - return sizeVal as "standard" | "nano" | "micro" | "esim"; + if ( + sizeVal === "standard" || + sizeVal === "nano" || + sizeVal === "micro" || + sizeVal === "esim" + ) { + return sizeVal; } return simData.eid ? "esim" : "nano"; })(), @@ -645,44 +680,48 @@ export class FreebititService { ): Promise { try { const doVoice = - typeof features.voiceMailEnabled === 'boolean' || - typeof features.callWaitingEnabled === 'boolean' || - typeof features.internationalRoamingEnabled === 'boolean'; - const doContract = typeof features.networkType === 'string'; + typeof features.voiceMailEnabled === "boolean" || + typeof features.callWaitingEnabled === "boolean" || + typeof features.internationalRoamingEnabled === "boolean"; + const doContract = typeof features.networkType === "string"; if (doVoice) { - const talkOption: any = {}; - if (typeof features.voiceMailEnabled === 'boolean') { - talkOption.voiceMail = features.voiceMailEnabled ? '10' : '20'; + const talkOption: { + voiceMail?: "10" | "20"; + callWaiting?: "10" | "20"; + worldWing?: "10" | "20"; + } = {}; + if (typeof features.voiceMailEnabled === "boolean") { + talkOption.voiceMail = features.voiceMailEnabled ? "10" : "20"; } - if (typeof features.callWaitingEnabled === 'boolean') { - talkOption.callWaiting = features.callWaitingEnabled ? '10' : '20'; + if (typeof features.callWaitingEnabled === "boolean") { + talkOption.callWaiting = features.callWaitingEnabled ? "10" : "20"; } - if (typeof features.internationalRoamingEnabled === 'boolean') { - talkOption.worldWing = features.internationalRoamingEnabled ? '10' : '20'; + if (typeof features.internationalRoamingEnabled === "boolean") { + talkOption.worldWing = features.internationalRoamingEnabled ? "10" : "20"; } - await this.makeAuthenticatedRequest( - '/mvno/talkoption/changeOrder/', + await this.makeAuthenticatedRequest( + "/mvno/talkoption/changeOrder/", { account, - userConfirmed: '10', - aladinOperated: '10', + userConfirmed: "10", + aladinOperated: "10", talkOption, } ); - this.logger.log('Applied voice option change (PA05-06)', { account, talkOption }); + this.logger.log("Applied voice option change (PA05-06)", { account, talkOption }); } if (doContract && features.networkType) { // Contract line change endpoint expects form-encoded payload (json=...) - await this.makeAuthenticatedRequest( - '/mvno/contractline/change/', + await this.makeAuthenticatedRequest( + "/mvno/contractline/change/", { account, contractLine: features.networkType, } ); - this.logger.log('Applied contract line change (PA05-38)', { + this.logger.log("Applied contract line change (PA05-38)", { account, contractLine: features.networkType, }); @@ -697,7 +736,10 @@ export class FreebititService { }); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to update SIM features for account ${account}`, { error: message, account }); + this.logger.error(`Failed to update SIM features for account ${account}`, { + error: message, + account, + }); throw error as Error; } } @@ -707,15 +749,12 @@ export class FreebititService { */ async cancelSim(account: string, scheduledAt?: string): Promise { try { - const req: Omit = { - kind: 'MVNO', + const req: Omit = { + kind: "MVNO", account, runDate: scheduledAt, }; - await this.makeAuthenticatedRequest( - '/master/cnclAcnt/', - req - ); + await this.makeAuthenticatedRequest("/master/cnclAcnt/", req); this.logger.log(`Successfully requested cancellation (PA02-04) for account ${account}`, { account, runDate: scheduledAt, @@ -863,6 +902,70 @@ export class FreebititService { } } + /** + * Activate a new eSIM account via PA05-41 addAcct (JSON API) + * This supports optional scheduling (shipDate) and MNP payload. + */ + async activateEsimAccountNew(params: { + account: string; // MSISDN to be activated (required by Freebit) + eid: string; // 32-digit EID + planCode?: string; + contractLine?: "4G" | "5G"; + aladinOperated?: "10" | "20"; + shipDate?: string; // YYYYMMDD; if provided we send as scheduled activation date + mnp?: { reserveNumber: string; reserveExpireDate: string }; + identity?: { + firstnameKanji?: string; + lastnameKanji?: string; + firstnameZenKana?: string; + lastnameZenKana?: string; + gender?: string; + birthday?: string; + }; + }): Promise { + const { + account, + eid, + planCode, + contractLine, + aladinOperated = "10", + shipDate, + mnp, + identity, + } = params; + + if (!account || !eid) { + throw new BadRequestException("activateEsimAccountNew requires account and eid"); + } + + const payload: FreebititEsimAccountActivationRequest = { + authKey: await this.getAuthKey(), + aladinOperated, + createType: "new", + eid, + account, + simkind: "esim", + planCode, + contractLine, + shipDate, + ...(mnp ? { mnp } : {}), + ...(identity ? identity : {}), + } as FreebititEsimAccountActivationRequest; + + await this.makeAuthenticatedJsonRequest( + "/mvno/esim/addAcct/", + payload as unknown as Record + ); + + this.logger.log("Activated new eSIM account via PA05-41", { + account, + planCode, + contractLine, + scheduled: !!shipDate, + mnp: !!mnp, + }); + } + /** * Health check for Freebit API */ diff --git a/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts b/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts index 72763923..c580524e 100644 --- a/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts +++ b/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts @@ -191,15 +191,15 @@ export interface FreebititPlanChangeResponse { export interface FreebititVoiceOptionChangeRequest { authKey: string; account: string; - userConfirmed: '10' | '20'; - aladinOperated: '10' | '20'; + userConfirmed: "10" | "20"; + aladinOperated: "10" | "20"; talkOption: { - voiceMail?: '10' | '20'; - callWaiting?: '10' | '20'; - worldWing?: '10' | '20'; - worldCall?: '10' | '20'; - callTransfer?: '10' | '20'; - callTransferNoId?: '10' | '20'; + voiceMail?: "10" | "20"; + callWaiting?: "10" | "20"; + worldWing?: "10" | "20"; + worldCall?: "10" | "20"; + callTransfer?: "10" | "20"; + callTransferNoId?: "10" | "20"; worldCallCreditLimit?: string; worldWingCreditLimit?: string; }; @@ -217,7 +217,7 @@ export interface FreebititVoiceOptionChangeResponse { export interface FreebititContractLineChangeRequest { authKey: string; account: string; - contractLine: '4G' | '5G'; + contractLine: "4G" | "5G"; productNumber?: string; eid?: string; } diff --git a/apps/portal/src/app/checkout/page.tsx b/apps/portal/src/app/checkout/page.tsx index 74199944..b4841ac2 100644 --- a/apps/portal/src/app/checkout/page.tsx +++ b/apps/portal/src/app/checkout/page.tsx @@ -242,6 +242,59 @@ function CheckoutContent() { ...(Object.keys(configurations).length > 0 && { configurations }), }; + if (orderType === "SIM") { + // Validate required SIM fields + if (!selections.eid && selections.simType === "eSIM") { + throw new Error("EID is required for eSIM activation. Please go back and provide your EID."); + } + if (!selections.phoneNumber && !selections.mnpPhone) { + throw new Error("Phone number is required for SIM activation. Please go back and provide a phone number."); + } + + // Build activation payload for new SIM endpoint + const activationPayload: { + planSku: string; + simType: "eSIM" | "Physical SIM"; + eid?: string; + activationType: "Immediate" | "Scheduled"; + scheduledAt?: string; + msisdn: string; + oneTimeAmountJpy: number; + monthlyAmountJpy: number; + addons?: { voiceMail?: boolean; callWaiting?: boolean }; + mnp?: { reserveNumber: string; reserveExpireDate: string }; + } = { + planSku: selections.plan, + simType: selections.simType as "eSIM" | "Physical SIM", + eid: selections.eid, + activationType: selections.activationType as "Immediate" | "Scheduled", + scheduledAt: selections.scheduledAt, + msisdn: selections.phoneNumber || selections.mnpPhone || "", + oneTimeAmountJpy: checkoutState.totals.oneTimeTotal, // Activation fee charged immediately + monthlyAmountJpy: checkoutState.totals.monthlyTotal, // Monthly subscription fee + addons: { + voiceMail: (new URLSearchParams(window.location.search).getAll("addonSku") || []).some( + sku => sku.toLowerCase().includes("voicemail") + ), + callWaiting: ( + new URLSearchParams(window.location.search).getAll("addonSku") || [] + ).some(sku => sku.toLowerCase().includes("waiting")), + }, + }; + if (selections.isMnp === "true") { + activationPayload.mnp = { + reserveNumber: selections.reservationNumber, + reserveExpireDate: selections.expiryDate, + }; + } + const result = await authenticatedApi.post<{ success: boolean }>( + "/subscriptions/sim/orders/activate", + activationPayload + ); + router.push(`/orders?status=${result.success ? "success" : "error"}`); + return; + } + const response = await authenticatedApi.post<{ sfOrderId: string }>("/orders", orderData); router.push(`/orders/${response.sfOrderId}?status=success`); } catch (error) { 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 2cf584f2..b1556c37 100644 --- a/apps/portal/src/app/subscriptions/[id]/sim/cancel/page.tsx +++ b/apps/portal/src/app/subscriptions/[id]/sim/cancel/page.tsx @@ -31,9 +31,11 @@ export default function SimCancelPage() { useEffect(() => { const fetchDetails = async () => { try { - const d = await authenticatedApi.get(`/subscriptions/${subscriptionId}/sim/details`); + const d = await authenticatedApi.get( + `/subscriptions/${subscriptionId}/sim/details` + ); setDetails(d); - } catch (e: any) { + } catch (e: unknown) { setError(e instanceof Error ? e.message : "Failed to load SIM details"); } }; @@ -69,9 +71,11 @@ export default function SimCancelPage() { const canProceedStep2 = !!details; const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailProvided = email.trim().length > 0 || email2.trim().length > 0; - const emailValid = !emailProvided || (emailPattern.test(email.trim()) && emailPattern.test(email2.trim())); + const emailValid = + !emailProvided || (emailPattern.test(email.trim()) && emailPattern.test(email2.trim())); const emailsMatch = !emailProvided || email.trim() === email2.trim(); - const canProceedStep3 = acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch; + const canProceedStep3 = + acceptTerms && !!cancelMonth && confirmMonthEnd && emailValid && emailsMatch; const runDate = cancelMonth ? `${cancelMonth}01` : undefined; // YYYYMM01 const submit = async () => { @@ -84,7 +88,7 @@ export default function SimCancelPage() { }); setMessage("Cancellation request submitted. You will receive a confirmation email."); setTimeout(() => router.push(`/subscriptions/${subscriptionId}#sim-management`), 1500); - } catch (e: any) { + } catch (e: unknown) { setError(e instanceof Error ? e.message : "Failed to submit cancellation"); } finally { setLoading(false); @@ -95,7 +99,12 @@ export default function SimCancelPage() {
- ← Back to SIM Management + + ← Back to SIM Management +
Step {step} of 3
@@ -103,7 +112,9 @@ export default function SimCancelPage() {
{error}
)} {message && ( -
{message}
+
+ {message} +
)}
@@ -111,18 +122,37 @@ export default function SimCancelPage() { {step === 1 && (
-

You are about to cancel your SIM subscription. Please review the details below and click Next to continue.

+

+ You are about to cancel your SIM subscription. Please review the details below and + click Next to continue. +

- +
-
Minimum contract period is 3 billing months (not including the free first month).
+
+ Minimum contract period is 3 billing months (not including the free first month). +
- Return - + + Return + +
)} @@ -131,31 +161,48 @@ export default function SimCancelPage() {
- Online cancellations must be made from this website by the 25th of the desired cancellation month. -Once a request of a cancellation of the SONIXNET SIM is accepted from this online form, a confirmation email containing details of the SIM plan will be sent to the registered email address. -The SIM card is a rental piece of hardware and must be returned to Assist Solutions upon cancellation. -The cancellation request through this website retains to your SIM subscriptions only. To cancel any other services with Assist Solutions (home internet etc.) please contact Assist Solutions at info@asolutions.co.jp + Online cancellations must be made from this website by the 25th of the desired + cancellation month. Once a request of a cancellation of the SONIXNET SIM is + accepted from this online form, a confirmation email containing details of the SIM + plan will be sent to the registered email address. The SIM card is a rental piece + of hardware and must be returned to Assist Solutions upon cancellation. The + cancellation request through this website retains to your SIM subscriptions only. + To cancel any other services with Assist Solutions (home internet etc.) please + contact Assist Solutions at info@asolutions.co.jp - The SONIXNET SIM has a minimum contract term agreement of three months (sign-up month is not included in the minimum term of three months; ie. sign-up in January = minimum term is February, March, April). - If the minimum contract term is not fulfilled, the monthly fees of the remaining months will be charged upon cancellation. + The SONIXNET SIM has a minimum contract term agreement of three months (sign-up + month is not included in the minimum term of three months; ie. sign-up in January + = minimum term is February, March, April). If the minimum contract term is not + fulfilled, the monthly fees of the remaining months will be charged upon + cancellation. - Cancellation of option services only (Voice Mail, Call Waiting) while keeping the base plan active is not possible from this online form. Please contact Assist Solutions Customer Support (info@asolutions.co.jp) for more information. - Upon cancelling the base plan, all additional options associated with the requested SIM plan will be cancelled. + Cancellation of option services only (Voice Mail, Call Waiting) while keeping the + base plan active is not possible from this online form. Please contact Assist + Solutions Customer Support (info@asolutions.co.jp) for more information. Upon + cancelling the base plan, all additional options associated with the requested SIM + plan will be cancelled. - Upon cancellation the SIM phone number will be lost. In order to keep the phone number active to be used with a different cellular provider, a request for an MNP transfer (administrative fee \1,000yen+tax) is necessary. The MNP cannot be requested from this online form. Please contact Assist Solutions Customer Support (info@asolutions.co.jp) for more information. - 4 + Upon cancellation the SIM phone number will be lost. In order to keep the phone + number active to be used with a different cellular provider, a request for an MNP + transfer (administrative fee \1,000yen+tax) is necessary. The MNP cannot be + requested from this online form. Please contact Assist Solutions Customer Support + (info@asolutions.co.jp) for more information. + + 4
- + -

Cancellation takes effect at the start of the selected month.

+

+ Cancellation takes effect at the start of the selected month. +

- setAcceptTerms(e.target.checked)} /> - + setAcceptTerms(e.target.checked)} + /> +
setConfirmMonthEnd(e.target.checked)} + onChange={e => setConfirmMonthEnd(e.target.checked)} disabled={!cancelMonth} />
- - + +
)} @@ -196,54 +266,94 @@ The cancellation request through this website retains to your SIM subscriptions {step === 3 && (
- Calling charges are post payment. Your bill for the final month's calling charges will be charged on your credit card on file during the first week of the second month after the cancellation. If you would like to make the payment with a different credit card, please contact Assist Solutions at - {" "} - info@asolutions.co.jp. + Calling charges are post payment. Your bill for the final month's calling + charges will be charged on your credit card on file during the first week of the + second month after the cancellation. If you would like to make the payment with a + different credit card, please contact Assist Solutions at{" "} + + info@asolutions.co.jp + + . {registeredEmail && (
- Your registered email address is: {registeredEmail} + Your registered email address is:{" "} + {registeredEmail}
)}
- You will receive a cancellation confirmation email. If you would like to receive this email on a different address, please enter the address below. + You will receive a cancellation confirmation email. If you would like to receive + this email on a different address, please enter the address below.
- setEmail(e.target.value)} placeholder="you@example.com" /> + setEmail(e.target.value)} + placeholder="you@example.com" + />
- setEmail2(e.target.value)} placeholder="you@example.com" /> + setEmail2(e.target.value)} + placeholder="you@example.com" + />
- -