import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.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"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { SimActivationException, OrderValidationException, } from "@bff/core/exceptions/domain-exceptions.js"; import type { FulfillmentConfigurations } from "./fulfillment-context-mapper.service.js"; /** * Contact identity data for PA05-05 voice option registration */ export interface ContactIdentityData { firstnameKanji: string; lastnameKanji: string; firstnameKana: string; lastnameKana: string; gender: "M" | "F"; birthday: string; // YYYYMMDD format } /** * Assignment details for Physical SIM inventory */ export interface SimAssignmentDetails { /** Salesforce Account ID to assign the SIM to */ accountId?: string; /** Salesforce Order ID that assigned the SIM */ orderId?: string; /** SIM Type (eSIM or Physical SIM) */ simType?: string; } export interface SimFulfillmentRequest { orderDetails: OrderDetails; configurations: FulfillmentConfigurations; /** Salesforce ID of the assigned Physical SIM (from Assign_Physical_SIM__c) */ assignedPhysicalSimId?: string; /** Voice Mail enabled from Order.SIM_Voice_Mail__c */ voiceMailEnabled?: boolean; /** Call Waiting enabled from Order.SIM_Call_Waiting__c */ callWaitingEnabled?: boolean; /** Contact identity data for PA05-05 */ contactIdentity?: ContactIdentityData; /** Assignment details for SIM Inventory record (Physical SIM only) */ assignmentDetails?: SimAssignmentDetails; } /** * Result from SIM fulfillment containing inventory data for WHMCS */ export interface SimFulfillmentResult { /** Whether the SIM was successfully activated */ activated: boolean; /** SIM type that was activated */ simType: "eSIM" | "Physical SIM"; /** Phone number from SIM inventory (for WHMCS custom fields) */ phoneNumber?: string; /** PT Number / Serial number from SIM inventory (for WHMCS custom fields) */ serialNumber?: string; /** Salesforce SIM Inventory ID */ simInventoryId?: string; /** EID for eSIM (for WHMCS custom fields) */ eid?: string; } /** * MNP configuration extracted from Salesforce order/porting fields */ interface MnpConfig { reserveNumber?: string; reserveExpireDate?: string; account?: string; firstnameKanji?: string; lastnameKanji?: string; firstnameZenKana?: string; lastnameZenKana?: string; gender?: string; birthday?: string; } /** * Map Salesforce gender value to Freebit gender code. * Salesforce picklist: "Male", "Female", "Corporate/Other" (or legacy "M", "F") * Freebit codes: "M" (Male), "W" (Weiblich/Female), "C" (Corporation) */ function mapGenderToFreebit(gender: string): string { const normalized = gender.trim().toLowerCase(); if (normalized === "female" || normalized === "f") return "W"; if (normalized === "male" || normalized === "m") return "M"; if (normalized.startsWith("corporate") || normalized === "c") return "C"; return gender; } @Injectable() export class SimFulfillmentService { constructor( private readonly freebitFacade: FreebitFacade, private readonly simInventory: SalesforceSIMInventoryService, @Inject(Logger) private readonly logger: Logger ) {} async fulfillSimOrder(request: SimFulfillmentRequest): Promise { const { orderDetails, configurations, assignedPhysicalSimId, voiceMailEnabled = false, callWaitingEnabled = false, contactIdentity, assignmentDetails, } = request; const simType = this.readEnum(configurations["simType"], ["eSIM", "Physical SIM"]); this.logger.log("Starting SIM fulfillment", { orderId: orderDetails.id, orderType: orderDetails.orderType, simType: simType ?? "(not set)", hasAssignedPhysicalSim: !!assignedPhysicalSimId, voiceMailEnabled, callWaitingEnabled, hasContactIdentity: !!contactIdentity, }); // Validate SIM type is explicitly set - don't default to eSIM if (!simType) { throw new SimActivationException( "SIM Type must be explicitly set to 'eSIM' or 'Physical SIM'", { orderId: orderDetails.id, configuredSimType: configurations["simType"], } ); } const eid = this.readString(configurations["eid"]); const activationType = this.readEnum(configurations["activationType"], ["Immediate", "Scheduled"]) ?? "Immediate"; const scheduledAt = this.readString(configurations["scheduledAt"]); const phoneNumber = this.readString(configurations["mnpPhone"]); const mnp = this.extractMnpConfig(configurations); const isMnp = !!mnp?.["reserveNumber"]; this.logger.log("MNP detection result", { orderId: orderDetails.id, isMnp, simType, mnpReserveNumber: mnp?.["reserveNumber"], mnpHasIdentity: !!(mnp?.["lastnameKanji"] || mnp?.["firstnameKanji"]), mnpGender: mnp?.["gender"], }); const simPlanItem = orderDetails.items.find( (item: OrderItemDetails) => item.product?.itemClass === "Plan" || item.product?.sku?.toLowerCase().includes("sim") ); if (!simPlanItem) { throw new OrderValidationException("No SIM plan found in order items", { orderId: orderDetails.id, }); } const planSku = simPlanItem.product?.sku; const planName = simPlanItem.product?.name; if (!planSku) { throw new OrderValidationException("SIM plan SKU not found", { orderId: orderDetails.id, itemId: simPlanItem.id, }); } if (simType === "eSIM") { return this.fulfillEsim({ orderDetails, eid, phoneNumber, planSku, planName, activationType, scheduledAt, mnp, }); } return this.fulfillPhysicalSim({ orderDetails, assignedPhysicalSimId, planSku, planName, voiceMailEnabled, callWaitingEnabled, contactIdentity, assignmentDetails, isMnp, mnp, }); } private async fulfillEsim(params: { orderDetails: OrderDetails; eid: string | undefined; phoneNumber: string | undefined; planSku: string; planName: string | undefined; activationType: "Immediate" | "Scheduled"; scheduledAt: string | undefined; mnp: MnpConfig | undefined; }): Promise { const { orderDetails, eid, phoneNumber, planSku, planName, activationType, scheduledAt, mnp } = params; if (!eid || eid.length < 15) { throw new SimActivationException("EID is required for eSIM and must be valid", { orderId: orderDetails.id, simType: "eSIM", eidLength: eid?.length, }); } if (!phoneNumber) { throw new SimActivationException("Phone number is required for eSIM activation", { orderId: orderDetails.id, }); } const planCode = mapProductToFreebitPlanCode(planSku, planName); if (!planCode) { throw new SimActivationException( `Unable to map product to Freebit plan code. SKU: ${planSku}, Name: ${planName}`, { orderId: orderDetails.id, planSku, planName } ); } await this.activateEsim({ account: phoneNumber, eid, planCode, activationType, ...(scheduledAt && { scheduledAt }), ...(mnp && { mnp }), }); this.logger.log("eSIM fulfillment completed successfully", { orderId: orderDetails.id, account: phoneNumber, planSku, }); return { activated: true, simType: "eSIM", phoneNumber, eid, }; } private async fulfillPhysicalSim(params: { orderDetails: OrderDetails; assignedPhysicalSimId: string | undefined; planSku: string; planName: string | undefined; voiceMailEnabled: boolean; callWaitingEnabled: boolean; contactIdentity: ContactIdentityData | undefined; assignmentDetails: SimAssignmentDetails | undefined; isMnp: boolean; mnp: MnpConfig | undefined; }): Promise { const { orderDetails, assignedPhysicalSimId, planSku, planName, voiceMailEnabled, callWaitingEnabled, contactIdentity, assignmentDetails, isMnp, mnp, } = params; if (!assignedPhysicalSimId) { throw new SimActivationException( "Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)", { orderId: orderDetails.id } ); } const simData = await this.activatePhysicalSim({ orderId: orderDetails.id, simInventoryId: assignedPhysicalSimId, planSku, planName, voiceMailEnabled, callWaitingEnabled, contactIdentity, assignmentDetails, isMnp, ...(mnp && { mnp }), }); this.logger.log("Physical SIM fulfillment completed successfully", { orderId: orderDetails.id, simInventoryId: assignedPhysicalSimId, planSku, voiceMailEnabled, callWaitingEnabled, phoneNumber: simData.phoneNumber, serialNumber: simData.serialNumber, }); return { activated: true, simType: "Physical SIM", phoneNumber: simData.phoneNumber, serialNumber: simData.serialNumber, simInventoryId: assignedPhysicalSimId, }; } /** * Activate eSIM via Freebit PA05-41 API */ private async activateEsim(params: { account: string; eid: string; planCode: string; activationType: "Immediate" | "Scheduled"; scheduledAt?: string; mnp?: MnpConfig; }): Promise { const { account, eid, planCode, activationType, scheduledAt, mnp } = params; const isMnp = !!mnp?.reserveNumber; this.logger.log("eSIM activation starting", { account, planCode, isMnp, addKind: isMnp ? "M" : "N", aladinOperated: isMnp ? "20" : "10", mnpReserveNumber: mnp?.reserveNumber, mnpHasIdentity: !!(mnp?.lastnameKanji || mnp?.firstnameKanji), mnpGender: mnp?.gender, }); try { // Build unified MNP object with both reservation and identity data (all Level 2 per PA05-41) const mnpPayload = isMnp ? this.buildMnpPayload(mnp) : undefined; const addKind = isMnp ? ("M" as const) : ("N" as const); const aladinOperated = isMnp ? ("20" as const) : ("10" as const); const pa0541Params = { account, eid, planCode, contractLine: "5G" as const, simKind: "E0" as const, addKind, aladinOperated, ...(activationType === "Scheduled" && scheduledAt && { shipDate: scheduledAt }), ...(mnpPayload && { mnp: mnpPayload }), }; this.logger.log("PA05-41 full request payload", { account: pa0541Params.account, eid: pa0541Params.eid, planCode: pa0541Params.planCode, contractLine: pa0541Params.contractLine, simKind: pa0541Params.simKind, addKind: pa0541Params.addKind, aladinOperated: pa0541Params.aladinOperated, shipDate: pa0541Params.shipDate ?? "not-set", hasMnpPayload: !!mnpPayload, mnpPayloadFields: mnpPayload ? Object.keys(mnpPayload) : [], mnpReserveNumber: mnpPayload?.reserveNumber ?? "not-set", mnpGenderMapped: mnpPayload?.gender ?? "not-set", }); await this.freebitFacade.activateEsimAccountNew(pa0541Params); this.logger.log("eSIM activated successfully via PA05-41", { account, planCode, isMnp, addKind: pa0541Params.addKind, scheduled: activationType === "Scheduled", }); } catch (error: unknown) { this.logger.error("eSIM activation failed via PA05-41", { account, planCode, isMnp, addKind: isMnp ? "M" : "N", mnpReserveNumber: mnp?.reserveNumber ?? "not-set", mnpFieldCount: mnp ? Object.keys(mnp).length : 0, error: extractErrorMessage(error), }); throw error; } } /** * Activate Physical SIM (Black SIM) via Freebit APIs * * Non-MNP flow: * 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) with createType="new" * 5. Call Freebit PA05-05 (Voice Options) to configure voice features * 6. Update SIM Inventory status to "Assigned" * * MNP flow: * 1-3. Same as above * 4. Call Freebit PA05-19 (Semi-Black MNP Registration) — replaces PA02-01 * 5. Call Freebit PA05-05 (Voice Options) * 6. Update SIM Inventory status to "Assigned" */ private async activatePhysicalSim(params: { orderId: string; simInventoryId: string; planSku: string; planName?: string | undefined; voiceMailEnabled: boolean; callWaitingEnabled: boolean; contactIdentity?: ContactIdentityData | undefined; assignmentDetails?: SimAssignmentDetails | undefined; isMnp?: boolean; mnp?: MnpConfig; }): Promise<{ phoneNumber: string; serialNumber: string }> { const { orderId, simInventoryId, planSku, planName, voiceMailEnabled, callWaitingEnabled, contactIdentity, assignmentDetails, isMnp = false, mnp, } = params; this.logger.log("Starting Physical SIM activation", { orderId, simInventoryId, planSku, isMnp, path: isMnp ? "PA05-19 (MNP)" : "PA02-01 (new)", voiceMailEnabled, callWaitingEnabled, hasContactIdentity: !!contactIdentity, mnpReserveNumber: mnp?.reserveNumber, }); // Step 1 & 2: Fetch and validate SIM Inventory const simRecord = await this.simInventory.getAndValidateForActivation(simInventoryId); // Step 3: Map product to Freebit plan code const planCode = mapProductToFreebitPlanCode(planSku, planName); if (!planCode) { throw new SimActivationException( `Unable to map product to Freebit plan code. SKU: ${planSku}, Name: ${planName}`, { orderId, simInventoryId, planSku, planName } ); } // Use phone number from SIM inventory const accountPhoneNumber = simRecord.phoneNumber; this.logger.log("Physical SIM inventory validated", { orderId, simInventoryId, accountPhoneNumber, ptNumber: simRecord.ptNumber, planCode, isMnp, }); try { if (isMnp) { // Step 4 (MNP): Call Freebit PA05-19 (Semi-Black MNP Registration) // PA05-19 replaces PA02-01 for MNP transfers — it registers the account // and initiates the MNP transfer in a single call this.logger.log("Calling PA05-19 Semi-Black MNP Registration", { orderId, account: accountPhoneNumber, productNumber: simRecord.ptNumber, planCode, mnpMethod: "10", }); await this.freebitFacade.registerSemiBlackAccount({ account: accountPhoneNumber, productNumber: simRecord.ptNumber, planCode, }); this.logger.log("PA05-19 Semi-Black MNP Registration successful", { orderId, account: accountPhoneNumber, }); } else { // Step 4 (non-MNP): Call Freebit PA02-01 (Account Registration) this.logger.log("Calling PA02-01 Account Registration", { orderId, account: accountPhoneNumber, planCode, createType: "new", }); await this.freebitFacade.registerAccount({ account: accountPhoneNumber, planCode, createType: "new", }); this.logger.log("PA02-01 Account Registration successful", { orderId, account: accountPhoneNumber, }); } // Step 5: Call Freebit PA05-05 (Voice Options Registration) await this.registerVoiceOptionsIfAvailable({ orderId, account: accountPhoneNumber, voiceMailEnabled, callWaitingEnabled, contactIdentity, }); // Step 6: Update SIM Inventory status to "Assigned" with assignment details await this.simInventory.markAsAssigned(simInventoryId, assignmentDetails); this.logger.log("Physical SIM activated successfully", { orderId, simInventoryId, accountPhoneNumber, planCode, voiceMailEnabled, callWaitingEnabled, }); // Return SIM data for WHMCS custom fields return { phoneNumber: simRecord.phoneNumber, serialNumber: simRecord.ptNumber, }; } catch (error: unknown) { this.logger.error("Physical SIM activation failed", { orderId, simInventoryId, phoneNumber: simRecord.phoneNumber, error: extractErrorMessage(error), }); throw error; } } private buildMnpPayload(mnp?: MnpConfig) { if (!mnp?.reserveNumber) return; return { reserveNumber: mnp.reserveNumber, ...(mnp.reserveExpireDate && { reserveExpireDate: mnp.reserveExpireDate }), ...(mnp.lastnameKanji && { lastnameKanji: mnp.lastnameKanji }), ...(mnp.firstnameKanji && { firstnameKanji: mnp.firstnameKanji }), ...(mnp.lastnameZenKana && { lastnameZenKana: mnp.lastnameZenKana }), ...(mnp.firstnameZenKana && { firstnameZenKana: mnp.firstnameZenKana }), // Map Salesforce gender 'F' → Freebit gender 'W' (Weiblich) ...(mnp.gender && { gender: mapGenderToFreebit(mnp.gender) }), ...(mnp.birthday && { birthday: mnp.birthday }), }; } private async registerVoiceOptionsIfAvailable(params: { orderId: string; account: string; voiceMailEnabled: boolean; callWaitingEnabled: boolean; contactIdentity?: ContactIdentityData | undefined; }): Promise { const { orderId, account, voiceMailEnabled, callWaitingEnabled, contactIdentity } = params; if (!contactIdentity) { this.logger.warn("Skipping PA05-05: No contact identity data provided", { orderId, account, }); return; } this.logger.log("Calling PA05-05 Voice Options Registration", { orderId, account, voiceMailEnabled, callWaitingEnabled, }); await this.freebitFacade.registerVoiceOptions({ account, voiceMailEnabled, callWaitingEnabled, identificationData: { lastnameKanji: contactIdentity.lastnameKanji, firstnameKanji: contactIdentity.firstnameKanji, lastnameKana: contactIdentity.lastnameKana, firstnameKana: contactIdentity.firstnameKana, gender: contactIdentity.gender, birthday: contactIdentity.birthday, }, }); this.logger.log("PA05-05 Voice Options Registration successful", { orderId, account, }); } private readString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } private readEnum(value: unknown, allowed: readonly T[]): T | undefined { return typeof value === "string" && allowed.includes(value as T) ? (value as T) : undefined; } private static readonly MNP_FIELD_MAPPINGS = [ { key: "reserveNumber", sources: ["mnpNumber", "reserveNumber"] }, { key: "reserveExpireDate", sources: ["mnpExpiry", "reserveExpireDate"] }, { key: "account", sources: ["mvnoAccountNumber", "account"] }, { key: "firstnameKanji", sources: ["portingFirstName", "firstnameKanji"] }, { key: "lastnameKanji", sources: ["portingLastName", "lastnameKanji"] }, { key: "firstnameZenKana", sources: ["portingFirstNameKatakana", "firstnameZenKana"] }, { key: "lastnameZenKana", sources: ["portingLastNameKatakana", "lastnameZenKana"] }, { key: "gender", sources: ["portingGender", "gender"] }, { key: "birthday", sources: ["portingDateOfBirth", "birthday"] }, ] as const; private extractMnpConfig(config: FulfillmentConfigurations) { const nested = config["mnp"]; const hasNestedMnp = nested && typeof nested === "object"; const source = hasNestedMnp ? nested : config; const isMnpFlag = this.readString(source["isMnp"] ?? config["isMnp"]); if (isMnpFlag && isMnpFlag.toLowerCase() !== "true") { this.logger.log("MNP extraction skipped: isMnp flag is not 'true'", { isMnpFlag, isMnpFlagType: typeof (source["isMnp"] ?? config["isMnp"]), }); return; } const result: Record = {}; for (const { key, sources } of SimFulfillmentService.MNP_FIELD_MAPPINGS) { const value = this.readString(source[sources[0]] ?? source[sources[1]]); if (value) result[key] = value; } if (Object.keys(result).length === 0) { this.logger.log("MNP extraction: no MNP fields found in config", { hasNestedMnp, isMnpFlag: isMnpFlag ?? "not-set", checkedKeys: SimFulfillmentService.MNP_FIELD_MAPPINGS.flatMap(m => m.sources), }); return; } this.logger.log("MNP config extracted", { hasReserveNumber: !!result["reserveNumber"], reserveNumberLength: result["reserveNumber"]?.length, hasReserveExpireDate: !!result["reserveExpireDate"], hasAccount: !!result["account"], hasIdentity: !!(result["firstnameKanji"] && result["lastnameKanji"]), hasKana: !!(result["firstnameZenKana"] && result["lastnameZenKana"]), gender: result["gender"] ?? "not-set", hasBirthday: !!result["birthday"], totalFieldsExtracted: Object.keys(result).length, }); return result; } }