feat: add MNP support for Physical SIM (PA05-19) and fix eSIM MNP bugs (PA05-41)

Physical SIM: route MNP orders through PA05-19 (semi-black registration)
instead of PA02-01. eSIM: fix PA05-41 payload — move identity fields into
mnp object (Level 2 nesting per spec), set addKind="M" and aladinOperated="20"
for MNP, map Salesforce gender "F" to Freebit "W", and pass simKind="E0".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Temuulen Ankhbayar 2026-02-17 18:04:13 +09:00
parent df017d520f
commit 891d3aa099
7 changed files with 191 additions and 89 deletions

View File

@ -208,14 +208,11 @@ export class FreebitFacade {
// ============================================================================
/**
* Register a semi-black SIM account (PA05-18)
* Register a semi-black SIM account (PA05-19)
*
* This MUST be called BEFORE PA02-01 for physical SIMs.
* For MNP transfers on physical SIMs, this replaces PA02-01.
* Semi-black SIMs are pre-provisioned physical 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.
*/
async registerSemiBlackAccount(params: {
account: string;

View File

@ -319,15 +319,15 @@ export interface FreebitEsimAccountActivationRequest {
oldEid?: string; // Row 14: 元eSIM識別番号 (Conditional - for exchange)
mnp?: {
// Row 15: MNP情報 (Conditional)
reserveNumber: string; // Row 16: MNP予約番号 (Conditional)
reserveExpireDate?: string; // (Conditional) YYYYMMDD
reserveNumber: string; // Row 16: MNP予約番号 (Level 2)
reserveExpireDate?: string; // (Level 2) YYYYMMDD
lastnameKanji?: string; // Row 17: 名前(漢字) (Level 2)
firstnameKanji?: string; // Row 18: 由字(漢字) (Level 2)
lastnameZenKana?: string; // Row 19: 名前(全角カタカナ) (Level 2)
firstnameZenKana?: string; // Row 20: 由字(全角カタカナ) (Level 2)
gender?: string; // Row 21: 性別 ('M'/'W'/'C') (Level 2)
birthday?: string; // Row 22: 生年月日 YYYYMMDD (Level 2)
};
firstnameKanji?: string; // Row 17: 由字(漢字) (Conditional)
lastnameKanji?: string; // Row 18: 名前(漢字) (Conditional)
firstnameZenKana?: string; // Row 19: 由字(全角カタカナ) (Conditional)
lastnameZenKana?: string; // Row 20: 名前(全角カタカナ) (Conditional)
gender?: string; // Row 21: 性別 ('M', 'F') (Required for identification)
birthday?: string; // Row 22: 生年月日 YYYYMMDD (Conditional)
shipDate?: string; // Row 23: 出荷日 YYYYMMDD (Conditional)
planCode?: string; // Row 24: プランコード (Max 32 chars) (Conditional)
deliveryCode?: string; // Row 25: 顧客コード (Max 10 chars) (Conditional - OEM specific)

View File

@ -24,13 +24,14 @@ export interface EsimActivationParams {
repAccount?: string;
deliveryCode?: string;
globalIp?: "10" | "20";
mnp?: { reserveNumber: string; reserveExpireDate?: string };
identity?: {
firstnameKanji?: string;
mnp?: {
reserveNumber: string;
reserveExpireDate?: string;
lastnameKanji?: string;
firstnameZenKana?: string;
firstnameKanji?: string;
lastnameZenKana?: string;
gender?: string;
firstnameZenKana?: string;
gender?: string; // Freebit uses 'M'/'W'/'C' (caller should map 'F' → 'W')
birthday?: string;
};
}
@ -128,7 +129,6 @@ export class FreebitEsimService {
deliveryCode,
globalIp,
mnp,
identity,
} = params;
if (!account || !eid) {
@ -144,6 +144,19 @@ export class FreebitEsimService {
);
}
this.logger.log("PA05-41 preparing eSIM activation payload", {
account,
addKind: finalAddKind,
simKind: simKind || "E0",
aladinOperated,
hasMnp: !!mnp,
hasReserveNumber: !!mnp?.reserveNumber,
hasIdentity: !!(mnp?.lastnameKanji || mnp?.firstnameKanji),
gender: mnp?.gender,
planCode,
contractLine,
});
try {
const payload: FreebitEsimAccountActivationRequest = {
authKey: await this.auth.getAuthKey(),
@ -159,10 +172,19 @@ export class FreebitEsimService {
repAccount,
deliveryCode,
globalIp,
// MNP object includes both reservation info AND identity fields (all Level 2 per PA05-41 spec)
...(mnp ? { mnp } : {}),
...(identity ? identity : {}),
} as FreebitEsimAccountActivationRequest;
this.logger.log("PA05-41 sending request", {
account,
addKind: finalAddKind,
aladinOperated,
mnpReserveNumber: mnp?.reserveNumber,
mnpHasIdentity: !!(mnp?.lastnameKanji || mnp?.firstnameKanji),
mnpGender: mnp?.gender,
});
await this.client.makeAuthenticatedJsonRequest<
FreebitEsimAccountActivationResponse,
FreebitEsimAccountActivationRequest
@ -172,9 +194,9 @@ export class FreebitEsimService {
account,
planCode,
contractLine,
addKind: addKind || "N",
addKind: finalAddKind,
scheduled: !!shipDate,
mnp: !!mnp,
isMnp: finalAddKind === "M",
});
} catch (error) {
const message = extractErrorMessage(error);
@ -182,7 +204,9 @@ export class FreebitEsimService {
account,
eid,
planCode,
addKind,
addKind: finalAddKind,
aladinOperated,
isMnp: finalAddKind === "M",
error: message,
});
throw new BadRequestException(`Failed to activate new eSIM account: ${message}`);

View File

@ -1,15 +1,11 @@
/**
* Freebit Semi-Black Account Registration Service (PA05-18)
* Freebit Semi-Black Account Registration Service (PA05-19)
*
* 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.
* Handles MVNO semi-black () SIM registration via the Freebit PA05-19 API.
* For MNP transfers, this replaces PA02-01. For non-MNP, PA02-01 is used instead.
*
* 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";
@ -18,7 +14,7 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { FreebitClientService } from "./freebit-client.service.js";
/**
* PA05-18 Semi-Black Account Registration parameters
* PA05-19 Semi-Black Account Registration parameters
*/
export interface SemiBlackRegistrationParams {
/** MSISDN (phone number) - 11-14 digits */
@ -40,7 +36,7 @@ export interface SemiBlackRegistrationParams {
}
/**
* PA05-18 Request payload structure
* PA05-19 Request payload structure
*/
interface FreebitSemiBlackRequest {
authKey: string;
@ -58,7 +54,7 @@ interface FreebitSemiBlackRequest {
}
/**
* PA05-18 Response structure
* PA05-19 Response structure
*/
interface FreebitSemiBlackResponse {
resultCode: number;
@ -76,10 +72,10 @@ export class FreebitSemiBlackService {
) {}
/**
* Register a semi-black SIM account (PA05-18)
* Register a semi-black SIM account (PA05-19)
*
* This registers a pre-provisioned (semi-black) physical SIM in Freebit's
* system. Must be called BEFORE PA02-01 account registration.
* system. For MNP transfers, this replaces PA02-01.
*
* @param params - Semi-black registration parameters
* @throws BadRequestException if registration fails
@ -117,7 +113,7 @@ export class FreebitSemiBlackService {
// Default to today's date if not provided
const effectiveShipDate = shipDate ?? this.formatTodayAsYYYYMMDD();
this.logger.log("Starting semi-black SIM registration (PA05-18)", {
this.logger.log("Starting semi-black SIM registration (PA05-19)", {
account,
productNumber,
planCode,
@ -146,7 +142,7 @@ export class FreebitSemiBlackService {
Omit<FreebitSemiBlackRequest, "authKey">
>("/mvno/semiblack/addAcnt/", payload);
this.logger.log("Semi-black SIM registration successful (PA05-18)", {
this.logger.log("Semi-black SIM registration successful (PA05-19)", {
account,
productNumber,
planCode,
@ -158,7 +154,7 @@ export class FreebitSemiBlackService {
}
const message = extractErrorMessage(error);
this.logger.error("Semi-black registration failed (PA05-18)", {
this.logger.error("Semi-black registration failed (PA05-19)", {
account,
productNumber,
planCode,

View File

@ -64,7 +64,7 @@ export class FulfillmentStepExecutors {
}
/**
* SIM fulfillment via Freebit (PA05-18 + PA02-01 + PA05-05)
* SIM fulfillment via Freebit (PA05-19/PA02-01 + PA05-05)
*/
async executeSimFulfillment(
ctx: OrderFulfillmentContext,

View File

@ -37,7 +37,7 @@ export class FulfillmentStepFactory {
* Step order:
* 1. sf_status_update (Activating)
* 2. order_details (retain in context)
* 3. sim_fulfillment (SIM orders only - PA05-18 + PA02-01 + PA05-05)
* 3. sim_fulfillment (SIM orders only - PA05-19/PA02-01 + PA05-05)
* 4. sf_activated_update (SIM orders only)
* 5. mapping (with SIM data for WHMCS)
* 6. whmcs_create
@ -111,7 +111,7 @@ export class FulfillmentStepFactory {
): DistributedStep {
return {
id: "sim_fulfillment",
description: "SIM activation via Freebit (PA05-18 + PA02-01 + PA05-05)",
description: "SIM activation via Freebit (PA05-19/PA02-01 + PA05-05)",
execute: this.createTrackedStep(ctx, "sim_fulfillment", async () => {
const result = await this.executors.executeSimFulfillment(ctx, payload);
state.simFulfillmentResult = result;

View File

@ -67,6 +67,31 @@ export interface SimFulfillmentResult {
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 code to Freebit gender code.
* Salesforce: 'M' (Male), 'F' (Female)
* Freebit: 'M' (Male), 'W' (Weiblich/Female), 'C' (Corporation)
*/
function mapGenderToFreebit(gender: string): string {
if (gender === "F") return "W";
return gender;
}
@Injectable()
export class SimFulfillmentService {
constructor(
@ -115,6 +140,16 @@ export class SimFulfillmentService {
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) =>
@ -174,7 +209,9 @@ export class SimFulfillmentService {
eid,
};
} else {
// Physical SIM activation flow (PA05-18 + PA02-01 + PA05-05)
// Physical SIM activation flow:
// Non-MNP: PA02-01 + PA05-05
// MNP: PA05-19 + PA05-05
if (!assignedPhysicalSimId) {
throw new SimActivationException(
"Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)",
@ -191,6 +228,8 @@ export class SimFulfillmentService {
callWaitingEnabled,
contactIdentity,
assignmentDetails,
isMnp,
...(mnp && { mnp }),
});
this.logger.log("Physical SIM fulfillment completed successfully", {
@ -222,55 +261,62 @@ export class SimFulfillmentService {
planSku: string;
activationType: "Immediate" | "Scheduled";
scheduledAt?: string;
mnp?: {
reserveNumber?: string;
reserveExpireDate?: string;
account?: string;
firstnameKanji?: string;
lastnameKanji?: string;
firstnameZenKana?: string;
lastnameZenKana?: string;
gender?: string;
birthday?: string;
};
mnp?: MnpConfig;
}): Promise<void> {
const { account, eid, planSku, activationType, scheduledAt, mnp } = params;
const isMnp = !!mnp?.reserveNumber;
this.logger.log("eSIM activation starting", {
account,
planSku,
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 && mnp?.reserveNumber
? {
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 }),
}
: undefined;
await this.freebitFacade.activateEsimAccountNew({
account,
eid,
planCode: planSku,
contractLine: "5G",
simKind: "E0", // Voice eSIM
addKind: isMnp ? "M" : "N",
aladinOperated: isMnp ? "20" : "10", // '20' = we provide identity for ALADIN
...(activationType === "Scheduled" && scheduledAt && { shipDate: scheduledAt }),
...(mnp?.reserveNumber &&
mnp?.reserveExpireDate && {
mnp: {
reserveNumber: mnp.reserveNumber,
reserveExpireDate: mnp.reserveExpireDate,
},
}),
...(mnp && {
identity: {
...(mnp.firstnameKanji && { firstnameKanji: mnp.firstnameKanji }),
...(mnp.lastnameKanji && { lastnameKanji: mnp.lastnameKanji }),
...(mnp.firstnameZenKana && { firstnameZenKana: mnp.firstnameZenKana }),
...(mnp.lastnameZenKana && { lastnameZenKana: mnp.lastnameZenKana }),
...(mnp.gender && { gender: mnp.gender }),
...(mnp.birthday && { birthday: mnp.birthday }),
},
}),
...(mnpPayload && { mnp: mnpPayload }),
});
this.logger.log("eSIM activated successfully", {
account,
planSku,
isMnp,
scheduled: activationType === "Scheduled",
});
} catch (error: unknown) {
this.logger.error("eSIM activation failed", {
account,
planSku,
isMnp,
error: extractErrorMessage(error),
});
throw error;
@ -278,15 +324,21 @@ export class SimFulfillmentService {
}
/**
* Activate Physical SIM (Black SIM) via Freebit PA02-01 + PA05-05 APIs
* Activate Physical SIM (Black SIM) via Freebit APIs
*
* Flow for Physical SIMs (Black SIMs):
* 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;
@ -297,6 +349,8 @@ export class SimFulfillmentService {
callWaitingEnabled: boolean;
contactIdentity?: ContactIdentityData | undefined;
assignmentDetails?: SimAssignmentDetails | undefined;
isMnp?: boolean;
mnp?: MnpConfig;
}): Promise<{ phoneNumber: string; serialNumber: string }> {
const {
orderId,
@ -307,15 +361,20 @@ export class SimFulfillmentService {
callWaitingEnabled,
contactIdentity,
assignmentDetails,
isMnp = false,
mnp,
} = params;
this.logger.log("Starting Physical SIM activation (PA02-01 + PA05-05)", {
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
@ -337,28 +396,54 @@ export class SimFulfillmentService {
orderId,
simInventoryId,
accountPhoneNumber,
ptNumber: simRecord.ptNumber,
planCode,
isMnp,
});
try {
// Step 4: Call Freebit PA02-01 (Account Registration) for Black SIM
this.logger.log("Calling PA02-01 Account Registration", {
orderId,
account: accountPhoneNumber,
planCode,
createType: "new",
});
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.registerAccount({
account: accountPhoneNumber,
planCode,
createType: "new",
});
await this.freebitFacade.registerSemiBlackAccount({
account: accountPhoneNumber,
productNumber: simRecord.ptNumber,
planCode,
});
this.logger.log("PA02-01 Account Registration successful", {
orderId,
account: accountPhoneNumber,
});
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)
// Only call if we have contact identity data