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:
Temuulen Ankhbayar 2026-02-17 18:48:50 +09:00
parent be09c78491
commit 5e5bff12da
11 changed files with 240 additions and 77 deletions

View File

@ -287,6 +287,8 @@ export interface FreebitEsimAddAccountRequest {
shipDate?: string;
planCode?: string;
contractLine?: string;
oldProductNumber?: string; // Required for physical SIM → eSIM reissue
oldEid?: string; // Required for eSIM → eSIM reissue
mnp?: {
reserveNumber: string;
reserveExpireDate: string;
@ -349,7 +351,7 @@ export interface FreebitEsimAccountActivationResponse {
export interface FreebitError extends Error {
resultCode: string;
statusCode: string | number;
freebititMessage: string;
freebitMessage: string;
}
// Configuration

View File

@ -2,7 +2,6 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
import { FreebitClientService } from "./freebit-client.service.js";
import { FreebitAuthService } from "./freebit-auth.service.js";
import type {
FreebitEsimReissueRequest,
FreebitEsimReissueResponse,
@ -44,7 +43,6 @@ export interface EsimActivationParams {
export class FreebitEsimService {
constructor(
private readonly client: FreebitClientService,
private readonly auth: FreebitAuthService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -88,6 +86,8 @@ export class FreebitEsimService {
eid: newEid,
addKind: "R",
...(options.planCode !== undefined && { planCode: options.planCode }),
...(options.oldProductNumber && { oldProductNumber: options.oldProductNumber }),
...(options.oldEid && { oldEid: options.oldEid }),
};
await this.client.makeAuthenticatedRequest<FreebitEsimAddAccountResponse, typeof request>(
@ -158,10 +158,11 @@ export class FreebitEsimService {
});
try {
const payload: FreebitEsimAccountActivationRequest = {
authKey: await this.auth.getAuthKey(),
// Note: authKey is injected by makeAuthenticatedJsonRequest
// createType is not required when addKind is 'R' (reissue)
const payload: Omit<FreebitEsimAccountActivationRequest, "authKey"> = {
aladinOperated,
createType: "new",
...(finalAddKind !== "R" && { createType: "new" }),
eid,
account,
simkind: simKind || "E0",
@ -174,21 +175,39 @@ export class FreebitEsimService {
globalIp,
// MNP object includes both reservation info AND identity fields (all Level 2 per PA05-41 spec)
...(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,
eid,
addKind: finalAddKind,
simkind: simKind || "E0",
aladinOperated,
mnpReserveNumber: mnp?.reserveNumber,
mnpHasIdentity: !!(mnp?.lastnameKanji || mnp?.firstnameKanji),
mnpGender: mnp?.gender,
createType: finalAddKind === "R" ? "omitted" : "new",
planCode,
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<
FreebitEsimAccountActivationResponse,
FreebitEsimAccountActivationRequest
>("/mvno/esim/addAcct/", payload);
Omit<FreebitEsimAccountActivationRequest, "authKey">
>("/mvno/esim/addAcnt/", payload);
this.logger.log("Successfully activated new eSIM account via PA05-41", {
account,
@ -207,6 +226,8 @@ export class FreebitEsimService {
addKind: finalAddKind,
aladinOperated,
isMnp: finalAddKind === "M",
hasMnpReserveNumber: !!mnp?.reserveNumber,
mnpFieldCount: mnp ? Object.keys(mnp).length : 0,
error: message,
});
throw new BadRequestException(`Failed to activate new eSIM account: ${message}`);

View File

@ -160,15 +160,15 @@ export class FreebitMapperService {
if (account.remainingQuotaMb != null) {
// If API explicitly provides remainingQuotaMb, use it directly
remainingQuotaMb = Number(account.remainingQuotaMb);
remainingQuotaKb = Number(account.remainingQuotaKb ?? remainingQuotaMb * 1000);
remainingQuotaKb = Number(account.remainingQuotaKb ?? remainingQuotaMb * 1024);
} 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);
remainingQuotaMb = remainingQuotaKb / 1000;
remainingQuotaMb = remainingQuotaKb / 1024;
} 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);
remainingQuotaMb = remainingQuotaKb / 1000;
remainingQuotaMb = remainingQuotaKb / 1024;
}
// Log raw account data in dev to debug MSISDN availability

View File

@ -25,7 +25,7 @@ export interface VoiceOptionIdentityData {
lastnameKana: string;
/** First name in Katakana (full-width, max 50 chars) */
firstnameKana: string;
/** Gender: "M" = Male, "F" = Female */
/** Gender: "M" = Male, "F" = Female (mapped to Freebit "W" on send) */
gender: "M" | "F";
/** Birthday in YYYYMMDD format */
birthday: string;
@ -67,9 +67,9 @@ interface FreebitVoiceOptionRequest {
identificationData: {
lastnameKanji: string;
firstnameKanji: string;
lastnameKana: string;
firstnameKana: string;
gender: string;
lastnameZenKana: string;
firstnameZenKana: string;
gender: string; // 'M'/'W'/'C' per Freebit spec
birthday: string;
};
}
@ -167,9 +167,11 @@ export class FreebitVoiceOptionsService {
identificationData: {
lastnameKanji: identificationData.lastnameKanji,
firstnameKanji: identificationData.firstnameKanji,
lastnameKana: identificationData.lastnameKana,
firstnameKana: identificationData.firstnameKana,
gender: identificationData.gender,
// PA05-05 spec uses lastnameZenKana/firstnameZenKana (full-width katakana)
lastnameZenKana: identificationData.lastnameKana,
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,
},
};

View File

@ -157,7 +157,15 @@ export class SalesforceFacade implements OnModuleInit {
const soql = `
SELECT Id, Status, Type, Activation_Status__c, WHMCS_Order_ID__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
WHERE Id = '${orderId}'
LIMIT 1
@ -169,11 +177,6 @@ export class SalesforceFacade implements OnModuleInit {
return result.records?.[0] || null;
} 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", {
orderId,
error: extractErrorMessage(error),

View File

@ -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;
}
@ -162,13 +209,13 @@ export class FulfillmentContextMapper {
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 {
const date = new Date(dateStr);
if (!Number.isNaN(date.getTime())) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
return `${year}${month}${day}`;
}
} catch {

View File

@ -98,6 +98,17 @@ export class FulfillmentStepExecutors {
voiceMailEnabled,
callWaitingEnabled,
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)

View File

@ -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({
account: phoneNumber,
eid,
planSku,
planCode,
activationType,
...(scheduledAt && { scheduledAt }),
...(mnp && { mnp }),
@ -258,17 +267,17 @@ export class SimFulfillmentService {
private async activateEsim(params: {
account: string;
eid: string;
planSku: string;
planCode: string;
activationType: "Immediate" | "Scheduled";
scheduledAt?: string;
mnp?: MnpConfig;
}): Promise<void> {
const { account, eid, planSku, activationType, scheduledAt, mnp } = params;
const { account, eid, planCode, activationType, scheduledAt, mnp } = params;
const isMnp = !!mnp?.reserveNumber;
this.logger.log("eSIM activation starting", {
account,
planSku,
planCode,
isMnp,
addKind: isMnp ? "M" : "N",
aladinOperated: isMnp ? "20" : "10",
@ -294,29 +303,52 @@ export class SimFulfillmentService {
}
: 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,
eid,
planCode: planSku,
contractLine: "5G",
simKind: "E0", // Voice eSIM
addKind: isMnp ? "M" : "N",
aladinOperated: isMnp ? "20" : "10", // '20' = we provide identity for ALADIN
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",
});
this.logger.log("eSIM activated successfully", {
await this.freebitFacade.activateEsimAccountNew(pa0541Params);
this.logger.log("eSIM activated successfully via PA05-41", {
account,
planSku,
planCode,
isMnp,
addKind: pa0541Params.addKind,
scheduled: activationType === "Scheduled",
});
} catch (error: unknown) {
this.logger.error("eSIM activation failed", {
this.logger.error("eSIM activation failed via PA05-41", {
account,
planSku,
planCode,
isMnp,
addKind: isMnp ? "M" : "N",
mnpReserveNumber: mnp?.reserveNumber ?? "not-set",
mnpFieldCount: mnp ? Object.keys(mnp).length : 0,
error: extractErrorMessage(error),
});
throw error;
@ -518,11 +550,15 @@ export class SimFulfillmentService {
private extractMnpConfig(config: Record<string, unknown>) {
const nested = config["mnp"];
const source =
nested && typeof nested === "object" ? (nested as Record<string, unknown>) : config;
const hasNestedMnp = nested && typeof nested === "object";
const source = hasNestedMnp ? (nested as Record<string, unknown>) : config;
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;
}
@ -551,11 +587,24 @@ export class SimFulfillmentService {
!gender &&
!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;
}
// Build object with only defined properties (for exactOptionalPropertyTypes)
return {
const result = {
...(reserveNumber && { reserveNumber }),
...(reserveExpireDate && { reserveExpireDate }),
...(account && { account }),
@ -566,5 +615,19 @@ export class SimFulfillmentService {
...(gender && { gender }),
...(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;
}
}

View File

@ -261,7 +261,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
// Calculate percentage for circle
const totalMB = Number.parseFloat(remainingMB) + Number.parseFloat(usedMB);
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;
return (

View File

@ -111,8 +111,8 @@ export function mapProductToFreebitPlanCode(
// Try to extract data tier from SKU or name
const source = productSku || productName || "";
// Match patterns like "50GB", "50G", "50gb", or just "50" in context of GB
const gbMatch = source.match(/(\d+)\s*G(?:B)?/i);
// Match patterns like "50GB", "50gb" — require the 'B' to distinguish from "5G" network type
const gbMatch = source.match(/(\d+)\s*GB/i);
if (gbMatch?.[1]) {
const tier = gbMatch[1];
const planCode = PLAN_CODE_MAPPING[tier];
@ -121,8 +121,8 @@ export function mapProductToFreebitPlanCode(
}
}
// Try matching standalone numbers in SKU patterns like "sim-50gb"
const skuMatch = source.match(/[-_](\d+)[-_]?(?:gb|g)?/i);
// Try matching numbers in SKU patterns like "sim-50gb" or "sim-50-gb"
const skuMatch = source.match(/[-_](\d+)[-_]?gb/i);
if (skuMatch?.[1]) {
const tier = skuMatch[1];
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;
}

View File

@ -126,7 +126,20 @@ export const freebitQuotaHistoryResponseSchema = z.object({
export const freebitEsimMnpSchema = z.object({
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({
@ -142,7 +155,7 @@ export const freebitEsimAddAccountRequestSchema = z.object({
aladinOperated: z.enum(["10", "20"]).default("10"),
account: z.string().min(1, MSG_ACCOUNT_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(),
planCode: z.string().optional(),
contractLine: z.enum(["4G", "5G"]).optional(),
@ -185,7 +198,7 @@ export const freebitEsimIdentitySchema = z.object({
lastnameKanji: z.string().optional(),
firstnameZenKana: 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
.string()
.regex(/^\d{8}$/, "Birthday must be in YYYYMMDD format")
@ -199,30 +212,20 @@ export const freebitEsimIdentitySchema = z.object({
export const freebitEsimActivationRequestSchema = z.object({
authKey: z.string().min(1, "Auth key is required"),
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"),
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(),
contractLine: z.enum(["4G", "5G"]).optional(),
shipDate: z.string().regex(YYYYMMDD_REGEX, MSG_SHIP_DATE_FORMAT).optional(),
mnp: freebitEsimMnpSchema.optional(),
// 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(),
mnp: freebitEsimMnpSchema.optional(), // MNP + identity fields (Level 2 per PA05-41)
// Additional fields for reissue/exchange
masterAccount: z.string().optional(),
masterPassword: z.string().optional(),
repAccount: 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(),
oldProductNumber: z.string().optional(),
deliveryCode: z.string().optional(),
@ -258,8 +261,9 @@ export const freebitEsimActivationParamsSchema = z.object({
contractLine: z.enum(["4G", "5G"]).optional(),
aladinOperated: z.enum(["10", "20"]).default("10"),
shipDate: z.string().regex(YYYYMMDD_REGEX, MSG_SHIP_DATE_FORMAT).optional(),
mnp: freebitEsimMnpSchema.optional(),
identity: freebitEsimIdentitySchema.optional(),
addKind: z.enum(["N", "M", "R"]).optional(),
simKind: z.enum(["E0", "E2", "E3"]).optional(),
mnp: freebitEsimMnpSchema.optional(), // MNP reservation + identity (Level 2)
});
// ============================================================================