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; 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

View File

@ -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}`);

View File

@ -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

View File

@ -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,
}, },
}; };

View File

@ -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),

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; 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 {

View File

@ -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)

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({ 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;
} }
} }

View File

@ -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 (

View File

@ -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;
} }

View File

@ -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)
}); });
// ============================================================================ // ============================================================================