fix: reset order config state when selecting a new plan and validate MNP phone length

Order wizard was skipping steps (jumping to add-ons) due to stale currentStep
persisting in localStorage from previous orders. Reset store on plan selection
and exclude currentStep from persistence. Also add max(11) validation on MNP
phone number to prevent Salesforce STRING_TOO_LONG errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Temuulen Ankhbayar 2026-03-07 11:02:25 +09:00
parent 1610e436a5
commit 454fb29c85
11 changed files with 127 additions and 30 deletions

View File

@ -158,8 +158,9 @@ export class FreebitEsimService {
}); });
try { try {
// Note: authKey is injected by makeAuthenticatedJsonRequest // Note: authKey is injected by makeAuthenticatedRequest
// createType is not required when addKind is 'R' (reissue) // createType is not required when addKind is 'R' (reissue)
// Only include defined optional fields — Freebit rejects unknown/null values
const payload: Omit<FreebitEsimAccountActivationRequest, "authKey"> = { const payload: Omit<FreebitEsimAccountActivationRequest, "authKey"> = {
aladinOperated, aladinOperated,
...(finalAddKind !== "R" && { createType: "new" }), ...(finalAddKind !== "R" && { createType: "new" }),
@ -167,12 +168,12 @@ export class FreebitEsimService {
account, account,
simkind: simKind || "E0", simkind: simKind || "E0",
addKind: finalAddKind, addKind: finalAddKind,
planCode, ...(planCode && { planCode }),
contractLine, ...(contractLine && { contractLine }),
shipDate, ...(shipDate && { shipDate }),
repAccount, ...(repAccount && { repAccount }),
deliveryCode, ...(deliveryCode && { deliveryCode }),
globalIp, ...(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 Omit<FreebitEsimAccountActivationRequest, "authKey">; } as Omit<FreebitEsimAccountActivationRequest, "authKey">;
@ -204,7 +205,7 @@ export class FreebitEsimService {
: "no-mnp", : "no-mnp",
}); });
await this.client.makeAuthenticatedJsonRequest< await this.client.makeAuthenticatedRequest<
FreebitEsimAccountActivationResponse, FreebitEsimAccountActivationResponse,
Omit<FreebitEsimAccountActivationRequest, "authKey"> Omit<FreebitEsimAccountActivationRequest, "authKey">
>("/mvno/esim/addAcnt/", payload); >("/mvno/esim/addAcnt/", payload);

View File

@ -345,6 +345,10 @@ export class FulfillmentStepExecutors {
/** /**
* Update Salesforce with WHMCS registration info * Update Salesforce with WHMCS registration info
* This is the final step that sets Status to "Processed" * This is the final step that sets Status to "Processed"
*
* If the order is already in an activated status (e.g. auto-approved by SF Flow),
* the integration user may lack "Edit Activated Orders" permission. In that case,
* we retry with only custom field updates (no Status change).
*/ */
async executeSfRegistrationComplete( async executeSfRegistrationComplete(
ctx: OrderFulfillmentContext, ctx: OrderFulfillmentContext,
@ -361,7 +365,31 @@ export class FulfillmentStepExecutors {
WHMCS_Order_ID__c: whmcsCreateResult?.orderId?.toString(), WHMCS_Order_ID__c: whmcsCreateResult?.orderId?.toString(),
}; };
const result = await this.salesforceFacade.updateOrder(updatePayload); try {
await this.salesforceFacade.updateOrder(updatePayload);
} catch (error) {
// SF returns permission error when order is already in an activated status
// (e.g. auto-approved by a Flow). Retry with only custom fields, no Status change.
const msg = error instanceof Error ? error.message : String(error);
if (
msg.includes("有効注文") ||
msg.includes("Edit Activated Orders") ||
msg.includes("FIELD_INTEGRITY_EXCEPTION")
) {
this.logger.warn("SF order already activated, retrying without Status change", {
sfOrderId: ctx.sfOrderId,
originalError: msg,
});
await this.salesforceFacade.updateOrder({
Id: ctx.sfOrderId,
Activation_Status__c: "Activated",
WHMCS_Order_ID__c: whmcsCreateResult?.orderId?.toString(),
});
} else {
throw error;
}
}
this.sideEffects.publishStatusUpdate(ctx.sfOrderId, { this.sideEffects.publishStatusUpdate(ctx.sfOrderId, {
status: "Processed", status: "Processed",
activationStatus: "Activated", activationStatus: "Activated",
@ -375,7 +403,6 @@ export class FulfillmentStepExecutors {
}, },
}); });
await this.sideEffects.notifyOrderActivated(ctx.sfOrderId, ctx.validation?.sfOrder?.AccountId); await this.sideEffects.notifyOrderActivated(ctx.sfOrderId, ctx.validation?.sfOrder?.AccountId);
return result;
} }
/** /**

View File

@ -18,6 +18,16 @@ function assignIfString(target: SalesforceOrderFields, key: string, value: unkno
} }
} }
/** Convert YYYYMMDD to YYYY-MM-DD for Salesforce Date fields */
function toSalesforceDate(yyyymmdd: unknown): string | undefined {
if (typeof yyyymmdd !== "string") return undefined;
const d = yyyymmdd.trim().replace(/-/g, "");
if (/^\d{8}$/.test(d)) {
return `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`;
}
return yyyymmdd;
}
/** /**
* Builds Salesforce Order records from domain order requests * Builds Salesforce Order records from domain order requests
*/ */
@ -74,7 +84,11 @@ export class OrderBuilder {
const config = body.configurations || {}; const config = body.configurations || {};
assignIfString(orderFields, fieldNames.activationType, config.activationType); assignIfString(orderFields, fieldNames.activationType, config.activationType);
assignIfString(orderFields, fieldNames.activationScheduledAt, config.scheduledAt); assignIfString(
orderFields,
fieldNames.activationScheduledAt,
toSalesforceDate(config.scheduledAt)
);
orderFields[fieldNames.activationStatus] = "Not Started"; orderFields[fieldNames.activationStatus] = "Not Started";
} }
@ -99,7 +113,7 @@ export class OrderBuilder {
if (config.isMnp === "true") { if (config.isMnp === "true") {
orderFields[fieldNames.mnpApplication] = true; orderFields[fieldNames.mnpApplication] = true;
assignIfString(orderFields, fieldNames.mnpReservation, config.mnpNumber); assignIfString(orderFields, fieldNames.mnpReservation, config.mnpNumber);
assignIfString(orderFields, fieldNames.mnpExpiry, config.mnpExpiry); assignIfString(orderFields, fieldNames.mnpExpiry, toSalesforceDate(config.mnpExpiry));
assignIfString(orderFields, fieldNames.mnpPhone, config.mnpPhone); assignIfString(orderFields, fieldNames.mnpPhone, config.mnpPhone);
assignIfString(orderFields, fieldNames.mvnoAccountNumber, config.mvnoAccountNumber); assignIfString(orderFields, fieldNames.mvnoAccountNumber, config.mvnoAccountNumber);
assignIfString(orderFields, fieldNames.portingLastName, config.portingLastName); assignIfString(orderFields, fieldNames.portingLastName, config.portingLastName);
@ -115,7 +129,11 @@ export class OrderBuilder {
config.portingFirstNameKatakana config.portingFirstNameKatakana
); );
assignIfString(orderFields, fieldNames.portingGender, config.portingGender); assignIfString(orderFields, fieldNames.portingGender, config.portingGender);
assignIfString(orderFields, fieldNames.portingDateOfBirth, config.portingDateOfBirth); assignIfString(
orderFields,
fieldNames.portingDateOfBirth,
toSalesforceDate(config.portingDateOfBirth)
);
} }
} }

