From 454fb29c85152ec55ad81c08c4e8ea363e946af7 Mon Sep 17 00:00:00 2001 From: Temuulen Ankhbayar Date: Sat, 7 Mar 2026 11:02:25 +0900 Subject: [PATCH] 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 --- .../freebit/services/freebit-esim.service.ts | 17 ++++---- .../fulfillment-step-executors.service.ts | 31 +++++++++++++- .../orders/services/order-builder.service.ts | 24 +++++++++-- .../order-fulfillment-orchestrator.service.ts | 42 +++++++++++++++---- .../services/hooks/useInternetConfigure.ts | 4 +- .../services/hooks/useSimConfigure.ts | 16 +++++-- .../services/stores/services.store.ts | 6 ++- .../services/views/PublicSimPlans.tsx | 4 ++ .../src/features/services/views/SimPlans.tsx | 4 ++ packages/domain/orders/schema.ts | 4 +- packages/domain/sim/schema.ts | 5 ++- 11 files changed, 127 insertions(+), 30 deletions(-) diff --git a/apps/bff/src/integrations/freebit/services/freebit-esim.service.ts b/apps/bff/src/integrations/freebit/services/freebit-esim.service.ts index dd41b541..00e3080e 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-esim.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-esim.service.ts @@ -158,8 +158,9 @@ export class FreebitEsimService { }); try { - // Note: authKey is injected by makeAuthenticatedJsonRequest + // Note: authKey is injected by makeAuthenticatedRequest // createType is not required when addKind is 'R' (reissue) + // Only include defined optional fields — Freebit rejects unknown/null values const payload: Omit = { aladinOperated, ...(finalAddKind !== "R" && { createType: "new" }), @@ -167,12 +168,12 @@ export class FreebitEsimService { account, simkind: simKind || "E0", addKind: finalAddKind, - planCode, - contractLine, - shipDate, - repAccount, - deliveryCode, - globalIp, + ...(planCode && { planCode }), + ...(contractLine && { contractLine }), + ...(shipDate && { shipDate }), + ...(repAccount && { repAccount }), + ...(deliveryCode && { deliveryCode }), + ...(globalIp && { globalIp }), // MNP object includes both reservation info AND identity fields (all Level 2 per PA05-41 spec) ...(mnp ? { mnp } : {}), } as Omit; @@ -204,7 +205,7 @@ export class FreebitEsimService { : "no-mnp", }); - await this.client.makeAuthenticatedJsonRequest< + await this.client.makeAuthenticatedRequest< FreebitEsimAccountActivationResponse, Omit >("/mvno/esim/addAcnt/", payload); diff --git a/apps/bff/src/modules/orders/services/fulfillment-step-executors.service.ts b/apps/bff/src/modules/orders/services/fulfillment-step-executors.service.ts index 62a4589b..f81c01a6 100644 --- a/apps/bff/src/modules/orders/services/fulfillment-step-executors.service.ts +++ b/apps/bff/src/modules/orders/services/fulfillment-step-executors.service.ts @@ -345,6 +345,10 @@ export class FulfillmentStepExecutors { /** * Update Salesforce with WHMCS registration info * 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( ctx: OrderFulfillmentContext, @@ -361,7 +365,31 @@ export class FulfillmentStepExecutors { 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, { status: "Processed", activationStatus: "Activated", @@ -375,7 +403,6 @@ export class FulfillmentStepExecutors { }, }); await this.sideEffects.notifyOrderActivated(ctx.sfOrderId, ctx.validation?.sfOrder?.AccountId); - return result; } /** diff --git a/apps/bff/src/modules/orders/services/order-builder.service.ts b/apps/bff/src/modules/orders/services/order-builder.service.ts index f77449b5..c47174a1 100644 --- a/apps/bff/src/modules/orders/services/order-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-builder.service.ts @@ -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 */ @@ -74,7 +84,11 @@ export class OrderBuilder { const config = body.configurations || {}; assignIfString(orderFields, fieldNames.activationType, config.activationType); - assignIfString(orderFields, fieldNames.activationScheduledAt, config.scheduledAt); + assignIfString( + orderFields, + fieldNames.activationScheduledAt, + toSalesforceDate(config.scheduledAt) + ); orderFields[fieldNames.activationStatus] = "Not Started"; } @@ -99,7 +113,7 @@ export class OrderBuilder { if (config.isMnp === "true") { orderFields[fieldNames.mnpApplication] = true; 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.mvnoAccountNumber, config.mvnoAccountNumber); assignIfString(orderFields, fieldNames.portingLastName, config.portingLastName); @@ -115,7 +129,11 @@ export class OrderBuilder { config.portingFirstNameKatakana ); assignIfString(orderFields, fieldNames.portingGender, config.portingGender); - assignIfString(orderFields, fieldNames.portingDateOfBirth, config.portingDateOfBirth); + assignIfString( + orderFields, + fieldNames.portingDateOfBirth, + toSalesforceDate(config.portingDateOfBirth) + ); } } diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index 690d0fb7..d5a5988f 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -313,16 +313,17 @@ export class OrderFulfillmentOrchestrator { }); // Update Salesforce with failure status + const errorShortCode = ( + this.orderFulfillmentErrorService.getShortCode(error) || String(errorCode) + ) + .toString() + .slice(0, 60); try { await this.salesforceFacade.updateOrder({ Id: sfOrderId, Status: "Pending Review", Activation_Status__c: "Failed", - Activation_Error_Code__c: ( - this.orderFulfillmentErrorService.getShortCode(error) || String(errorCode) - ) - .toString() - .slice(0, 60), + Activation_Error_Code__c: errorShortCode, Activation_Error_Message__c: userMessage?.slice(0, 255), }); this.logger.log("Salesforce updated with failure status", { @@ -330,10 +331,33 @@ export class OrderFulfillmentOrchestrator { errorCode, }); } catch (updateError) { - this.logger.error("Failed to update Salesforce with error status", { - sfOrderId: context.sfOrderId, - updateError: updateError instanceof Error ? updateError.message : String(updateError), - }); + // If the order is already activated (e.g. auto-approved by SF Flow), + // retry without Status change — only update custom error fields + 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 diff --git a/apps/portal/src/features/services/hooks/useInternetConfigure.ts b/apps/portal/src/features/services/hooks/useInternetConfigure.ts index 1c11b534..53bee289 100644 --- a/apps/portal/src/features/services/hooks/useInternetConfigure.ts +++ b/apps/portal/src/features/services/hooks/useInternetConfigure.ts @@ -64,8 +64,10 @@ export function useInternetConfigure(): UseInternetConfigureResult { // Initialize/restore state on mount 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) { + const resetInternetConfig = useCatalogStore.getState().resetInternetConfig; + resetInternetConfig(); setConfig({ planSku: urlPlanSku }); } diff --git a/apps/portal/src/features/services/hooks/useSimConfigure.ts b/apps/portal/src/features/services/hooks/useSimConfigure.ts index a5ffd8fe..2b9de2a9 100644 --- a/apps/portal/src/features/services/hooks/useSimConfigure.ts +++ b/apps/portal/src/features/services/hooks/useSimConfigure.ts @@ -61,6 +61,11 @@ function trimOptional(value?: string | null) { 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 { return { simType: configState.simType, @@ -69,13 +74,13 @@ function buildFormData(configState: SimConfigState): SimConfigureFormData { activationType: configState.activationType, scheduledActivationDate: configState.activationType === "Scheduled" - ? trimOptional(configState.scheduledActivationDate) + ? trimOptional(configState.scheduledActivationDate)?.replace(/-/g, "") : undefined, wantsMnp: configState.wantsMnp, mnpData: configState.wantsMnp ? { reservationNumber: configState.mnpData.reservationNumber.trim(), - expiryDate: configState.mnpData.expiryDate.trim(), + expiryDate: toYYYYMMDD(configState.mnpData.expiryDate.trim()), phoneNumber: configState.mnpData.phoneNumber.trim(), mvnoAccountNumber: trimOptional(configState.mnpData.mvnoAccountNumber), portingLastName: trimOptional(configState.mnpData.portingLastName), @@ -83,7 +88,10 @@ function buildFormData(configState: SimConfigState): SimConfigureFormData { portingLastNameKatakana: trimOptional(configState.mnpData.portingLastNameKatakana), portingFirstNameKatakana: trimOptional(configState.mnpData.portingFirstNameKatakana), portingGender: trimOptional(configState.mnpData.portingGender), - portingDateOfBirth: trimOptional(configState.mnpData.portingDateOfBirth), + portingDateOfBirth: trimOptional(configState.mnpData.portingDateOfBirth)?.replace( + /-/g, + "" + ), } : undefined, }; @@ -149,6 +157,8 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult { useEffect(() => { const effectivePlanSku = urlPlanSku || planId; if (effectivePlanSku && configState.planSku !== effectivePlanSku) { + const resetSimConfig = useCatalogStore.getState().resetSimConfig; + resetSimConfig(); setConfig({ planSku: effectivePlanSku }); } diff --git a/apps/portal/src/features/services/stores/services.store.ts b/apps/portal/src/features/services/stores/services.store.ts index cb03944e..a7577a42 100644 --- a/apps/portal/src/features/services/stores/services.store.ts +++ b/apps/portal/src/features/services/stores/services.store.ts @@ -329,9 +329,13 @@ export const useCatalogStore = create()( name: "services-config-store", storage: createJSONStorage(() => localStorage), partialize: state => ({ - internet: state.internet, + internet: { + ...state.internet, + currentStep: 1, + }, sim: { ...state.sim, + currentStep: 1, eid: "", wantsMnp: false, mnpData: { ...initialSimState.mnpData }, diff --git a/apps/portal/src/features/services/views/PublicSimPlans.tsx b/apps/portal/src/features/services/views/PublicSimPlans.tsx index 58d6ced4..ee7d2572 100644 --- a/apps/portal/src/features/services/views/PublicSimPlans.tsx +++ b/apps/portal/src/features/services/views/PublicSimPlans.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import type { SimCatalogProduct } from "@customer-portal/domain/services"; import { usePublicSimCatalog } from "@/features/services/hooks"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; +import { useCatalogStore } from "@/features/services/stores/services.store"; import { SimPlansContent, type SimPlansTab, @@ -26,6 +27,9 @@ export function PublicSimPlansView() { const [activeTab, setActiveTab] = useState("data-voice"); const handleSelectPlan = (planSku: string) => { + const { resetSimConfig, setSimConfig } = useCatalogStore.getState(); + resetSimConfig(); + setSimConfig({ planSku, currentStep: 1 }); router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`); }; diff --git a/apps/portal/src/features/services/views/SimPlans.tsx b/apps/portal/src/features/services/views/SimPlans.tsx index f3849ed4..c5c92e04 100644 --- a/apps/portal/src/features/services/views/SimPlans.tsx +++ b/apps/portal/src/features/services/views/SimPlans.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import type { SimCatalogProduct } from "@customer-portal/domain/services"; import { useAccountSimCatalog } from "@/features/services/hooks"; import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePath"; +import { useCatalogStore } from "@/features/services/stores/services.store"; import { SimPlansContent, type SimPlansTab, @@ -26,6 +27,9 @@ export function SimPlansContainer() { const [activeTab, setActiveTab] = useState("data-voice"); const handleSelectPlan = (planSku: string) => { + const { resetSimConfig, setSimConfig } = useCatalogStore.getState(); + resetSimConfig(); + setSimConfig({ planSku, currentStep: 1 }); router.push(`${servicesBasePath}/sim/configure?planSku=${encodeURIComponent(planSku)}`); }; diff --git a/packages/domain/orders/schema.ts b/packages/domain/orders/schema.ts index 1fa14f0a..83ea7ad5 100644 --- a/packages/domain/orders/schema.ts +++ b/packages/domain/orders/schema.ts @@ -135,7 +135,7 @@ export const orderConfigurationsSchema = z.object({ isMnp: z.string().optional(), mnpNumber: 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(), portingLastName: z.string().optional(), portingFirstName: z.string().optional(), @@ -168,7 +168,7 @@ export const orderSelectionsSchema = z isMnp: z.string().optional(), mnpNumber: 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(), portingLastName: z.string().optional(), portingFirstName: z.string().optional(), diff --git a/packages/domain/sim/schema.ts b/packages/domain/sim/schema.ts index 367cd18b..ee1308bb 100644 --- a/packages/domain/sim/schema.ts +++ b/packages/domain/sim/schema.ts @@ -401,7 +401,10 @@ export const simChangePlanFullRequestSchema = z.object({ const simMnpFormSchema = z.object({ reservationNumber: z.string().min(1, "Reservation number is required"), 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(), portingLastName: z.string().optional(), portingFirstName: z.string().optional(),