From 9fbb6ed61eee49f1e5b6dda27c79d90219a885dc Mon Sep 17 00:00:00 2001 From: Temuulen Ankhbayar Date: Tue, 3 Feb 2026 11:02:15 +0900 Subject: [PATCH] Add PA05-18 semi-black registration before PA02-01 for physical SIM activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Physical SIM activation was failing with error 210 "アカウント不在エラー" (Account not found) because PA02-01 requires the SIM to be pre-registered in Freebit's system. This adds PA05-18 (Semi-Black Account Registration) as the first step before PA02-01. New flow: PA05-18 → PA02-01 → PA05-05 Co-Authored-By: Claude Opus 4.5 --- .../integrations/freebit/freebit.module.ts | 7 +- .../services/freebit-semiblack.service.ts | 234 ++++++++++++++++++ .../integrations/freebit/services/index.ts | 4 + .../order-fulfillment-orchestrator.service.ts | 19 +- .../services/sim-fulfillment.service.ts | 48 +++- 5 files changed, 290 insertions(+), 22 deletions(-) create mode 100644 apps/bff/src/integrations/freebit/services/freebit-semiblack.service.ts diff --git a/apps/bff/src/integrations/freebit/freebit.module.ts b/apps/bff/src/integrations/freebit/freebit.module.ts index 25163f39..ec322569 100644 --- a/apps/bff/src/integrations/freebit/freebit.module.ts +++ b/apps/bff/src/integrations/freebit/freebit.module.ts @@ -14,6 +14,7 @@ import { FreebitEsimService } from "./services/freebit-esim.service.js"; import { FreebitTestTrackerService } from "./services/freebit-test-tracker.service.js"; import { FreebitAccountRegistrationService } from "./services/freebit-account-registration.service.js"; import { FreebitVoiceOptionsService } from "./services/freebit-voice-options.service.js"; +import { FreebitSemiBlackService } from "./services/freebit-semiblack.service.js"; import { SimManagementModule } from "../../modules/subscriptions/sim-management/sim-management.module.js"; @Module({ @@ -32,7 +33,8 @@ import { SimManagementModule } from "../../modules/subscriptions/sim-management/ FreebitVoiceService, FreebitCancellationService, FreebitEsimService, - // Physical SIM activation services (PA02-01 + PA05-05) + // Physical SIM activation services (PA05-18 + PA02-01 + PA05-05) + FreebitSemiBlackService, FreebitAccountRegistrationService, FreebitVoiceOptionsService, // Facade (delegates to specialized services) @@ -51,7 +53,8 @@ import { SimManagementModule } from "../../modules/subscriptions/sim-management/ FreebitVoiceService, FreebitCancellationService, FreebitEsimService, - // Physical SIM activation services (PA02-01 + PA05-05) + // Physical SIM activation services (PA05-18 + PA02-01 + PA05-05) + FreebitSemiBlackService, FreebitAccountRegistrationService, FreebitVoiceOptionsService, // Rate limiter (needed by SimController) diff --git a/apps/bff/src/integrations/freebit/services/freebit-semiblack.service.ts b/apps/bff/src/integrations/freebit/services/freebit-semiblack.service.ts new file mode 100644 index 00000000..35a71d05 --- /dev/null +++ b/apps/bff/src/integrations/freebit/services/freebit-semiblack.service.ts @@ -0,0 +1,234 @@ +/** + * Freebit Semi-Black Account Registration Service (PA05-18) + * + * Handles MVNO semi-black (半黒) SIM registration via the Freebit PA05-18 API. + * This must be called BEFORE PA02-01 for physical SIMs to create the account + * in Freebit's system. + * + * Semi-black SIMs are pre-provisioned SIMs that need to be registered + * to associate them with a customer account and plan. + * + * Error 210 "アカウント不在エラー" on PA02-01 indicates that PA05-18 + * was not called first. + */ + +import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { extractErrorMessage } from "@bff/core/utils/error.util.js"; +import { FreebitClientService } from "./freebit-client.service.js"; + +/** + * PA05-18 Semi-Black Account Registration parameters + */ +export interface SemiBlackRegistrationParams { + /** MSISDN (phone number) - 11-14 digits */ + account: string; + /** Manufacturing number (productNumber) - 15 chars, e.g., AXxxxxxxxxxxxxx */ + productNumber: string; + /** Freebit plan code (e.g., "PASI_5G") */ + planCode: string; + /** Ship date in YYYYMMDD format (defaults to today) */ + shipDate?: string; + /** Create type: "new" for new master account, "add" for existing */ + createType?: "new" | "add"; + /** Global IP assignment: "20" = disabled (default) */ + globalIp?: "20"; + /** ALADIN operation flag: "10" = operated, "20" = not operated (default) */ + aladinOperated?: "10" | "20"; + /** Sales channel code (optional) */ + deliveryCode?: string; +} + +/** + * PA05-18 Request payload structure + */ +interface FreebitSemiBlackRequest { + authKey: string; + createType: "new" | "add"; + account: string; + productNumber: string; + planCode: string; + shipDate: string; + mnp: { + method: "10"; // "10" = Semi-black SIM + }; + globalIp?: string; + aladinOperated?: string; + deliveryCode?: string; +} + +/** + * PA05-18 Response structure + */ +interface FreebitSemiBlackResponse { + resultCode: number; + status: { + message: string; + statusCode: number; + }; +} + +@Injectable() +export class FreebitSemiBlackService { + constructor( + private readonly client: FreebitClientService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Register a semi-black SIM account (PA05-18) + * + * This registers a pre-provisioned (semi-black) physical SIM in Freebit's + * system. Must be called BEFORE PA02-01 account registration. + * + * @param params - Semi-black registration parameters + * @throws BadRequestException if registration fails + */ + async registerSemiBlackAccount(params: SemiBlackRegistrationParams): Promise { + const { + account, + productNumber, + planCode, + shipDate, + createType = "new", + globalIp = "20", + aladinOperated = "20", + deliveryCode, + } = params; + + // Validate phone number + if (!account || account.length < 11 || account.length > 14) { + throw new BadRequestException( + "Invalid phone number (account) for semi-black registration - must be 11-14 digits" + ); + } + + // Validate product number (manufacturing number) + if (!productNumber || productNumber.length !== 15) { + throw new BadRequestException( + "Invalid product number for semi-black registration - must be 15 characters (e.g., AXxxxxxxxxxxxxx)" + ); + } + + if (!planCode) { + throw new BadRequestException("Plan code is required for semi-black registration"); + } + + // Default to today's date if not provided + const effectiveShipDate = shipDate ?? this.formatTodayAsYYYYMMDD(); + + this.logger.log("Starting semi-black SIM registration (PA05-18)", { + account, + productNumber, + planCode, + shipDate: effectiveShipDate, + createType, + }); + + try { + const payload: Omit = { + createType, + account, + productNumber, + planCode, + shipDate: effectiveShipDate, + mnp: { + method: "10", // Semi-black SIM method + }, + globalIp, + aladinOperated, + ...(deliveryCode && { deliveryCode }), + }; + + const response = await this.client.makeAuthenticatedRequest< + FreebitSemiBlackResponse, + Omit + >("/mvno/semiblack/addAcnt/", payload); + + // Check response status + if (response.resultCode !== 100 || response.status?.statusCode !== 200) { + const errorCode = response.resultCode; + const errorMessage = this.getErrorMessage(errorCode, response.status?.message); + + this.logger.error("Semi-black registration failed (PA05-18)", { + account, + productNumber, + planCode, + resultCode: response.resultCode, + statusCode: response.status?.statusCode, + message: response.status?.message, + }); + + throw new BadRequestException( + `Semi-black registration failed: ${errorMessage} (code: ${errorCode})` + ); + } + + this.logger.log("Semi-black SIM registration successful (PA05-18)", { + account, + productNumber, + planCode, + }); + } catch (error: unknown) { + // Re-throw BadRequestException as-is + if (error instanceof BadRequestException) { + throw error; + } + + const message = extractErrorMessage(error); + this.logger.error("Semi-black registration failed (PA05-18)", { + account, + productNumber, + planCode, + error: message, + }); + throw new BadRequestException(`Semi-black registration failed: ${message}`); + } + } + + /** + * Format today's date as YYYYMMDD + */ + private formatTodayAsYYYYMMDD(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${year}${month}${day}`; + } + + /** + * Get human-readable error message for PA05-18 error codes + */ + private getErrorMessage(code: number, defaultMessage?: string): string { + const errorMessages: Record = { + 201: "Invalid account/phone number parameter", + 202: "Invalid master password", + 204: "Invalid parameter", + 205: "Authentication key error", + 208: "Account already exists (duplicate)", + 210: "Master account not found", + 211: "Account status does not allow this operation", + 215: "Invalid plan code", + 228: "Invalid authentication key", + 230: "Account is in async processing queue", + 231: "Invalid global IP parameter", + 232: "Plan not found", + 266: "Invalid product number (manufacturing number)", + 269: "Invalid representative number", + 274: "Invalid delivery code", + 275: "No phone number stock for representative number", + 276: "Invalid ship date", + 279: "Invalid create type", + 284: "Representative number is locked", + 287: "Representative number does not exist or is unavailable", + 288: "Product number does not exist, is already used, or not stocked", + 289: "SIM type does not match representative number type", + 306: "Invalid MNP method", + 313: "MNP reservation expires within grace period", + 900: "Unexpected system error", + }; + + return errorMessages[code] ?? defaultMessage ?? "Unknown error"; + } +} diff --git a/apps/bff/src/integrations/freebit/services/index.ts b/apps/bff/src/integrations/freebit/services/index.ts index 4f5413d7..b2389350 100644 --- a/apps/bff/src/integrations/freebit/services/index.ts +++ b/apps/bff/src/integrations/freebit/services/index.ts @@ -9,3 +9,7 @@ export { FreebitPlanService } from "./freebit-plan.service.js"; export { FreebitVoiceService } from "./freebit-voice.service.js"; export { FreebitCancellationService } from "./freebit-cancellation.service.js"; export { FreebitEsimService, type EsimActivationParams } from "./freebit-esim.service.js"; +export { + FreebitSemiBlackService, + type SemiBlackRegistrationParams, +} from "./freebit-semiblack.service.js"; diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index 48de90a0..9c4e3286 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -164,7 +164,7 @@ export class OrderFulfillmentOrchestrator { } // Step 3: Execute the main fulfillment workflow as a distributed transaction - // New flow: SIM activation (PA02-01 + PA05-05) → Activated status → WHMCS → Registration Completed + // New flow: SIM activation (PA05-18 + PA02-01 + PA05-05) → Activated status → WHMCS → Registration Completed let simFulfillmentResult: SimFulfillmentResult | undefined; let mappingResult: WhmcsOrderItemMappingResult | undefined; let whmcsCreateResult: WhmcsOrderResult | undefined; @@ -212,10 +212,10 @@ export class OrderFulfillmentOrchestrator { ), critical: false, }, - // SIM fulfillment now runs BEFORE WHMCS (PA02-01 + PA05-05) + // SIM fulfillment now runs BEFORE WHMCS (PA05-18 + PA02-01 + PA05-05) { id: "sim_fulfillment", - description: "SIM activation via Freebit (PA02-01 + PA05-05)", + description: "SIM activation via Freebit (PA05-18 + PA02-01 + PA05-05)", execute: this.createTrackedStep(context, "sim_fulfillment", async () => { if (context.orderDetails?.orderType === "SIM") { const sfOrder = context.validation?.sfOrder; @@ -264,10 +264,13 @@ export class OrderFulfillmentOrchestrator { rollback: () => { // SIM activation cannot be easily rolled back // Log for manual intervention if needed - this.logger.warn("SIM fulfillment step needs rollback - manual intervention may be required", { - sfOrderId, - simFulfillmentResult, - }); + this.logger.warn( + "SIM fulfillment step needs rollback - manual intervention may be required", + { + sfOrderId, + simFulfillmentResult, + } + ); return Promise.resolve(); }, critical: context.validation?.sfOrder?.SIM_Type__c === "Physical SIM", @@ -587,7 +590,7 @@ export class OrderFulfillmentOrchestrator { * 1. validation * 2. sf_status_update (Activating) * 3. order_details - * 4. sim_fulfillment (PA02-01 + PA05-05) - SIM orders only + * 4. sim_fulfillment (PA05-18 + PA02-01 + PA05-05) - SIM orders only * 5. sf_activated_update - SIM orders only * 6. mapping (with SIM data for WHMCS) * 7. whmcs_create diff --git a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts index 74c9b6b0..3074428d 100644 --- a/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts +++ b/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts @@ -3,6 +3,7 @@ import { Logger } from "nestjs-pino"; import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service.js"; import { FreebitAccountRegistrationService } from "@bff/integrations/freebit/services/freebit-account-registration.service.js"; import { FreebitVoiceOptionsService } from "@bff/integrations/freebit/services/freebit-voice-options.service.js"; +import { FreebitSemiBlackService } from "@bff/integrations/freebit/services/freebit-semiblack.service.js"; import { SalesforceSIMInventoryService } from "@bff/integrations/salesforce/services/salesforce-sim-inventory.service.js"; import type { OrderDetails, OrderItemDetails } from "@customer-portal/domain/orders"; import { mapProductToFreebitPlanCode } from "@customer-portal/domain/sim"; @@ -57,6 +58,7 @@ export interface SimFulfillmentResult { export class SimFulfillmentService { constructor( private readonly freebit: FreebitOrchestratorService, + private readonly freebitSemiBlack: FreebitSemiBlackService, private readonly freebitAccountReg: FreebitAccountRegistrationService, private readonly freebitVoiceOptions: FreebitVoiceOptionsService, private readonly simInventory: SalesforceSIMInventoryService, @@ -160,7 +162,7 @@ export class SimFulfillmentService { phoneNumber, }; } else { - // Physical SIM activation flow (PA02-01 + PA05-05) + // Physical SIM activation flow (PA05-18 + PA02-01 + PA05-05) if (!assignedPhysicalSimId) { throw new SimActivationException( "Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)", @@ -263,18 +265,19 @@ export class SimFulfillmentService { } /** - * Activate Physical SIM via Freebit PA02-01 + PA05-05 APIs + * Activate Physical SIM via Freebit PA05-18 + PA02-01 + PA05-05 APIs * - * New Flow (replaces PA05-33 OTA): + * Flow for Semi-Black Physical SIMs: * 1. Fetch SIM Inventory details from Salesforce * 2. Validate SIM status is "Available" * 3. Map product SKU to Freebit plan code - * 4. Call Freebit PA02-01 (Account Registration) to create MVNO account - * 5. Call Freebit PA05-05 (Voice Options) to configure voice features - * 6. Update SIM Inventory status to "In Use" + * 4. Call Freebit PA05-18 (Semi-Black Registration) to register the SIM + * 5. Call Freebit PA02-01 (Account Registration) to create MVNO account + * 6. Call Freebit PA05-05 (Voice Options) to configure voice features + * 7. Update SIM Inventory status to "In Use" * - * Note: PA02-01 is asynchronous and may take up to 10 minutes to process. - * The account must be fully registered before PA05-05 can be called. + * Note: PA05-18 must be called before PA02-01, otherwise PA02-01 returns + * error 210 "アカウント不在エラー" (Account not found). */ private async activatePhysicalSim(params: { orderId: string; @@ -295,7 +298,7 @@ export class SimFulfillmentService { contactIdentity, } = params; - this.logger.log("Starting Physical SIM activation (PA02-01 + PA05-05)", { + this.logger.log("Starting Physical SIM activation (PA05-18 + PA02-01 + PA05-05)", { orderId, simInventoryId, planSku, @@ -328,7 +331,28 @@ export class SimFulfillmentService { }); try { - // Step 4: Call Freebit PA02-01 (Account Registration) + // Step 4: Call Freebit PA05-18 (Semi-Black Registration) + // This registers the semi-black SIM in Freebit's system + // Must be called BEFORE PA02-01 or we get error 210 "アカウント不在エラー" + this.logger.log("Calling PA05-18 Semi-Black Registration", { + orderId, + account: accountPhoneNumber, + productNumber: simRecord.ptNumber, + planCode, + }); + + await this.freebitSemiBlack.registerSemiBlackAccount({ + account: accountPhoneNumber, + productNumber: simRecord.ptNumber, + planCode, + }); + + this.logger.log("PA05-18 Semi-Black Registration successful", { + orderId, + account: accountPhoneNumber, + }); + + // Step 5: Call Freebit PA02-01 (Account Registration) this.logger.log("Calling PA02-01 Account Registration", { orderId, account: accountPhoneNumber, @@ -345,7 +369,7 @@ export class SimFulfillmentService { account: accountPhoneNumber, }); - // Step 5: Call Freebit PA05-05 (Voice Options Registration) + // Step 6: Call Freebit PA05-05 (Voice Options Registration) // Only call if we have contact identity data if (contactIdentity) { this.logger.log("Calling PA05-05 Voice Options Registration", { @@ -380,7 +404,7 @@ export class SimFulfillmentService { }); } - // Step 6: Update SIM Inventory status to "In Use" + // Step 7: Update SIM Inventory status to "In Use" await this.simInventory.markAsInUse(simInventoryId); this.logger.log("Physical SIM activated successfully", {