Assist_Design/apps/bff/src/modules/orders/services/sim-fulfillment.service.ts

690 lines
22 KiB
TypeScript
Raw Normal View History

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<SimFulfillmentResult> {
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<SimFulfillmentResult> {
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<SimFulfillmentResult> {
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<void> {
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<void> {
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<T extends string>(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<string, string> = {};
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;
}
}