fix: comprehensive SIM audit fixes and MNP debug logging
Address critical, high, and medium issues found during SIM management audit: Critical: fix eSIM plan code mapping (SKU→PASI), PA05-41 endpoint typo, PA05-05 gender mapping (F→W) and katakana field names. High: fix double authKey injection, add MNP/porting fields to SF getOrder SOQL, add reissue params to eSIM addAcnt, remove console.error debug stmt. Medium: fix KB/MB conversion (1000→1024), birthday UTC timezone bug, plan code regex matching "5G" as 5GB, case-insensitive isMnp flag, domain schema enums (addKind +M, simkind E0/E2/E3), move identity into mnp Level 2. Frontend: fix SVG donut radius mismatch (r=88→96), fix FreebitError typo. Add comprehensive MNP debug logging across the entire data flow pipeline: SF order extraction, config mapping, MNP field parsing, API payload assembly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
be09c78491
commit
5e5bff12da
@ -287,6 +287,8 @@ export interface FreebitEsimAddAccountRequest {
|
|||||||
shipDate?: string;
|
shipDate?: string;
|
||||||
planCode?: string;
|
planCode?: string;
|
||||||
contractLine?: string;
|
contractLine?: string;
|
||||||
|
oldProductNumber?: string; // Required for physical SIM → eSIM reissue
|
||||||
|
oldEid?: string; // Required for eSIM → eSIM reissue
|
||||||
mnp?: {
|
mnp?: {
|
||||||
reserveNumber: string;
|
reserveNumber: string;
|
||||||
reserveExpireDate: string;
|
reserveExpireDate: string;
|
||||||
@ -349,7 +351,7 @@ export interface FreebitEsimAccountActivationResponse {
|
|||||||
export interface FreebitError extends Error {
|
export interface FreebitError extends Error {
|
||||||
resultCode: string;
|
resultCode: string;
|
||||||
statusCode: string | number;
|
statusCode: string | number;
|
||||||
freebititMessage: string;
|
freebitMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
|||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
import { FreebitClientService } from "./freebit-client.service.js";
|
import { FreebitClientService } from "./freebit-client.service.js";
|
||||||
import { FreebitAuthService } from "./freebit-auth.service.js";
|
|
||||||
import type {
|
import type {
|
||||||
FreebitEsimReissueRequest,
|
FreebitEsimReissueRequest,
|
||||||
FreebitEsimReissueResponse,
|
FreebitEsimReissueResponse,
|
||||||
@ -44,7 +43,6 @@ export interface EsimActivationParams {
|
|||||||
export class FreebitEsimService {
|
export class FreebitEsimService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly client: FreebitClientService,
|
private readonly client: FreebitClientService,
|
||||||
private readonly auth: FreebitAuthService,
|
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -88,6 +86,8 @@ export class FreebitEsimService {
|
|||||||
eid: newEid,
|
eid: newEid,
|
||||||
addKind: "R",
|
addKind: "R",
|
||||||
...(options.planCode !== undefined && { planCode: options.planCode }),
|
...(options.planCode !== undefined && { planCode: options.planCode }),
|
||||||
|
...(options.oldProductNumber && { oldProductNumber: options.oldProductNumber }),
|
||||||
|
...(options.oldEid && { oldEid: options.oldEid }),
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.client.makeAuthenticatedRequest<FreebitEsimAddAccountResponse, typeof request>(
|
await this.client.makeAuthenticatedRequest<FreebitEsimAddAccountResponse, typeof request>(
|
||||||
@ -158,10 +158,11 @@ export class FreebitEsimService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload: FreebitEsimAccountActivationRequest = {
|
// Note: authKey is injected by makeAuthenticatedJsonRequest
|
||||||
authKey: await this.auth.getAuthKey(),
|
// createType is not required when addKind is 'R' (reissue)
|
||||||
|
const payload: Omit<FreebitEsimAccountActivationRequest, "authKey"> = {
|
||||||
aladinOperated,
|
aladinOperated,
|
||||||
createType: "new",
|
...(finalAddKind !== "R" && { createType: "new" }),
|
||||||
eid,
|
eid,
|
||||||
account,
|
account,
|
||||||
simkind: simKind || "E0",
|
simkind: simKind || "E0",
|
||||||
@ -174,21 +175,39 @@ export class FreebitEsimService {
|
|||||||
globalIp,
|
globalIp,
|
||||||
// MNP object includes both reservation info AND identity fields (all Level 2 per PA05-41 spec)
|
// MNP object includes both reservation info AND identity fields (all Level 2 per PA05-41 spec)
|
||||||
...(mnp ? { mnp } : {}),
|
...(mnp ? { mnp } : {}),
|
||||||
} as FreebitEsimAccountActivationRequest;
|
} as Omit<FreebitEsimAccountActivationRequest, "authKey">;
|
||||||
|
|
||||||
this.logger.log("PA05-41 sending request", {
|
// Log full payload details (excluding authKey) for MNP debugging
|
||||||
|
this.logger.log("PA05-41 final payload to Freebit API", {
|
||||||
account,
|
account,
|
||||||
|
eid,
|
||||||
addKind: finalAddKind,
|
addKind: finalAddKind,
|
||||||
|
simkind: simKind || "E0",
|
||||||
aladinOperated,
|
aladinOperated,
|
||||||
mnpReserveNumber: mnp?.reserveNumber,
|
createType: finalAddKind === "R" ? "omitted" : "new",
|
||||||
mnpHasIdentity: !!(mnp?.lastnameKanji || mnp?.firstnameKanji),
|
planCode,
|
||||||
mnpGender: mnp?.gender,
|
contractLine,
|
||||||
|
shipDate: shipDate ?? "not-set",
|
||||||
|
hasMnpObject: !!mnp,
|
||||||
|
mnpFields: mnp
|
||||||
|
? {
|
||||||
|
reserveNumber: mnp.reserveNumber ?? "MISSING",
|
||||||
|
reserveExpireDate: mnp.reserveExpireDate ?? "not-set",
|
||||||
|
lastnameKanji: mnp.lastnameKanji ? "present" : "missing",
|
||||||
|
firstnameKanji: mnp.firstnameKanji ? "present" : "missing",
|
||||||
|
lastnameZenKana: mnp.lastnameZenKana ? "present" : "missing",
|
||||||
|
firstnameZenKana: mnp.firstnameZenKana ? "present" : "missing",
|
||||||
|
gender: mnp.gender ?? "not-set",
|
||||||
|
birthday: mnp.birthday ? "present" : "missing",
|
||||||
|
totalFields: Object.keys(mnp).length,
|
||||||
|
}
|
||||||
|
: "no-mnp",
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.client.makeAuthenticatedJsonRequest<
|
await this.client.makeAuthenticatedJsonRequest<
|
||||||
FreebitEsimAccountActivationResponse,
|
FreebitEsimAccountActivationResponse,
|
||||||
FreebitEsimAccountActivationRequest
|
Omit<FreebitEsimAccountActivationRequest, "authKey">
|
||||||
>("/mvno/esim/addAcct/", payload);
|
>("/mvno/esim/addAcnt/", payload);
|
||||||
|
|
||||||
this.logger.log("Successfully activated new eSIM account via PA05-41", {
|
this.logger.log("Successfully activated new eSIM account via PA05-41", {
|
||||||
account,
|
account,
|
||||||
@ -207,6 +226,8 @@ export class FreebitEsimService {
|
|||||||
addKind: finalAddKind,
|
addKind: finalAddKind,
|
||||||
aladinOperated,
|
aladinOperated,
|
||||||
isMnp: finalAddKind === "M",
|
isMnp: finalAddKind === "M",
|
||||||
|
hasMnpReserveNumber: !!mnp?.reserveNumber,
|
||||||
|
mnpFieldCount: mnp ? Object.keys(mnp).length : 0,
|
||||||
error: message,
|
error: message,
|
||||||
});
|
});
|
||||||
throw new BadRequestException(`Failed to activate new eSIM account: ${message}`);
|
throw new BadRequestException(`Failed to activate new eSIM account: ${message}`);
|
||||||
|
|||||||
@ -160,15 +160,15 @@ export class FreebitMapperService {
|
|||||||
if (account.remainingQuotaMb != null) {
|
if (account.remainingQuotaMb != null) {
|
||||||
// If API explicitly provides remainingQuotaMb, use it directly
|
// If API explicitly provides remainingQuotaMb, use it directly
|
||||||
remainingQuotaMb = Number(account.remainingQuotaMb);
|
remainingQuotaMb = Number(account.remainingQuotaMb);
|
||||||
remainingQuotaKb = Number(account.remainingQuotaKb ?? remainingQuotaMb * 1000);
|
remainingQuotaKb = Number(account.remainingQuotaKb ?? remainingQuotaMb * 1024);
|
||||||
} else if (account.quota != null) {
|
} else if (account.quota != null) {
|
||||||
// If only quota is provided, it's in KB - convert to MB
|
// If only quota is provided, it's in KB - convert to MB (1 MB = 1024 KB)
|
||||||
remainingQuotaKb = Number(account.quota);
|
remainingQuotaKb = Number(account.quota);
|
||||||
remainingQuotaMb = remainingQuotaKb / 1000;
|
remainingQuotaMb = remainingQuotaKb / 1024;
|
||||||
} else if (account.remainingQuotaKb != null) {
|
} else if (account.remainingQuotaKb != null) {
|
||||||
// If only remainingQuotaKb is provided, convert to MB
|
// If only remainingQuotaKb is provided, convert to MB (1 MB = 1024 KB)
|
||||||
remainingQuotaKb = Number(account.remainingQuotaKb);
|
remainingQuotaKb = Number(account.remainingQuotaKb);
|
||||||
remainingQuotaMb = remainingQuotaKb / 1000;
|
remainingQuotaMb = remainingQuotaKb / 1024;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log raw account data in dev to debug MSISDN availability
|
// Log raw account data in dev to debug MSISDN availability
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export interface VoiceOptionIdentityData {
|
|||||||
lastnameKana: string;
|
lastnameKana: string;
|
||||||
/** First name in Katakana (full-width, max 50 chars) */
|
/** First name in Katakana (full-width, max 50 chars) */
|
||||||
firstnameKana: string;
|
firstnameKana: string;
|
||||||
/** Gender: "M" = Male, "F" = Female */
|
/** Gender: "M" = Male, "F" = Female (mapped to Freebit "W" on send) */
|
||||||
gender: "M" | "F";
|
gender: "M" | "F";
|
||||||
/** Birthday in YYYYMMDD format */
|
/** Birthday in YYYYMMDD format */
|
||||||
birthday: string;
|
birthday: string;
|
||||||
@ -67,9 +67,9 @@ interface FreebitVoiceOptionRequest {
|
|||||||
identificationData: {
|
identificationData: {
|
||||||
lastnameKanji: string;
|
lastnameKanji: string;
|
||||||
firstnameKanji: string;
|
firstnameKanji: string;
|
||||||
lastnameKana: string;
|
lastnameZenKana: string;
|
||||||
firstnameKana: string;
|
firstnameZenKana: string;
|
||||||
gender: string;
|
gender: string; // 'M'/'W'/'C' per Freebit spec
|
||||||
birthday: string;
|
birthday: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -167,9 +167,11 @@ export class FreebitVoiceOptionsService {
|
|||||||
identificationData: {
|
identificationData: {
|
||||||
lastnameKanji: identificationData.lastnameKanji,
|
lastnameKanji: identificationData.lastnameKanji,
|
||||||
firstnameKanji: identificationData.firstnameKanji,
|
firstnameKanji: identificationData.firstnameKanji,
|
||||||
lastnameKana: identificationData.lastnameKana,
|
// PA05-05 spec uses lastnameZenKana/firstnameZenKana (full-width katakana)
|
||||||
firstnameKana: identificationData.firstnameKana,
|
lastnameZenKana: identificationData.lastnameKana,
|
||||||
gender: identificationData.gender,
|
firstnameZenKana: identificationData.firstnameKana,
|
||||||
|
// Map Salesforce gender 'F' → Freebit gender 'W' (PA05-05 uses M/W/C)
|
||||||
|
gender: identificationData.gender === "F" ? "W" : identificationData.gender,
|
||||||
birthday: identificationData.birthday,
|
birthday: identificationData.birthday,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -157,7 +157,15 @@ export class SalesforceFacade implements OnModuleInit {
|
|||||||
const soql = `
|
const soql = `
|
||||||
SELECT Id, Status, Type, Activation_Status__c, WHMCS_Order_ID__c,
|
SELECT Id, Status, Type, Activation_Status__c, WHMCS_Order_ID__c,
|
||||||
Activation_Error_Code__c, Activation_Error_Message__c,
|
Activation_Error_Code__c, Activation_Error_Message__c,
|
||||||
AccountId, Account.Name, SIM_Type__c, Assign_Physical_SIM__c
|
AccountId, Account.Name, SIM_Type__c, Assign_Physical_SIM__c,
|
||||||
|
EID__c, SIM_Voice_Mail__c, SIM_Call_Waiting__c,
|
||||||
|
Activation_Type__c, Activation_Scheduled_At__c,
|
||||||
|
MNP_Application__c, MNP_Reservation_Number__c,
|
||||||
|
MNP_Expiry_Date__c, MNP_Phone_Number__c,
|
||||||
|
MVNO_Account_Number__c,
|
||||||
|
Porting_FirstName__c, Porting_LastName__c,
|
||||||
|
Porting_FirstName_Katakana__c, Porting_LastName_Katakana__c,
|
||||||
|
Porting_Gender__c, Porting_DateOfBirth__c
|
||||||
FROM Order
|
FROM Order
|
||||||
WHERE Id = '${orderId}'
|
WHERE Id = '${orderId}'
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@ -169,11 +177,6 @@ export class SalesforceFacade implements OnModuleInit {
|
|||||||
|
|
||||||
return result.records?.[0] || null;
|
return result.records?.[0] || null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Temporary: Raw console log to see full error
|
|
||||||
console.error(
|
|
||||||
">>> SALESFORCE getOrder ERROR >>>",
|
|
||||||
JSON.stringify(error, Object.getOwnPropertyNames(error as object), 2)
|
|
||||||
);
|
|
||||||
this.logger.error("Failed to get order from Salesforce", {
|
this.logger.error("Failed to get order from Salesforce", {
|
||||||
orderId,
|
orderId,
|
||||||
error: extractErrorMessage(error),
|
error: extractErrorMessage(error),
|
||||||
|
|||||||
@ -80,6 +80,53 @@ export class FulfillmentContextMapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log MNP field extraction results for debugging
|
||||||
|
const mnpFieldsFound = [
|
||||||
|
config["isMnp"] != null && "isMnp",
|
||||||
|
config["mnpNumber"] != null && "mnpNumber",
|
||||||
|
config["mnpExpiry"] != null && "mnpExpiry",
|
||||||
|
config["mnpPhone"] != null && "mnpPhone",
|
||||||
|
config["mvnoAccountNumber"] != null && "mvnoAccountNumber",
|
||||||
|
config["portingFirstName"] != null && "portingFirstName",
|
||||||
|
config["portingLastName"] != null && "portingLastName",
|
||||||
|
config["portingFirstNameKatakana"] != null && "portingFirstNameKatakana",
|
||||||
|
config["portingLastNameKatakana"] != null && "portingLastNameKatakana",
|
||||||
|
config["portingGender"] != null && "portingGender",
|
||||||
|
config["portingDateOfBirth"] != null && "portingDateOfBirth",
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
if (mnpFieldsFound.length > 0 || config["isMnp"] != null) {
|
||||||
|
this.logger.log("MNP configuration fields extracted", {
|
||||||
|
source: sfOrder ? "payload+salesforce" : "payload-only",
|
||||||
|
fieldsFound: mnpFieldsFound,
|
||||||
|
isMnp: config["isMnp"],
|
||||||
|
mnpNumber: config["mnpNumber"] ? "present" : "missing",
|
||||||
|
mnpExpiry: config["mnpExpiry"] ? "present" : "missing",
|
||||||
|
mnpPhone: config["mnpPhone"] ? "present" : "missing",
|
||||||
|
portingIdentityComplete: !!(
|
||||||
|
config["portingFirstName"] &&
|
||||||
|
config["portingLastName"] &&
|
||||||
|
config["portingFirstNameKatakana"] &&
|
||||||
|
config["portingLastNameKatakana"] &&
|
||||||
|
config["portingGender"] &&
|
||||||
|
config["portingDateOfBirth"]
|
||||||
|
),
|
||||||
|
// Log SF field presence separately so we know which side contributed
|
||||||
|
sfFields: sfOrder
|
||||||
|
? {
|
||||||
|
MNP_Application__c: sfOrder.MNP_Application__c ?? "null",
|
||||||
|
MNP_Reservation_Number__c: sfOrder.MNP_Reservation_Number__c ? "present" : "missing",
|
||||||
|
MNP_Expiry_Date__c: sfOrder.MNP_Expiry_Date__c ? "present" : "missing",
|
||||||
|
MNP_Phone_Number__c: sfOrder.MNP_Phone_Number__c ? "present" : "missing",
|
||||||
|
Porting_FirstName__c: sfOrder.Porting_FirstName__c ? "present" : "missing",
|
||||||
|
Porting_LastName__c: sfOrder.Porting_LastName__c ? "present" : "missing",
|
||||||
|
Porting_Gender__c: sfOrder.Porting_Gender__c ?? "null",
|
||||||
|
Porting_DateOfBirth__c: sfOrder.Porting_DateOfBirth__c ?? "null",
|
||||||
|
}
|
||||||
|
: "no-sf-order",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,13 +209,13 @@ export class FulfillmentContextMapper {
|
|||||||
return `${isoMatch[1]}${isoMatch[2]}${isoMatch[3]}`;
|
return `${isoMatch[1]}${isoMatch[2]}${isoMatch[3]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try parsing as Date object
|
// Try parsing as Date object — use UTC methods to avoid timezone shift
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
if (!Number.isNaN(date.getTime())) {
|
if (!Number.isNaN(date.getTime())) {
|
||||||
const year = date.getFullYear();
|
const year = date.getUTCFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||||
const day = String(date.getDate()).padStart(2, "0");
|
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||||
return `${year}${month}${day}`;
|
return `${year}${month}${day}`;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@ -98,6 +98,17 @@ export class FulfillmentStepExecutors {
|
|||||||
voiceMailEnabled,
|
voiceMailEnabled,
|
||||||
callWaitingEnabled,
|
callWaitingEnabled,
|
||||||
hasContactIdentity: !!contactIdentity,
|
hasContactIdentity: !!contactIdentity,
|
||||||
|
// MNP debug: log what made it into the configurations
|
||||||
|
mnpInConfig: {
|
||||||
|
isMnp: configurations["isMnp"] ?? "not-set",
|
||||||
|
mnpNumber: configurations["mnpNumber"] ? "present" : "missing",
|
||||||
|
mnpExpiry: configurations["mnpExpiry"] ? "present" : "missing",
|
||||||
|
mnpPhone: configurations["mnpPhone"] ? "present" : "missing",
|
||||||
|
portingFirstName: configurations["portingFirstName"] ? "present" : "missing",
|
||||||
|
portingLastName: configurations["portingLastName"] ? "present" : "missing",
|
||||||
|
portingGender: configurations["portingGender"] ?? "not-set",
|
||||||
|
portingDateOfBirth: configurations["portingDateOfBirth"] ? "present" : "missing",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build assignment details for SIM Inventory record (only include defined properties)
|
// Build assignment details for SIM Inventory record (only include defined properties)
|
||||||
|
|||||||
@ -187,10 +187,19 @@ export class SimFulfillmentService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map product SKU to Freebit plan code (same as physical SIM path)
|
||||||
|
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({
|
await this.activateEsim({
|
||||||
account: phoneNumber,
|
account: phoneNumber,
|
||||||
eid,
|
eid,
|
||||||
planSku,
|
planCode,
|
||||||
activationType,
|
activationType,
|
||||||
...(scheduledAt && { scheduledAt }),
|
...(scheduledAt && { scheduledAt }),
|
||||||
...(mnp && { mnp }),
|
...(mnp && { mnp }),
|
||||||
@ -258,17 +267,17 @@ export class SimFulfillmentService {
|
|||||||
private async activateEsim(params: {
|
private async activateEsim(params: {
|
||||||
account: string;
|
account: string;
|
||||||
eid: string;
|
eid: string;
|
||||||
planSku: string;
|
planCode: string;
|
||||||
activationType: "Immediate" | "Scheduled";
|
activationType: "Immediate" | "Scheduled";
|
||||||
scheduledAt?: string;
|
scheduledAt?: string;
|
||||||
mnp?: MnpConfig;
|
mnp?: MnpConfig;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { account, eid, planSku, activationType, scheduledAt, mnp } = params;
|
const { account, eid, planCode, activationType, scheduledAt, mnp } = params;
|
||||||
const isMnp = !!mnp?.reserveNumber;
|
const isMnp = !!mnp?.reserveNumber;
|
||||||
|
|
||||||
this.logger.log("eSIM activation starting", {
|
this.logger.log("eSIM activation starting", {
|
||||||
account,
|
account,
|
||||||
planSku,
|
planCode,
|
||||||
isMnp,
|
isMnp,
|
||||||
addKind: isMnp ? "M" : "N",
|
addKind: isMnp ? "M" : "N",
|
||||||
aladinOperated: isMnp ? "20" : "10",
|
aladinOperated: isMnp ? "20" : "10",
|
||||||
@ -294,29 +303,52 @@ export class SimFulfillmentService {
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
await this.freebitFacade.activateEsimAccountNew({
|
const addKind = isMnp ? ("M" as const) : ("N" as const);
|
||||||
|
const aladinOperated = isMnp ? ("20" as const) : ("10" as const);
|
||||||
|
const pa0541Params = {
|
||||||
account,
|
account,
|
||||||
eid,
|
eid,
|
||||||
planCode: planSku,
|
planCode,
|
||||||
contractLine: "5G",
|
contractLine: "5G" as const,
|
||||||
simKind: "E0", // Voice eSIM
|
simKind: "E0" as const,
|
||||||
addKind: isMnp ? "M" : "N",
|
addKind,
|
||||||
aladinOperated: isMnp ? "20" : "10", // '20' = we provide identity for ALADIN
|
aladinOperated,
|
||||||
...(activationType === "Scheduled" && scheduledAt && { shipDate: scheduledAt }),
|
...(activationType === "Scheduled" && scheduledAt && { shipDate: scheduledAt }),
|
||||||
...(mnpPayload && { mnp: mnpPayload }),
|
...(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",
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log("eSIM activated successfully", {
|
await this.freebitFacade.activateEsimAccountNew(pa0541Params);
|
||||||
|
|
||||||
|
this.logger.log("eSIM activated successfully via PA05-41", {
|
||||||
account,
|
account,
|
||||||
planSku,
|
planCode,
|
||||||
isMnp,
|
isMnp,
|
||||||
|
addKind: pa0541Params.addKind,
|
||||||
scheduled: activationType === "Scheduled",
|
scheduled: activationType === "Scheduled",
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.logger.error("eSIM activation failed", {
|
this.logger.error("eSIM activation failed via PA05-41", {
|
||||||
account,
|
account,
|
||||||
planSku,
|
planCode,
|
||||||
isMnp,
|
isMnp,
|
||||||
|
addKind: isMnp ? "M" : "N",
|
||||||
|
mnpReserveNumber: mnp?.reserveNumber ?? "not-set",
|
||||||
|
mnpFieldCount: mnp ? Object.keys(mnp).length : 0,
|
||||||
error: extractErrorMessage(error),
|
error: extractErrorMessage(error),
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
@ -518,11 +550,15 @@ export class SimFulfillmentService {
|
|||||||
|
|
||||||
private extractMnpConfig(config: Record<string, unknown>) {
|
private extractMnpConfig(config: Record<string, unknown>) {
|
||||||
const nested = config["mnp"];
|
const nested = config["mnp"];
|
||||||
const source =
|
const hasNestedMnp = nested && typeof nested === "object";
|
||||||
nested && typeof nested === "object" ? (nested as Record<string, unknown>) : config;
|
const source = hasNestedMnp ? (nested as Record<string, unknown>) : config;
|
||||||
|
|
||||||
const isMnpFlag = this.readString(source["isMnp"] ?? config["isMnp"]);
|
const isMnpFlag = this.readString(source["isMnp"] ?? config["isMnp"]);
|
||||||
if (isMnpFlag && isMnpFlag !== "true") {
|
if (isMnpFlag && isMnpFlag.toLowerCase() !== "true") {
|
||||||
|
this.logger.log("MNP extraction skipped: isMnp flag is not 'true'", {
|
||||||
|
isMnpFlag,
|
||||||
|
isMnpFlagType: typeof (source["isMnp"] ?? config["isMnp"]),
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -551,11 +587,24 @@ export class SimFulfillmentService {
|
|||||||
!gender &&
|
!gender &&
|
||||||
!birthday
|
!birthday
|
||||||
) {
|
) {
|
||||||
|
this.logger.log("MNP extraction: no MNP fields found in config", {
|
||||||
|
hasNestedMnp,
|
||||||
|
isMnpFlag: isMnpFlag ?? "not-set",
|
||||||
|
checkedKeys: [
|
||||||
|
"mnpNumber",
|
||||||
|
"reserveNumber",
|
||||||
|
"mnpExpiry",
|
||||||
|
"portingFirstName",
|
||||||
|
"portingLastName",
|
||||||
|
"portingGender",
|
||||||
|
"portingDateOfBirth",
|
||||||
|
],
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build object with only defined properties (for exactOptionalPropertyTypes)
|
// Build object with only defined properties (for exactOptionalPropertyTypes)
|
||||||
return {
|
const result = {
|
||||||
...(reserveNumber && { reserveNumber }),
|
...(reserveNumber && { reserveNumber }),
|
||||||
...(reserveExpireDate && { reserveExpireDate }),
|
...(reserveExpireDate && { reserveExpireDate }),
|
||||||
...(account && { account }),
|
...(account && { account }),
|
||||||
@ -566,5 +615,19 @@ export class SimFulfillmentService {
|
|||||||
...(gender && { gender }),
|
...(gender && { gender }),
|
||||||
...(birthday && { birthday }),
|
...(birthday && { birthday }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.logger.log("MNP config extracted", {
|
||||||
|
hasReserveNumber: !!reserveNumber,
|
||||||
|
reserveNumberLength: reserveNumber?.length,
|
||||||
|
hasReserveExpireDate: !!reserveExpireDate,
|
||||||
|
hasAccount: !!account,
|
||||||
|
hasIdentity: !!(firstnameKanji && lastnameKanji),
|
||||||
|
hasKana: !!(firstnameZenKana && lastnameZenKana),
|
||||||
|
gender: gender ?? "not-set",
|
||||||
|
hasBirthday: !!birthday,
|
||||||
|
totalFieldsExtracted: Object.keys(result).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -261,7 +261,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
|
|||||||
// Calculate percentage for circle
|
// Calculate percentage for circle
|
||||||
const totalMB = Number.parseFloat(remainingMB) + Number.parseFloat(usedMB);
|
const totalMB = Number.parseFloat(remainingMB) + Number.parseFloat(usedMB);
|
||||||
const usagePercentage = totalMB > 0 ? (Number.parseFloat(usedMB) / totalMB) * 100 : 0;
|
const usagePercentage = totalMB > 0 ? (Number.parseFloat(usedMB) / totalMB) * 100 : 0;
|
||||||
const circumference = 2 * Math.PI * 88;
|
const circumference = 2 * Math.PI * 96;
|
||||||
const strokeDashoffset = circumference - (usagePercentage / 100) * circumference;
|
const strokeDashoffset = circumference - (usagePercentage / 100) * circumference;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -111,8 +111,8 @@ export function mapProductToFreebitPlanCode(
|
|||||||
// Try to extract data tier from SKU or name
|
// Try to extract data tier from SKU or name
|
||||||
const source = productSku || productName || "";
|
const source = productSku || productName || "";
|
||||||
|
|
||||||
// Match patterns like "50GB", "50G", "50gb", or just "50" in context of GB
|
// Match patterns like "50GB", "50gb" — require the 'B' to distinguish from "5G" network type
|
||||||
const gbMatch = source.match(/(\d+)\s*G(?:B)?/i);
|
const gbMatch = source.match(/(\d+)\s*GB/i);
|
||||||
if (gbMatch?.[1]) {
|
if (gbMatch?.[1]) {
|
||||||
const tier = gbMatch[1];
|
const tier = gbMatch[1];
|
||||||
const planCode = PLAN_CODE_MAPPING[tier];
|
const planCode = PLAN_CODE_MAPPING[tier];
|
||||||
@ -121,8 +121,8 @@ export function mapProductToFreebitPlanCode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try matching standalone numbers in SKU patterns like "sim-50gb"
|
// Try matching numbers in SKU patterns like "sim-50gb" or "sim-50-gb"
|
||||||
const skuMatch = source.match(/[-_](\d+)[-_]?(?:gb|g)?/i);
|
const skuMatch = source.match(/[-_](\d+)[-_]?gb/i);
|
||||||
if (skuMatch?.[1]) {
|
if (skuMatch?.[1]) {
|
||||||
const tier = skuMatch[1];
|
const tier = skuMatch[1];
|
||||||
const planCode = PLAN_CODE_MAPPING[tier];
|
const planCode = PLAN_CODE_MAPPING[tier];
|
||||||
@ -131,5 +131,15 @@ export function mapProductToFreebitPlanCode(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: match "NNg" only when preceded by delimiter (not "5G" network type)
|
||||||
|
const delimiterMatch = source.match(/[-_](\d+)g(?:\b|[-_])/i);
|
||||||
|
if (delimiterMatch?.[1]) {
|
||||||
|
const tier = delimiterMatch[1];
|
||||||
|
const planCode = PLAN_CODE_MAPPING[tier];
|
||||||
|
if (planCode) {
|
||||||
|
return planCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -126,7 +126,20 @@ export const freebitQuotaHistoryResponseSchema = z.object({
|
|||||||
|
|
||||||
export const freebitEsimMnpSchema = z.object({
|
export const freebitEsimMnpSchema = z.object({
|
||||||
reserveNumber: z.string().min(1, "Reserve number is required"),
|
reserveNumber: z.string().min(1, "Reserve number is required"),
|
||||||
reserveExpireDate: z.string().regex(/^\d{8}$/, "Reserve expire date must be in YYYYMMDD format"),
|
reserveExpireDate: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{8}$/, "Reserve expire date must be in YYYYMMDD format")
|
||||||
|
.optional(),
|
||||||
|
// Identity fields (Level 2 per PA05-41 spec — nested inside mnp)
|
||||||
|
lastnameKanji: z.string().optional(),
|
||||||
|
firstnameKanji: z.string().optional(),
|
||||||
|
lastnameZenKana: z.string().optional(),
|
||||||
|
firstnameZenKana: z.string().optional(),
|
||||||
|
gender: z.enum(["M", "W", "C"]).optional(), // M: Male, W: Female (Weiblich), C: Corporate
|
||||||
|
birthday: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{8}$/, "Birthday must be in YYYYMMDD format")
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const freebitEsimReissueRequestSchema = z.object({
|
export const freebitEsimReissueRequestSchema = z.object({
|
||||||
@ -142,7 +155,7 @@ export const freebitEsimAddAccountRequestSchema = z.object({
|
|||||||
aladinOperated: z.enum(["10", "20"]).default("10"),
|
aladinOperated: z.enum(["10", "20"]).default("10"),
|
||||||
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
account: z.string().min(1, MSG_ACCOUNT_REQUIRED),
|
||||||
eid: z.string().min(1, "EID is required"),
|
eid: z.string().min(1, "EID is required"),
|
||||||
addKind: z.enum(["N", "R"]).default("N"),
|
addKind: z.enum(["N", "R", "M"]).default("N"), // N: New, R: Reissue, M: MNP
|
||||||
shipDate: z.string().regex(YYYYMMDD_REGEX, MSG_SHIP_DATE_FORMAT).optional(),
|
shipDate: z.string().regex(YYYYMMDD_REGEX, MSG_SHIP_DATE_FORMAT).optional(),
|
||||||
planCode: z.string().optional(),
|
planCode: z.string().optional(),
|
||||||
contractLine: z.enum(["4G", "5G"]).optional(),
|
contractLine: z.enum(["4G", "5G"]).optional(),
|
||||||
@ -185,7 +198,7 @@ export const freebitEsimIdentitySchema = z.object({
|
|||||||
lastnameKanji: z.string().optional(),
|
lastnameKanji: z.string().optional(),
|
||||||
firstnameZenKana: z.string().optional(),
|
firstnameZenKana: z.string().optional(),
|
||||||
lastnameZenKana: z.string().optional(),
|
lastnameZenKana: z.string().optional(),
|
||||||
gender: z.enum(["M", "F"]).optional(),
|
gender: z.enum(["M", "W", "C"]).optional(), // Freebit: M=Male, W=Female, C=Corporate
|
||||||
birthday: z
|
birthday: z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^\d{8}$/, "Birthday must be in YYYYMMDD format")
|
.regex(/^\d{8}$/, "Birthday must be in YYYYMMDD format")
|
||||||
@ -199,30 +212,20 @@ export const freebitEsimIdentitySchema = z.object({
|
|||||||
export const freebitEsimActivationRequestSchema = z.object({
|
export const freebitEsimActivationRequestSchema = z.object({
|
||||||
authKey: z.string().min(1, "Auth key is required"),
|
authKey: z.string().min(1, "Auth key is required"),
|
||||||
aladinOperated: z.enum(["10", "20"]).default("10"), // 10: issue profile, 20: no-issue
|
aladinOperated: z.enum(["10", "20"]).default("10"), // 10: issue profile, 20: no-issue
|
||||||
createType: z.enum(["new", "reissue", "exchange"]).default("new"),
|
createType: z.enum(["new", "reissue", "exchange"]).optional(), // Not required for addKind='R'
|
||||||
account: z.string().min(1, "Account (MSISDN) is required"),
|
account: z.string().min(1, "Account (MSISDN) is required"),
|
||||||
eid: z.string().min(1, "EID is required for eSIM"),
|
eid: z.string().min(1, "EID is required for eSIM"),
|
||||||
simkind: z.enum(["esim", "psim"]).default("esim"),
|
simkind: z.enum(["E0", "E2", "E3"]).default("E0"), // E0: voice, E2: data-only, E3: SMS
|
||||||
planCode: z.string().optional(),
|
planCode: z.string().optional(),
|
||||||
contractLine: z.enum(["4G", "5G"]).optional(),
|
contractLine: z.enum(["4G", "5G"]).optional(),
|
||||||
shipDate: z.string().regex(YYYYMMDD_REGEX, MSG_SHIP_DATE_FORMAT).optional(),
|
shipDate: z.string().regex(YYYYMMDD_REGEX, MSG_SHIP_DATE_FORMAT).optional(),
|
||||||
mnp: freebitEsimMnpSchema.optional(),
|
mnp: freebitEsimMnpSchema.optional(), // MNP + identity fields (Level 2 per PA05-41)
|
||||||
// Identity fields (flattened for API)
|
|
||||||
firstnameKanji: z.string().optional(),
|
|
||||||
lastnameKanji: z.string().optional(),
|
|
||||||
firstnameZenKana: z.string().optional(),
|
|
||||||
lastnameZenKana: z.string().optional(),
|
|
||||||
gender: z.enum(["M", "F"]).optional(),
|
|
||||||
birthday: z
|
|
||||||
.string()
|
|
||||||
.regex(/^\d{8}$/, "Birthday must be in YYYYMMDD format")
|
|
||||||
.optional(),
|
|
||||||
// Additional fields for reissue/exchange
|
// Additional fields for reissue/exchange
|
||||||
masterAccount: z.string().optional(),
|
masterAccount: z.string().optional(),
|
||||||
masterPassword: z.string().optional(),
|
masterPassword: z.string().optional(),
|
||||||
repAccount: z.string().optional(),
|
repAccount: z.string().optional(),
|
||||||
size: z.string().optional(),
|
size: z.string().optional(),
|
||||||
addKind: z.string().optional(), // 'R' for reissue
|
addKind: z.enum(["N", "M", "R"]).optional(), // N: New, M: MNP, R: Reissue
|
||||||
oldEid: z.string().optional(),
|
oldEid: z.string().optional(),
|
||||||
oldProductNumber: z.string().optional(),
|
oldProductNumber: z.string().optional(),
|
||||||
deliveryCode: z.string().optional(),
|
deliveryCode: z.string().optional(),
|
||||||
@ -258,8 +261,9 @@ export const freebitEsimActivationParamsSchema = z.object({
|
|||||||
contractLine: z.enum(["4G", "5G"]).optional(),
|
contractLine: z.enum(["4G", "5G"]).optional(),
|
||||||
aladinOperated: z.enum(["10", "20"]).default("10"),
|
aladinOperated: z.enum(["10", "20"]).default("10"),
|
||||||
shipDate: z.string().regex(YYYYMMDD_REGEX, MSG_SHIP_DATE_FORMAT).optional(),
|
shipDate: z.string().regex(YYYYMMDD_REGEX, MSG_SHIP_DATE_FORMAT).optional(),
|
||||||
mnp: freebitEsimMnpSchema.optional(),
|
addKind: z.enum(["N", "M", "R"]).optional(),
|
||||||
identity: freebitEsimIdentitySchema.optional(),
|
simKind: z.enum(["E0", "E2", "E3"]).optional(),
|
||||||
|
mnp: freebitEsimMnpSchema.optional(), // MNP reservation + identity (Level 2)
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user