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