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 * Semi-black SIMs are pre-provisioned physical SIMs that need to be registered
* to associate them with a customer account and plan. * 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: { async registerSemiBlackAccount(params: {
account: string; account: string;

View File

@ -319,15 +319,15 @@ export interface FreebitEsimAccountActivationRequest {
oldEid?: string; // Row 14: 元eSIM識別番号 (Conditional - for exchange) oldEid?: string; // Row 14: 元eSIM識別番号 (Conditional - for exchange)
mnp?: { mnp?: {
// Row 15: MNP情報 (Conditional) // Row 15: MNP情報 (Conditional)
reserveNumber: string; // Row 16: MNP予約番号 (Conditional) reserveNumber: string; // Row 16: MNP予約番号 (Level 2)
reserveExpireDate?: string; // (Conditional) YYYYMMDD 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) shipDate?: string; // Row 23: 出荷日 YYYYMMDD (Conditional)
planCode?: string; // Row 24: プランコード (Max 32 chars) (Conditional) planCode?: string; // Row 24: プランコード (Max 32 chars) (Conditional)
deliveryCode?: string; // Row 25: 顧客コード (Max 10 chars) (Conditional - OEM specific) deliveryCode?: string; // Row 25: 顧客コード (Max 10 chars) (Conditional - OEM specific)

View File

@ -24,13 +24,14 @@ export interface EsimActivationParams {
repAccount?: string; repAccount?: string;
deliveryCode?: string; deliveryCode?: string;
globalIp?: "10" | "20"; globalIp?: "10" | "20";
mnp?: { reserveNumber: string; reserveExpireDate?: string }; mnp?: {
identity?: { reserveNumber: string;
firstnameKanji?: string; reserveExpireDate?: string;
lastnameKanji?: string; lastnameKanji?: string;
firstnameZenKana?: string; firstnameKanji?: string;
lastnameZenKana?: string; lastnameZenKana?: string;
gender?: string; firstnameZenKana?: string;
gender?: string; // Freebit uses 'M'/'W'/'C' (caller should map 'F' → 'W')
birthday?: string; birthday?: string;
}; };
} }
@ -128,7 +129,6 @@ export class FreebitEsimService {
deliveryCode, deliveryCode,
globalIp, globalIp,
mnp, mnp,
identity,
} = params; } = params;
if (!account || !eid) { 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 { try {
const payload: FreebitEsimAccountActivationRequest = { const payload: FreebitEsimAccountActivationRequest = {
authKey: await this.auth.getAuthKey(), authKey: await this.auth.getAuthKey(),
@ -159,10 +172,19 @@ export class FreebitEsimService {
repAccount, repAccount,
deliveryCode, deliveryCode,
globalIp, globalIp,
// MNP object includes both reservation info AND identity fields (all Level 2 per PA05-41 spec)
...(mnp ? { mnp } : {}), ...(mnp ? { mnp } : {}),
...(identity ? identity : {}),
} as FreebitEsimAccountActivationRequest; } 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< await this.client.makeAuthenticatedJsonRequest<
FreebitEsimAccountActivationResponse, FreebitEsimAccountActivationResponse,
FreebitEsimAccountActivationRequest FreebitEsimAccountActivationRequest
@ -172,9 +194,9 @@ export class FreebitEsimService {
account, account,
planCode, planCode,
contractLine, contractLine,
addKind: addKind || "N", addKind: finalAddKind,
scheduled: !!shipDate, scheduled: !!shipDate,
mnp: !!mnp, isMnp: finalAddKind === "M",
}); });
} catch (error) { } catch (error) {
const message = extractErrorMessage(error); const message = extractErrorMessage(error);
@ -182,7 +204,9 @@ export class FreebitEsimService {
account, account,
eid, eid,
planCode, planCode,
addKind, addKind: finalAddKind,
aladinOperated,
isMnp: finalAddKind === "M",
error: message, error: message,
}); });
throw new BadRequestException(`Failed to activate new eSIM account: ${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. * Handles MVNO semi-black () SIM registration via the Freebit PA05-19 API.
* This must be called BEFORE PA02-01 for physical SIMs to create the account * For MNP transfers, this replaces PA02-01. For non-MNP, PA02-01 is used instead.
* in Freebit's system.
* *
* Semi-black SIMs are pre-provisioned SIMs that need to be registered * Semi-black SIMs are pre-provisioned SIMs that need to be registered
* to associate them with a customer account and plan. * 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 { 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"; import { FreebitClientService } from "./freebit-client.service.js";
/** /**
* PA05-18 Semi-Black Account Registration parameters * PA05-19 Semi-Black Account Registration parameters
*/ */
export interface SemiBlackRegistrationParams { export interface SemiBlackRegistrationParams {
/** MSISDN (phone number) - 11-14 digits */ /** 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 { interface FreebitSemiBlackRequest {
authKey: string; authKey: string;
@ -58,7 +54,7 @@ interface FreebitSemiBlackRequest {
} }
/** /**
* PA05-18 Response structure * PA05-19 Response structure
*/ */
interface FreebitSemiBlackResponse { interface FreebitSemiBlackResponse {
resultCode: number; 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 * 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 * @param params - Semi-black registration parameters
* @throws BadRequestException if registration fails * @throws BadRequestException if registration fails
@ -117,7 +113,7 @@ export class FreebitSemiBlackService {
// Default to today's date if not provided // Default to today's date if not provided
const effectiveShipDate = shipDate ?? this.formatTodayAsYYYYMMDD(); 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, account,
productNumber, productNumber,
planCode, planCode,
@ -146,7 +142,7 @@ export class FreebitSemiBlackService {
Omit<FreebitSemiBlackRequest, "authKey"> Omit<FreebitSemiBlackRequest, "authKey">
>("/mvno/semiblack/addAcnt/", payload); >("/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, account,
productNumber, productNumber,
planCode, planCode,
@ -158,7 +154,7 @@ export class FreebitSemiBlackService {
} }
const message = extractErrorMessage(error); const message = extractErrorMessage(error);
this.logger.error("Semi-black registration failed (PA05-18)", { this.logger.error("Semi-black registration failed (PA05-19)", {
account, account,
productNumber, productNumber,
planCode, 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( async executeSimFulfillment(
ctx: OrderFulfillmentContext, ctx: OrderFulfillmentContext,

View File

@ -37,7 +37,7 @@ export class FulfillmentStepFactory {
* Step order: * Step order:
* 1. sf_status_update (Activating) * 1. sf_status_update (Activating)
* 2. order_details (retain in context) * 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) * 4. sf_activated_update (SIM orders only)
* 5. mapping (with SIM data for WHMCS) * 5. mapping (with SIM data for WHMCS)
* 6. whmcs_create * 6. whmcs_create
@ -111,7 +111,7 @@ export class FulfillmentStepFactory {
): DistributedStep { ): DistributedStep {
return { return {
id: "sim_fulfillment", 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 () => { execute: this.createTrackedStep(ctx, "sim_fulfillment", async () => {
const result = await this.executors.executeSimFulfillment(ctx, payload); const result = await this.executors.executeSimFulfillment(ctx, payload);
state.simFulfillmentResult = result; state.simFulfillmentResult = result;

View File

@ -67,6 +67,31 @@ export interface SimFulfillmentResult {
eid?: string; 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() @Injectable()
export class SimFulfillmentService { export class SimFulfillmentService {
constructor( constructor(
@ -115,6 +140,16 @@ export class SimFulfillmentService {
const scheduledAt = this.readString(configurations["scheduledAt"]); const scheduledAt = this.readString(configurations["scheduledAt"]);
const phoneNumber = this.readString(configurations["mnpPhone"]); const phoneNumber = this.readString(configurations["mnpPhone"]);
const mnp = this.extractMnpConfig(configurations); 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( const simPlanItem = orderDetails.items.find(
(item: OrderItemDetails) => (item: OrderItemDetails) =>
@ -174,7 +209,9 @@ export class SimFulfillmentService {
eid, eid,
}; };
} else { } 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) { if (!assignedPhysicalSimId) {
throw new SimActivationException( throw new SimActivationException(
"Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)", "Physical SIM requires an assigned SIM from inventory (Assign_Physical_SIM__c)",
@ -191,6 +228,8 @@ export class SimFulfillmentService {
callWaitingEnabled, callWaitingEnabled,
contactIdentity, contactIdentity,
assignmentDetails, assignmentDetails,
isMnp,
...(mnp && { mnp }),
}); });
this.logger.log("Physical SIM fulfillment completed successfully", { this.logger.log("Physical SIM fulfillment completed successfully", {
@ -222,55 +261,62 @@ export class SimFulfillmentService {
planSku: string; planSku: string;
activationType: "Immediate" | "Scheduled"; activationType: "Immediate" | "Scheduled";
scheduledAt?: string; scheduledAt?: string;
mnp?: { mnp?: MnpConfig;
reserveNumber?: string;
reserveExpireDate?: string;
account?: string;
firstnameKanji?: string;
lastnameKanji?: string;
firstnameZenKana?: string;
lastnameZenKana?: string;
gender?: string;
birthday?: string;
};
}): Promise<void> { }): Promise<void> {
const { account, eid, planSku, activationType, scheduledAt, mnp } = params; 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 { 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({ await this.freebitFacade.activateEsimAccountNew({
account, account,
eid, eid,
planCode: planSku, planCode: planSku,
contractLine: "5G", 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 }), ...(activationType === "Scheduled" && scheduledAt && { shipDate: scheduledAt }),
...(mnp?.reserveNumber && ...(mnpPayload && { mnp: mnpPayload }),
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 }),
},
}),
}); });
this.logger.log("eSIM activated successfully", { this.logger.log("eSIM activated successfully", {
account, account,
planSku, planSku,
isMnp,
scheduled: activationType === "Scheduled", scheduled: activationType === "Scheduled",
}); });
} catch (error: unknown) { } catch (error: unknown) {
this.logger.error("eSIM activation failed", { this.logger.error("eSIM activation failed", {
account, account,
planSku, planSku,
isMnp,
error: extractErrorMessage(error), error: extractErrorMessage(error),
}); });
throw 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 * 1. Fetch SIM Inventory details from Salesforce
* 2. Validate SIM status is "Available" * 2. Validate SIM status is "Available"
* 3. Map product SKU to Freebit plan code * 3. Map product SKU to Freebit plan code
* 4. Call Freebit PA02-01 (Account Registration) with createType="new" * 4. Call Freebit PA02-01 (Account Registration) with createType="new"
* 5. Call Freebit PA05-05 (Voice Options) to configure voice features * 5. Call Freebit PA05-05 (Voice Options) to configure voice features
* 6. Update SIM Inventory status to "Assigned" * 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: { private async activatePhysicalSim(params: {
orderId: string; orderId: string;
@ -297,6 +349,8 @@ export class SimFulfillmentService {
callWaitingEnabled: boolean; callWaitingEnabled: boolean;
contactIdentity?: ContactIdentityData | undefined; contactIdentity?: ContactIdentityData | undefined;
assignmentDetails?: SimAssignmentDetails | undefined; assignmentDetails?: SimAssignmentDetails | undefined;
isMnp?: boolean;
mnp?: MnpConfig;
}): Promise<{ phoneNumber: string; serialNumber: string }> { }): Promise<{ phoneNumber: string; serialNumber: string }> {
const { const {
orderId, orderId,
@ -307,15 +361,20 @@ export class SimFulfillmentService {
callWaitingEnabled, callWaitingEnabled,
contactIdentity, contactIdentity,
assignmentDetails, assignmentDetails,
isMnp = false,
mnp,
} = params; } = params;
this.logger.log("Starting Physical SIM activation (PA02-01 + PA05-05)", { this.logger.log("Starting Physical SIM activation", {
orderId, orderId,
simInventoryId, simInventoryId,
planSku, planSku,
isMnp,
path: isMnp ? "PA05-19 (MNP)" : "PA02-01 (new)",
voiceMailEnabled, voiceMailEnabled,
callWaitingEnabled, callWaitingEnabled,
hasContactIdentity: !!contactIdentity, hasContactIdentity: !!contactIdentity,
mnpReserveNumber: mnp?.reserveNumber,
}); });
// Step 1 & 2: Fetch and validate SIM Inventory // Step 1 & 2: Fetch and validate SIM Inventory
@ -337,11 +396,36 @@ export class SimFulfillmentService {
orderId, orderId,
simInventoryId, simInventoryId,
accountPhoneNumber, accountPhoneNumber,
ptNumber: simRecord.ptNumber,
planCode, planCode,
isMnp,
}); });
try { try {
// Step 4: Call Freebit PA02-01 (Account Registration) for Black SIM 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", { this.logger.log("Calling PA02-01 Account Registration", {
orderId, orderId,
account: accountPhoneNumber, account: accountPhoneNumber,
@ -359,6 +443,7 @@ export class SimFulfillmentService {
orderId, orderId,
account: accountPhoneNumber, account: accountPhoneNumber,
}); });
}
// Step 5: Call Freebit PA05-05 (Voice Options Registration) // Step 5: Call Freebit PA05-05 (Voice Options Registration)
// Only call if we have contact identity data // Only call if we have contact identity data