From ea2fe816069c78b4cee2f25ca74d764490df1424 Mon Sep 17 00:00:00 2001 From: tema Date: Thu, 11 Sep 2025 14:52:26 +0900 Subject: [PATCH] Add SIM fulfillment support to order orchestration - Integrated SimFulfillmentService into OrderFulfillmentOrchestrator to handle SIM-specific fulfillment steps. - Updated initializeSteps method to conditionally include SIM fulfillment based on order type. - Enhanced CheckoutContent to validate required SIM fields for order processing, ensuring necessary data is provided before activation. --- apps/bff/src/orders/orders.module.ts | 2 + .../order-fulfillment-orchestrator.service.ts | 38 +++- .../services/sim-fulfillment.service.ts | 206 ++++++++++++++++++ apps/portal/src/app/checkout/page.tsx | 45 +--- 4 files changed, 241 insertions(+), 50 deletions(-) create mode 100644 apps/bff/src/orders/services/sim-fulfillment.service.ts diff --git a/apps/bff/src/orders/orders.module.ts b/apps/bff/src/orders/orders.module.ts index 28ed5234..48639472 100644 --- a/apps/bff/src/orders/orders.module.ts +++ b/apps/bff/src/orders/orders.module.ts @@ -15,6 +15,7 @@ import { OrderFulfillmentValidator } from "./services/order-fulfillment-validato import { OrderWhmcsMapper } from "./services/order-whmcs-mapper.service"; import { OrderFulfillmentOrchestrator } from "./services/order-fulfillment-orchestrator.service"; import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error.service"; +import { SimFulfillmentService } from "./services/sim-fulfillment.service"; import { ProvisioningQueueService } from "./queue/provisioning.queue"; import { ProvisioningProcessor } from "./queue/provisioning.processor"; @@ -33,6 +34,7 @@ import { ProvisioningProcessor } from "./queue/provisioning.processor"; OrderWhmcsMapper, OrderFulfillmentOrchestrator, OrderFulfillmentErrorService, + SimFulfillmentService, // Async provisioning queue ProvisioningQueueService, ProvisioningProcessor, diff --git a/apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts index 6c50a02f..60bed79c 100644 --- a/apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts @@ -12,6 +12,7 @@ import { } from "./order-fulfillment-validator.service"; import { OrderWhmcsMapper, OrderItemMappingResult } from "./order-whmcs-mapper.service"; import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service"; +import { SimFulfillmentService } from "./sim-fulfillment.service"; import { getErrorMessage } from "../../common/utils/error.util"; import { getSalesforceFieldMap } from "../../common/config/field-map"; @@ -48,7 +49,8 @@ export class OrderFulfillmentOrchestrator { private readonly orderOrchestrator: OrderOrchestrator, private readonly orderFulfillmentValidator: OrderFulfillmentValidator, private readonly orderWhmcsMapper: OrderWhmcsMapper, - private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService + private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService, + private readonly simFulfillmentService: SimFulfillmentService ) {} /** @@ -63,7 +65,7 @@ export class OrderFulfillmentOrchestrator { sfOrderId, idempotencyKey, validation: {} as OrderFulfillmentValidationResult, - steps: this.initializeSteps(), + steps: this.initializeSteps(payload.orderType as string), }; this.logger.log("Starting fulfillment orchestration", { @@ -170,7 +172,24 @@ export class OrderFulfillmentOrchestrator { context.whmcsResult = acceptResult; }); - // Step 7: Update Salesforce with success + // Step 7: SIM-specific fulfillment (if applicable) + if (context.orderDetails?.orderType === "SIM") { + await this.executeStep(context, "sim_fulfillment", async () => { + if (!context.orderDetails) { + throw new Error("Order details are required for SIM fulfillment"); + } + + // Extract configurations from the original payload + const configurations = payload.configurations || {}; + + await this.simFulfillmentService.fulfillSimOrder({ + orderDetails: context.orderDetails, + configurations, + }); + }); + } + + // Step 8: Update Salesforce with success await this.executeStep(context, "sf_success_update", async () => { const fields = getSalesforceFieldMap(); await this.salesforceService.updateOrder({ @@ -202,16 +221,23 @@ export class OrderFulfillmentOrchestrator { /** * Initialize fulfillment steps */ - private initializeSteps(): OrderFulfillmentStep[] { - return [ + private initializeSteps(orderType?: string): OrderFulfillmentStep[] { + const steps: OrderFulfillmentStep[] = [ { step: "validation", status: "pending" }, { step: "sf_status_update", status: "pending" }, { step: "order_details", status: "pending" }, { step: "mapping", status: "pending" }, { step: "whmcs_create", status: "pending" }, { step: "whmcs_accept", status: "pending" }, - { step: "sf_success_update", status: "pending" }, ]; + + // Add SIM fulfillment step for SIM orders + if (orderType === "SIM") { + steps.push({ step: "sim_fulfillment", status: "pending" }); + } + + steps.push({ step: "sf_success_update", status: "pending" }); + return steps; } /** diff --git a/apps/bff/src/orders/services/sim-fulfillment.service.ts b/apps/bff/src/orders/services/sim-fulfillment.service.ts new file mode 100644 index 00000000..bed657e2 --- /dev/null +++ b/apps/bff/src/orders/services/sim-fulfillment.service.ts @@ -0,0 +1,206 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { FreebititService } from "../../vendors/freebit/freebit.service"; +import { OrderDetailsDto } from "../types/order-details.dto"; +import { getSalesforceFieldMap } from "../../common/config/field-map"; + +export interface SimFulfillmentRequest { + orderDetails: OrderDetailsDto; + configurations: Record; +} + +@Injectable() +export class SimFulfillmentService { + constructor( + private readonly freebit: FreebititService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Handle SIM-specific fulfillment after WHMCs provisioning + */ + async fulfillSimOrder(request: SimFulfillmentRequest): Promise { + const { orderDetails, configurations } = request; + + this.logger.log("Starting SIM fulfillment", { + orderId: orderDetails.id, + orderType: orderDetails.orderType, + }); + + // Extract SIM-specific configurations + const simType = configurations.simType as "eSIM" | "Physical SIM" | undefined; + const eid = configurations.eid as string | undefined; + const activationType = configurations.activationType as "Immediate" | "Scheduled" | undefined; + const scheduledAt = configurations.scheduledAt as string | undefined; + const phoneNumber = configurations.mnpPhone as string | undefined; + const mnp = this.extractMnpConfig(configurations); + const addons = this.extractAddonConfig(configurations); + + // Find the main SIM plan from order items + const simPlanItem = orderDetails.items.find(item => + item.product.itemClass === "Plan" || item.product.sku?.toLowerCase().includes("sim") + ); + + if (!simPlanItem) { + throw new Error("No SIM plan found in order items"); + } + + const planSku = simPlanItem.product.sku; + if (!planSku) { + throw new Error("SIM plan SKU not found"); + } + + // Validate required fields + if (simType === "eSIM" && (!eid || eid.length < 15)) { + throw new Error("EID is required for eSIM and must be valid"); + } + + if (!phoneNumber) { + throw new Error("Phone number is required for SIM activation"); + } + + // Perform Freebit activation + await this.activateSim({ + account: phoneNumber, + eid, + planSku, + simType: simType || "eSIM", + activationType: activationType || "Immediate", + scheduledAt, + mnp, + addons, + }); + + this.logger.log("SIM fulfillment completed successfully", { + orderId: orderDetails.id, + account: phoneNumber, + planSku, + }); + } + + /** + * Activate SIM via Freebit API + */ + private async activateSim(params: { + account: string; + eid?: string; + planSku: string; + simType: "eSIM" | "Physical SIM"; + activationType: "Immediate" | "Scheduled"; + scheduledAt?: string; + mnp?: { + reserveNumber: string; + reserveExpireDate: string; + account?: string; + firstnameKanji?: string; + lastnameKanji?: string; + firstnameZenKana?: string; + lastnameZenKana?: string; + gender?: string; + birthday?: string; + }; + addons?: { + voiceMail?: boolean; + callWaiting?: boolean; + }; + }): Promise { + const { + account, + eid, + planSku, + simType, + activationType, + scheduledAt, + mnp, + addons, + } = params; + + try { + // Activate eSIM if applicable + if (simType === "eSIM") { + await this.freebit.activateEsimAccountNew({ + account, + eid: eid!, + planCode: planSku, + contractLine: "5G", + shipDate: activationType === "Scheduled" ? scheduledAt : undefined, + mnp: mnp ? { + reserveNumber: mnp.reserveNumber, + reserveExpireDate: mnp.reserveExpireDate, + } : undefined, + identity: mnp ? { + firstnameKanji: mnp.firstnameKanji, + lastnameKanji: mnp.lastnameKanji, + firstnameZenKana: mnp.firstnameZenKana, + lastnameZenKana: mnp.lastnameZenKana, + gender: mnp.gender, + birthday: mnp.birthday, + } : undefined, + }); + + this.logger.log("eSIM activated successfully", { + account, + planSku, + scheduled: activationType === "Scheduled", + }); + } else { + this.logger.warn("Physical SIM activation path is not implemented; skipping Freebit call", { + account, + }); + } + + // Apply add-ons (voice options) if selected + if (addons && (addons.voiceMail || addons.callWaiting)) { + await this.freebit.updateSimFeatures(account, { + voiceMailEnabled: !!addons.voiceMail, + callWaitingEnabled: !!addons.callWaiting, + }); + + this.logger.log("SIM add-ons applied", { + account, + voiceMail: addons.voiceMail, + callWaiting: addons.callWaiting, + }); + } + } catch (error) { + this.logger.error("SIM activation failed", { + account, + planSku, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * Extract MNP configuration from order configurations + */ + private extractMnpConfig(configurations: Record) { + const isMnp = configurations.isMnp; + if (!isMnp || isMnp !== "true") { + return undefined; + } + + return { + reserveNumber: configurations.mnpNumber as string | undefined, + reserveExpireDate: configurations.mnpExpiry as string | undefined, + account: configurations.mvnoAccountNumber as string | undefined, + firstnameKanji: configurations.portingFirstName as string | undefined, + lastnameKanji: configurations.portingLastName as string | undefined, + firstnameZenKana: configurations.portingFirstNameKatakana as string | undefined, + lastnameZenKana: configurations.portingLastNameKatakana as string | undefined, + gender: configurations.portingGender as string | undefined, + birthday: configurations.portingDateOfBirth as string | undefined, + }; + } + + /** + * Extract addon configuration from order configurations + */ + private extractAddonConfig(configurations: Record) { + // Check if voice addons are present in the configurations + // This would need to be determined based on the order items or configurations + // For now, return undefined - this can be enhanced based on actual addon detection logic + return undefined; + } +} diff --git a/apps/portal/src/app/checkout/page.tsx b/apps/portal/src/app/checkout/page.tsx index b4841ac2..b4fc67b1 100644 --- a/apps/portal/src/app/checkout/page.tsx +++ b/apps/portal/src/app/checkout/page.tsx @@ -242,57 +242,14 @@ function CheckoutContent() { ...(Object.keys(configurations).length > 0 && { configurations }), }; + // Validate required SIM fields for all order types if (orderType === "SIM") { - // Validate required SIM fields if (!selections.eid && selections.simType === "eSIM") { throw new Error("EID is required for eSIM activation. Please go back and provide your EID."); } if (!selections.phoneNumber && !selections.mnpPhone) { throw new Error("Phone number is required for SIM activation. Please go back and provide a phone number."); } - - // Build activation payload for new SIM endpoint - const activationPayload: { - planSku: string; - simType: "eSIM" | "Physical SIM"; - eid?: string; - activationType: "Immediate" | "Scheduled"; - scheduledAt?: string; - msisdn: string; - oneTimeAmountJpy: number; - monthlyAmountJpy: number; - addons?: { voiceMail?: boolean; callWaiting?: boolean }; - mnp?: { reserveNumber: string; reserveExpireDate: string }; - } = { - planSku: selections.plan, - simType: selections.simType as "eSIM" | "Physical SIM", - eid: selections.eid, - activationType: selections.activationType as "Immediate" | "Scheduled", - scheduledAt: selections.scheduledAt, - msisdn: selections.phoneNumber || selections.mnpPhone || "", - oneTimeAmountJpy: checkoutState.totals.oneTimeTotal, // Activation fee charged immediately - monthlyAmountJpy: checkoutState.totals.monthlyTotal, // Monthly subscription fee - addons: { - voiceMail: (new URLSearchParams(window.location.search).getAll("addonSku") || []).some( - sku => sku.toLowerCase().includes("voicemail") - ), - callWaiting: ( - new URLSearchParams(window.location.search).getAll("addonSku") || [] - ).some(sku => sku.toLowerCase().includes("waiting")), - }, - }; - if (selections.isMnp === "true") { - activationPayload.mnp = { - reserveNumber: selections.reservationNumber, - reserveExpireDate: selections.expiryDate, - }; - } - const result = await authenticatedApi.post<{ success: boolean }>( - "/subscriptions/sim/orders/activate", - activationPayload - ); - router.push(`/orders?status=${result.success ? "success" : "error"}`); - return; } const response = await authenticatedApi.post<{ sfOrderId: string }>("/orders", orderData);