View File

@ -313,16 +313,17 @@ export class OrderFulfillmentOrchestrator {
}); });
// Update Salesforce with failure status // Update Salesforce with failure status
const errorShortCode = (
this.orderFulfillmentErrorService.getShortCode(error) || String(errorCode)
)
.toString()
.slice(0, 60);
try { try {
await this.salesforceFacade.updateOrder({ await this.salesforceFacade.updateOrder({
Id: sfOrderId, Id: sfOrderId,
Status: "Pending Review", Status: "Pending Review",
Activation_Status__c: "Failed", Activation_Status__c: "Failed",
Activation_Error_Code__c: ( Activation_Error_Code__c: errorShortCode,
this.orderFulfillmentErrorService.getShortCode(error) || String(errorCode)
)
.toString()
.slice(0, 60),
Activation_Error_Message__c: userMessage?.slice(0, 255), Activation_Error_Message__c: userMessage?.slice(0, 255),
}); });
this.logger.log("Salesforce updated with failure status", { this.logger.log("Salesforce updated with failure status", {
@ -330,10 +331,33 @@ export class OrderFulfillmentOrchestrator {
errorCode, errorCode,
}); });
} catch (updateError) { } catch (updateError) {
this.logger.error("Failed to update Salesforce with error status", { // If the order is already activated (e.g. auto-approved by SF Flow),
sfOrderId: context.sfOrderId, // retry without Status change — only update custom error fields
updateError: updateError instanceof Error ? updateError.message : String(updateError), const updateMsg = updateError instanceof Error ? updateError.message : String(updateError);
}); if (updateMsg.includes("有効注文") || updateMsg.includes("Edit Activated Orders")) {
try {
await this.salesforceFacade.updateOrder({
Id: sfOrderId,
Activation_Status__c: "Failed",
Activation_Error_Code__c: errorShortCode,
Activation_Error_Message__c: userMessage?.slice(0, 255),
});
this.logger.log("Salesforce updated with failure status (without Status change)", {
sfOrderId: context.sfOrderId,
errorCode,
});
} catch (retryError) {
this.logger.error("Failed to update Salesforce with error status (retry)", {
sfOrderId: context.sfOrderId,
retryError: retryError instanceof Error ? retryError.message : String(retryError),
});
}
} else {
this.logger.error("Failed to update Salesforce with error status", {
sfOrderId: context.sfOrderId,
updateError: updateMsg,
});
}
} }
// Publish failure events and notifications // Publish failure events and notifications

View File

@ -64,8 +64,10 @@ export function useInternetConfigure(): UseInternetConfigureResult {
// Initialize/restore state on mount // Initialize/restore state on mount
useEffect(() => { useEffect(() => {
// If URL has plan param but store doesn't, this is a fresh entry // If URL has a different plan than stored, reset config for fresh start
if (urlPlanSku && configState.planSku !== urlPlanSku) { if (urlPlanSku && configState.planSku !== urlPlanSku) {
const resetInternetConfig = useCatalogStore.getState().resetInternetConfig;
resetInternetConfig();
setConfig({ planSku: urlPlanSku }); setConfig({ planSku: urlPlanSku });
} }

View File

@ -61,6 +61,11 @@ function trimOptional(value?: string | null) {
return trimmed.length > 0 ? trimmed : undefined; return trimmed.length > 0 ? trimmed : undefined;
} }
/** Convert YYYY-MM-DD (from date input) to YYYYMMDD (FreeBit API format) */
function toYYYYMMDD(value: string): string {
return value.replace(/-/g, "");
}
function buildFormData(configState: SimConfigState): SimConfigureFormData { function buildFormData(configState: SimConfigState): SimConfigureFormData {
return { return {
simType: configState.simType, simType: configState.simType,
@ -69,13 +74,13 @@ function buildFormData(configState: SimConfigState): SimConfigureFormData {
activationType: configState.activationType, activationType: configState.activationType,
scheduledActivationDate: scheduledActivationDate:
configState.activationType === "Scheduled" configState.activationType === "Scheduled"
? trimOptional(configState.scheduledActivationDate) ? trimOptional(configState.scheduledActivationDate)?.replace(/-/g, "")
: undefined, : undefined,
wantsMnp: configState.wantsMnp, wantsMnp: configState.wantsMnp,
mnpData: configState.wantsMnp mnpData: configState.wantsMnp
? { ? {
reservationNumber: configState.mnpData.reservationNumber.trim(), reservationNumber: configState.mnpData.reservationNumber.trim(),
expiryDate: configState.mnpData.expiryDate.trim(), expiryDate: toYYYYMMDD(configState.mnpData.expiryDate.trim()),
phoneNumber: configState.mnpData.phoneNumber.trim(), phoneNumber: configState.mnpData.phoneNumber.trim(),
mvnoAccountNumber: trimOptional(configState.mnpData.mvnoAccountNumber), mvnoAccountNumber: trimOptional(configState.mnpData.mvnoAccountNumber),
portingLastName: trimOptional(configState.mnpData.portingLastName), portingLastName: trimOptional(configState.mnpData.portingLastName),
@ -83,7 +88,10 @@ function buildFormData(configState: SimConfigState): SimConfigureFormData {
portingLastNameKatakana: trimOptional(configState.mnpData.portingLastNameKatakana), portingLastNameKatakana: trimOptional(configState.mnpData.portingLastNameKatakana),
portingFirstNameKatakana: trimOptional(configState.mnpData.portingFirstNameKatakana), portingFirstNameKatakana: trimOptional(configState.mnpData.portingFirstNameKatakana),
portingGender: trimOptional(configState.mnpData.portingGender), portingGender: trimOptional(configState.mnpData.portingGender),
portingDateOfBirth: trimOptional(configState.mnpData.portingDateOfBirth), portingDateOfBirth: trimOptional(configState.mnpData.portingDateOfBirth)?.replace(
/-/g,
""
),
} }
: undefined, : undefined,
}; };
@ -149,6 +157,8 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
useEffect(() => { useEffect(() => {
const effectivePlanSku = urlPlanSku || planId; const effectivePlanSku = urlPlanSku || planId;
if (effectivePlanSku && configState.planSku !== effectivePlanSku) { if (effectivePlanSku && configState.planSku !== effectivePlanSku) {
const resetSimConfig = useCatalogStore.getState().resetSimConfig;
resetSimConfig();
setConfig({ planSku: effectivePlanSku }); setConfig({ planSku: effectivePlanSku });
} }

View File

@ -329,9 +329,13 @@ export const useCatalogStore = create<CatalogStore>()(
name: "services-config-store", name: "services-config-store",
storage: createJSONStorage(() => localStorage), storage: createJSONStorage(() => localStorage),
partialize: state => ({ partialize: state => ({
internet: state.internet, internet: {
...state.internet,
currentStep: 1,
},
sim: { sim: {
...state.sim, ...state.sim,
currentStep: 1,
eid: "", eid: "",
wantsMnp: false, wantsMnp: false,
mnpData: { ...initialSimState.mnpData }, mnpData: { ...initialSimState.mnpData },

View File

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import type { SimCatalogProduct } from "@customer-portal/domain/services"; import type { SimCatalogProduct } from "@customer-portal/domain/services";
import { usePublicSimCatalog } from "@/features/services/hooks"; import { usePublicSimCatalog } from "@/features/services/hooks";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { useCatalogStore } from "@/features/services/stores/services.store";
import { import {
SimPlansContent, SimPlansContent,
type SimPlansTab, type SimPlansTab,
@ -26,6 +27,9 @@ export function PublicSimPlansView() {
const [activeTab, setActiveTab] = useState<SimPlansTab>("data-voice"); const [activeTab, setActiveTab] = useState<SimPlansTab>("data-voice");
const handleSelectPlan = (planSku: string) => { const handleSelectPlan = (planSku: string) => {
const { resetSimConfig, setSimConfig } = useCatalogStore.getState();
resetSimConfig();
setSimConfig({ planSku, currentStep: 1 });
router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`); router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`);
}; };

View File

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import type { SimCatalogProduct } from "@customer-portal/domain/services"; import type { SimCatalogProduct } from "@customer-portal/domain/services";
import { useAccountSimCatalog } from "@/features/services/hooks"; import { useAccountSimCatalog } from "@/features/services/hooks";
import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath";
import { useCatalogStore } from "@/features/services/stores/services.store";
import { import {
SimPlansContent, SimPlansContent,
type SimPlansTab, type SimPlansTab,
@ -26,6 +27,9 @@ export function SimPlansContainer() {
const [activeTab, setActiveTab] = useState<SimPlansTab>("data-voice"); const [activeTab, setActiveTab] = useState<SimPlansTab>("data-voice");
const handleSelectPlan = (planSku: string) => { const handleSelectPlan = (planSku: string) => {
const { resetSimConfig, setSimConfig } = useCatalogStore.getState();
resetSimConfig();
setSimConfig({ planSku, currentStep: 1 });
router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`); router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`);
}; };

View File

@ -135,7 +135,7 @@ export const orderConfigurationsSchema = z.object({
isMnp: z.string().optional(), isMnp: z.string().optional(),
mnpNumber: z.string().optional(), mnpNumber: z.string().optional(),
mnpExpiry: z.string().optional(), mnpExpiry: z.string().optional(),
mnpPhone: z.string().optional(), mnpPhone: z.string().max(11, "MNP phone number must be 11 digits or fewer").optional(),
mvnoAccountNumber: z.string().optional(), mvnoAccountNumber: z.string().optional(),
portingLastName: z.string().optional(), portingLastName: z.string().optional(),
portingFirstName: z.string().optional(), portingFirstName: z.string().optional(),
@ -168,7 +168,7 @@ export const orderSelectionsSchema = z
isMnp: z.string().optional(), isMnp: z.string().optional(),
mnpNumber: z.string().optional(), mnpNumber: z.string().optional(),
mnpExpiry: z.string().optional(), mnpExpiry: z.string().optional(),
mnpPhone: z.string().optional(), mnpPhone: z.string().max(11, "MNP phone number must be 11 digits or fewer").optional(),
mvnoAccountNumber: z.string().optional(), mvnoAccountNumber: z.string().optional(),
portingLastName: z.string().optional(), portingLastName: z.string().optional(),
portingFirstName: z.string().optional(), portingFirstName: z.string().optional(),

View File

@ -401,7 +401,10 @@ export const simChangePlanFullRequestSchema = z.object({
const simMnpFormSchema = z.object({ const simMnpFormSchema = z.object({
reservationNumber: z.string().min(1, "Reservation number is required"), reservationNumber: z.string().min(1, "Reservation number is required"),
expiryDate: z.string().regex(/^\d{8}$/, "Expiry date must be in YYYYMMDD format"), expiryDate: z.string().regex(/^\d{8}$/, "Expiry date must be in YYYYMMDD format"),
phoneNumber: z.string().min(1, "Phone number is required"), phoneNumber: z
.string()
.min(1, "Phone number is required")
.max(11, "Phone number must be 11 digits or fewer"),
mvnoAccountNumber: z.string().optional(), mvnoAccountNumber: z.string().optional(),
portingLastName: z.string().optional(), portingLastName: z.string().optional(),
portingFirstName: z.string().optional(), portingFirstName: z.string().optional(),