diff --git a/apps/bff/src/integrations/salesforce/salesforce.module.ts b/apps/bff/src/integrations/salesforce/salesforce.module.ts index 6ed87082..d0204301 100644 --- a/apps/bff/src/integrations/salesforce/salesforce.module.ts +++ b/apps/bff/src/integrations/salesforce/salesforce.module.ts @@ -5,9 +5,10 @@ import { SalesforceService } from "./salesforce.service"; import { SalesforceConnection } from "./services/salesforce-connection.service"; import { SalesforceAccountService } from "./services/salesforce-account.service"; import { SalesforceOrderService } from "./services/salesforce-order.service"; +import { OrderFieldConfigModule } from "@bff/modules/orders/config/order-field-config.module"; @Module({ - imports: [QueueModule, ConfigModule], + imports: [QueueModule, ConfigModule, OrderFieldConfigModule], providers: [ SalesforceConnection, SalesforceAccountService, diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts index dc6eb223..a0496112 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-order.service.ts @@ -14,11 +14,6 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "./salesforce-connection.service"; -import { - buildOrderSelectFields, - buildOrderItemSelectFields, - buildOrderItemProduct2Fields, -} from "../utils/order-query-builder"; import { assertSalesforceId, buildInClause } from "../utils/soql.util"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { @@ -29,6 +24,7 @@ import { type SalesforceOrderItemRecord, } from "@customer-portal/domain/orders"; import type { SalesforceResponse } from "@customer-portal/domain/common"; +import { OrderFieldMapService } from "@bff/modules/orders/config/order-field-map.service"; /** * Salesforce Order Service @@ -40,7 +36,8 @@ import type { SalesforceResponse } from "@customer-portal/domain/common"; export class SalesforceOrderService { constructor( private readonly sf: SalesforceConnection, - @Inject(Logger) private readonly logger: Logger + @Inject(Logger) private readonly logger: Logger, + private readonly orderFieldMap: OrderFieldMapService ) {} /** @@ -51,11 +48,14 @@ export class SalesforceOrderService { this.logger.log({ orderId: safeOrderId }, "Fetching order details with items"); // Build queries - const orderQueryFields = buildOrderSelectFields(["Account.Name"]).join(", "); - const orderItemProduct2Fields = buildOrderItemProduct2Fields().map( + const orderQueryFields = this.orderFieldMap.buildOrderSelectFields(["Account.Name"]).join(", "); + const orderItemProduct2Fields = this.orderFieldMap.buildOrderItemProduct2Fields().map( f => `PricebookEntry.Product2.${f}` ); - const orderItemSelect = [...buildOrderItemSelectFields(), ...orderItemProduct2Fields].join( + const orderItemSelect = [ + ...this.orderFieldMap.buildOrderItemSelectFields(), + ...orderItemProduct2Fields, + ].join( ", " ); @@ -109,7 +109,8 @@ export class SalesforceOrderService { * Create a new order in Salesforce */ async createOrder(orderFields: Record): Promise<{ id: string }> { - this.logger.log({ orderType: orderFields.Type }, "Creating Salesforce Order"); + const typeField = this.orderFieldMap.fields.order.type; + this.logger.log({ orderType: orderFields[typeField] }, "Creating Salesforce Order"); try { const created = (await this.sf.sobject("Order").create(orderFields)) as { id: string }; @@ -118,7 +119,7 @@ export class SalesforceOrderService { } catch (error: unknown) { this.logger.error("Failed to create Salesforce Order", { error: getErrorMessage(error), - orderType: orderFields.Type, + orderType: orderFields[typeField], }); throw error; } @@ -132,11 +133,14 @@ export class SalesforceOrderService { this.logger.log({ sfAccountId: safeAccountId }, "Fetching orders for account"); // Build queries - const orderQueryFields = buildOrderSelectFields().join(", "); - const orderItemProduct2Fields = buildOrderItemProduct2Fields().map( + const orderQueryFields = this.orderFieldMap.buildOrderSelectFields().join(", "); + const orderItemProduct2Fields = this.orderFieldMap.buildOrderItemProduct2Fields().map( f => `PricebookEntry.Product2.${f}` ); - const orderItemSelect = [...buildOrderItemSelectFields(), ...orderItemProduct2Fields].join( + const orderItemSelect = [ + ...this.orderFieldMap.buildOrderItemSelectFields(), + ...orderItemProduct2Fields, + ].join( ", " ); diff --git a/apps/bff/src/integrations/salesforce/utils/order-query-builder.ts b/apps/bff/src/integrations/salesforce/utils/order-query-builder.ts deleted file mode 100644 index 5c57fc3c..00000000 --- a/apps/bff/src/integrations/salesforce/utils/order-query-builder.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Salesforce Order Query Builders - * - * SOQL query field builders for Orders and OrderItems. - * Moved from domain layer - these are infrastructure concerns, not business logic. - */ - -const UNIQUE = (values: T[]): T[] => Array.from(new Set(values)); - -/** - * Build field list for Order queries - */ -export function buildOrderSelectFields(additional: string[] = []): string[] { - const fields = [ - "Id", - "AccountId", - "Status", - "Type", - "EffectiveDate", - "OrderNumber", - "TotalAmount", - "CreatedDate", - "LastModifiedDate", - "Pricebook2Id", - "Activation_Type__c", - "Activation_Status__c", - "Activation_Scheduled_At__c", - "Activation_Error_Code__c", - "Activation_Error_Message__c", - "ActivatedDate", - "Internet_Plan_Tier__c", - "Installment_Plan__c", - "Access_Mode__c", - "Weekend_Install__c", - "Hikari_Denwa__c", - "VPN_Region__c", - "SIM_Type__c", - "SIM_Voice_Mail__c", - "SIM_Call_Waiting__c", - "EID__c", - "WHMCS_Order_ID__c", - "Address_Changed__c", - ]; - - return UNIQUE([...fields, ...additional]); -} - -/** - * Build field list for OrderItem queries - */ -export function buildOrderItemSelectFields(additional: string[] = []): string[] { - const fields = [ - "Id", - "OrderId", - "Quantity", - "UnitPrice", - "TotalPrice", - "PricebookEntry.Id", - "WHMCS_Service_ID__c", - ]; - - return UNIQUE([...fields, ...additional]); -} - -/** - * Build field list for Product2 fields within OrderItem queries - */ -export function buildOrderItemProduct2Fields(additional: string[] = []): string[] { - const fields = [ - "Id", - "Name", - "StockKeepingUnit", - "Item_Class__c", - "Billing_Cycle__c", - "WH_Product_ID__c", - "Internet_Offering_Type__c", - "Internet_Plan_Tier__c", - "VPN_Region__c", - ]; - - return UNIQUE([...fields, ...additional]); -} diff --git a/apps/bff/src/modules/orders/config/order-field-config.module.ts b/apps/bff/src/modules/orders/config/order-field-config.module.ts new file mode 100644 index 00000000..efba0fb6 --- /dev/null +++ b/apps/bff/src/modules/orders/config/order-field-config.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { OrderFieldMapService } from "./order-field-map.service"; + +@Module({ + imports: [ConfigModule], + providers: [OrderFieldMapService], + exports: [OrderFieldMapService], +}) +export class OrderFieldConfigModule {} diff --git a/apps/bff/src/modules/orders/config/order-field-map.service.ts b/apps/bff/src/modules/orders/config/order-field-map.service.ts new file mode 100644 index 00000000..03cbab42 --- /dev/null +++ b/apps/bff/src/modules/orders/config/order-field-map.service.ts @@ -0,0 +1,192 @@ +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +export interface OrderFieldMap { + order: { + type: string; + activationType: string; + activationScheduledAt: string; + activationStatus: string; + activationErrorCode: string; + activationErrorMessage: string; + activationLastAttemptAt: string; + internetPlanTier: string; + installationType: string; + weekendInstall: string; + accessMode: string; + hikariDenwa: string; + vpnRegion: string; + simType: string; + simVoiceMail: string; + simCallWaiting: string; + eid: string; + whmcsOrderId: string; + addressChanged: string; + billingStreet: string; + billingCity: string; + billingState: string; + billingPostalCode: string; + billingCountry: string; + mnpApplication: string; + mnpReservation: string; + mnpExpiry: string; + mnpPhone: string; + mvnoAccountNumber: string; + portingDateOfBirth: string; + portingFirstName: string; + portingLastName: string; + portingFirstNameKatakana: string; + portingLastNameKatakana: string; + portingGender: string; + }; + orderItem: { + billingCycle: string; + whmcsServiceId: string; + }; + product: { + sku: string; + itemClass: string; + billingCycle: string; + whmcsProductId: string; + internetOfferingType: string; + internetPlanTier: string; + vpnRegion: string; + }; +} + +const unique = (values: T[]): T[] => Array.from(new Set(values)); + +@Injectable() +export class OrderFieldMapService { + readonly fields: OrderFieldMap; + + constructor(private readonly config: ConfigService) { + const resolve = (key: string) => this.config.get(key, { infer: true }) ?? key; + + this.fields = { + order: { + type: resolve("ORDER_TYPE_FIELD"), + activationType: resolve("ORDER_ACTIVATION_TYPE_FIELD"), + activationScheduledAt: resolve("ORDER_ACTIVATION_SCHEDULED_AT_FIELD"), + activationStatus: resolve("ORDER_ACTIVATION_STATUS_FIELD"), + activationErrorCode: resolve("ORDER_ACTIVATION_ERROR_CODE_FIELD"), + activationErrorMessage: resolve("ORDER_ACTIVATION_ERROR_MESSAGE_FIELD"), + activationLastAttemptAt: resolve("ORDER_ACTIVATION_LAST_ATTEMPT_AT_FIELD"), + internetPlanTier: resolve("ORDER_INTERNET_PLAN_TIER_FIELD"), + installationType: resolve("ORDER_INSTALLATION_TYPE_FIELD"), + weekendInstall: resolve("ORDER_WEEKEND_INSTALL_FIELD"), + accessMode: resolve("ORDER_ACCESS_MODE_FIELD"), + hikariDenwa: resolve("ORDER_HIKARI_DENWA_FIELD"), + vpnRegion: resolve("ORDER_VPN_REGION_FIELD"), + simType: resolve("ORDER_SIM_TYPE_FIELD"), + simVoiceMail: resolve("ORDER_SIM_VOICE_MAIL_FIELD"), + simCallWaiting: resolve("ORDER_SIM_CALL_WAITING_FIELD"), + eid: resolve("ORDER_EID_FIELD"), + whmcsOrderId: resolve("ORDER_WHMCS_ORDER_ID_FIELD"), + addressChanged: resolve("ORDER_ADDRESS_CHANGED_FIELD"), + billingStreet: resolve("ORDER_BILLING_STREET_FIELD"), + billingCity: resolve("ORDER_BILLING_CITY_FIELD"), + billingState: resolve("ORDER_BILLING_STATE_FIELD"), + billingPostalCode: resolve("ORDER_BILLING_POSTAL_CODE_FIELD"), + billingCountry: resolve("ORDER_BILLING_COUNTRY_FIELD"), + mnpApplication: resolve("ORDER_MNP_APPLICATION_FIELD"), + mnpReservation: resolve("ORDER_MNP_RESERVATION_FIELD"), + mnpExpiry: resolve("ORDER_MNP_EXPIRY_FIELD"), + mnpPhone: resolve("ORDER_MNP_PHONE_FIELD"), + mvnoAccountNumber: resolve("ORDER_MVNO_ACCOUNT_NUMBER_FIELD"), + portingDateOfBirth: resolve("ORDER_PORTING_DOB_FIELD"), + portingFirstName: resolve("ORDER_PORTING_FIRST_NAME_FIELD"), + portingLastName: resolve("ORDER_PORTING_LAST_NAME_FIELD"), + portingFirstNameKatakana: resolve("ORDER_PORTING_FIRST_NAME_KATAKANA_FIELD"), + portingLastNameKatakana: resolve("ORDER_PORTING_LAST_NAME_KATAKANA_FIELD"), + portingGender: resolve("ORDER_PORTING_GENDER_FIELD"), + }, + orderItem: { + billingCycle: resolve("ORDER_ITEM_BILLING_CYCLE_FIELD"), + whmcsServiceId: resolve("ORDER_ITEM_WHMCS_SERVICE_ID_FIELD"), + }, + product: { + sku: resolve("PRODUCT_SKU_FIELD"), + itemClass: resolve("PRODUCT_ITEM_CLASS_FIELD"), + billingCycle: resolve("PRODUCT_BILLING_CYCLE_FIELD"), + whmcsProductId: resolve("PRODUCT_WHMCS_PRODUCT_ID_FIELD"), + internetOfferingType: resolve("PRODUCT_INTERNET_OFFERING_TYPE_FIELD"), + internetPlanTier: resolve("PRODUCT_INTERNET_PLAN_TIER_FIELD"), + vpnRegion: resolve("PRODUCT_VPN_REGION_FIELD"), + }, + }; + } + + buildOrderSelectFields(additional: string[] = []): string[] { + const { order } = this.fields; + const base = [ + "Id", + "AccountId", + "Status", + order.type, + "EffectiveDate", + "OrderNumber", + "TotalAmount", + "CreatedDate", + "LastModifiedDate", + "Pricebook2Id", + order.activationType, + order.activationStatus, + order.activationScheduledAt, + order.activationErrorCode, + order.activationErrorMessage, + order.activationLastAttemptAt, + order.internetPlanTier, + order.installationType, + order.accessMode, + order.weekendInstall, + order.hikariDenwa, + order.vpnRegion, + order.simType, + order.simVoiceMail, + order.simCallWaiting, + order.eid, + order.whmcsOrderId, + order.addressChanged, + order.billingStreet, + order.billingCity, + order.billingState, + order.billingPostalCode, + order.billingCountry, + ]; + + return unique([...base, ...additional]); + } + + buildOrderItemSelectFields(additional: string[] = []): string[] { + const { orderItem } = this.fields; + const base = [ + "Id", + "OrderId", + "Quantity", + "UnitPrice", + "TotalPrice", + "PricebookEntry.Id", + orderItem.whmcsServiceId, + ]; + + return unique([...base, ...additional]); + } + + buildOrderItemProduct2Fields(additional: string[] = []): string[] { + const { product } = this.fields; + const base = [ + "Id", + "Name", + product.sku, + product.itemClass, + product.billingCycle, + product.whmcsProductId, + product.internetOfferingType, + product.internetPlanTier, + product.vpnRegion, + ]; + + return unique([...base, ...additional]); + } +} diff --git a/apps/bff/src/modules/orders/orders.module.ts b/apps/bff/src/modules/orders/orders.module.ts index e73d23b4..54f13263 100644 --- a/apps/bff/src/modules/orders/orders.module.ts +++ b/apps/bff/src/modules/orders/orders.module.ts @@ -24,6 +24,7 @@ import { OrderFulfillmentErrorService } from "./services/order-fulfillment-error import { SimFulfillmentService } from "./services/sim-fulfillment.service"; import { ProvisioningQueueService } from "./queue/provisioning.queue"; import { ProvisioningProcessor } from "./queue/provisioning.processor"; +import { OrderFieldConfigModule } from "./config/order-field-config.module"; @Module({ imports: [ @@ -33,6 +34,7 @@ import { ProvisioningProcessor } from "./queue/provisioning.processor"; CoreConfigModule, DatabaseModule, CatalogModule, + OrderFieldConfigModule, ], controllers: [OrdersController, CheckoutController], providers: [ 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 c7a5c8e4..edfcbcc7 100644 --- a/apps/bff/src/modules/orders/services/order-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-builder.service.ts @@ -2,6 +2,7 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import type { OrderBusinessValidation, UserMapping } from "@customer-portal/domain/orders"; import { UsersService } from "@bff/modules/users/users.service"; +import { OrderFieldMapService } from "@bff/modules/orders/config/order-field-map.service"; function assignIfString(target: Record, key: string, value: unknown): void { if (typeof value === "string" && value.trim().length > 0) { @@ -16,7 +17,8 @@ function assignIfString(target: Record, key: string, value: unk export class OrderBuilder { constructor( @Inject(Logger) private readonly logger: Logger, - private readonly usersService: UsersService + private readonly usersService: UsersService, + private readonly orderFieldMap: OrderFieldMapService ) {} async buildOrderFields( @@ -26,75 +28,82 @@ export class OrderBuilder { userId: string ): Promise> { const today = new Date().toISOString().slice(0, 10); + const orderFieldNames = this.orderFieldMap.fields.order; const orderFields: Record = { AccountId: userMapping.sfAccountId, EffectiveDate: today, Status: "Pending Review", Pricebook2Id: pricebookId, - Type: body.orderType, + [orderFieldNames.type]: body.orderType, ...(body.opportunityId ? { OpportunityId: body.opportunityId } : {}), }; - this.addActivationFields(orderFields, body); + this.addActivationFields(orderFields, body, orderFieldNames); switch (body.orderType) { case "Internet": - this.addInternetFields(orderFields, body); + this.addInternetFields(orderFields, body, orderFieldNames); break; case "SIM": - this.addSimFields(orderFields, body); + this.addSimFields(orderFields, body, orderFieldNames); break; case "VPN": this.addVpnFields(orderFields, body); break; } - await this.addAddressSnapshot(orderFields, userId, body); + await this.addAddressSnapshot(orderFields, userId, body, orderFieldNames); return orderFields; } private addActivationFields( orderFields: Record, - body: OrderBusinessValidation + body: OrderBusinessValidation, + fieldNames: OrderFieldMapService["fields"]["order"] ): void { const config = body.configurations || {}; - assignIfString(orderFields, "Activation_Type__c", config.activationType); - assignIfString(orderFields, "Activation_Scheduled_At__c", config.scheduledAt); - orderFields.Activation_Status__c = "Not Started"; + assignIfString(orderFields, fieldNames.activationType, config.activationType); + assignIfString(orderFields, fieldNames.activationScheduledAt, config.scheduledAt); + orderFields[fieldNames.activationStatus] = "Not Started"; } private addInternetFields( orderFields: Record, - body: OrderBusinessValidation + body: OrderBusinessValidation, + fieldNames: OrderFieldMapService["fields"]["order"] ): void { const config = body.configurations || {}; - assignIfString(orderFields, "Access_Mode__c", config.accessMode); + assignIfString(orderFields, fieldNames.accessMode, config.accessMode); } - private addSimFields(orderFields: Record, body: OrderBusinessValidation): void { + private addSimFields( + orderFields: Record, + body: OrderBusinessValidation, + fieldNames: OrderFieldMapService["fields"]["order"] + ): void { const config = body.configurations || {}; - assignIfString(orderFields, "SIM_Type__c", config.simType); - assignIfString(orderFields, "EID__c", config.eid); + assignIfString(orderFields, fieldNames.simType, config.simType); + assignIfString(orderFields, fieldNames.eid, config.eid); if (config.isMnp === "true") { - orderFields.MNP_Application__c = true; - assignIfString(orderFields, "MNP_Reservation_Number__c", config.mnpNumber); - assignIfString(orderFields, "MNP_Expiry_Date__c", config.mnpExpiry); - assignIfString(orderFields, "MNP_Phone_Number__c", config.mnpPhone); - assignIfString(orderFields, "MVNO_Account_Number__c", config.mvnoAccountNumber); - assignIfString(orderFields, "Porting_Last_Name__c", config.portingLastName); - assignIfString(orderFields, "Porting_First_Name__c", config.portingFirstName); - assignIfString(orderFields, "Porting_Last_Name_Katakana__c", config.portingLastNameKatakana); + orderFields[fieldNames.mnpApplication] = true; + assignIfString(orderFields, fieldNames.mnpReservation, config.mnpNumber); + assignIfString(orderFields, fieldNames.mnpExpiry, config.mnpExpiry); + assignIfString(orderFields, fieldNames.mnpPhone, config.mnpPhone); + assignIfString(orderFields, fieldNames.mvnoAccountNumber, config.mvnoAccountNumber); + assignIfString(orderFields, fieldNames.portingLastName, config.portingLastName); + assignIfString(orderFields, fieldNames.portingFirstName, config.portingFirstName); + assignIfString(orderFields, fieldNames.portingLastNameKatakana, config.portingLastNameKatakana); assignIfString( orderFields, - "Porting_First_Name_Katakana__c", + fieldNames.portingFirstNameKatakana, config.portingFirstNameKatakana ); - assignIfString(orderFields, "Porting_Gender__c", config.portingGender); - assignIfString(orderFields, "Porting_Date_Of_Birth__c", config.portingDateOfBirth); + assignIfString(orderFields, fieldNames.portingGender, config.portingGender); + assignIfString(orderFields, fieldNames.portingDateOfBirth, config.portingDateOfBirth); } } @@ -108,7 +117,8 @@ export class OrderBuilder { private async addAddressSnapshot( orderFields: Record, userId: string, - body: OrderBusinessValidation + body: OrderBusinessValidation, + fieldNames: OrderFieldMapService["fields"]["order"] ): Promise { try { const profile = await this.usersService.getProfile(userId); @@ -122,14 +132,16 @@ export class OrderBuilder { const address2 = typeof addressToUse?.address2 === "string" ? addressToUse.address2 : ""; const fullStreet = [address1, address2].filter(Boolean).join(", "); - orderFields.BillingStreet = fullStreet; - orderFields.BillingCity = typeof addressToUse?.city === "string" ? addressToUse.city : ""; - orderFields.BillingState = typeof addressToUse?.state === "string" ? addressToUse.state : ""; - orderFields.BillingPostalCode = + orderFields[fieldNames.billingStreet] = fullStreet; + orderFields[fieldNames.billingCity] = + typeof addressToUse?.city === "string" ? addressToUse.city : ""; + orderFields[fieldNames.billingState] = + typeof addressToUse?.state === "string" ? addressToUse.state : ""; + orderFields[fieldNames.billingPostalCode] = typeof addressToUse?.postcode === "string" ? addressToUse.postcode : ""; - orderFields.BillingCountry = + orderFields[fieldNames.billingCountry] = typeof addressToUse?.country === "string" ? addressToUse.country : ""; - orderFields.Address_Changed__c = addressChanged; + orderFields[fieldNames.addressChanged] = addressChanged; if (addressChanged) { this.logger.log({ userId }, "Customer updated address during checkout"); diff --git a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx index 8a20eeea..0da93dd5 100644 --- a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx +++ b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx @@ -14,6 +14,7 @@ import { COUNTRY_OPTIONS, getCountryName } from "@/lib/constants/countries"; // Use canonical Address type from domain import type { Address } from "@customer-portal/domain/customer"; +import { ORDER_TYPE } from "@customer-portal/domain/orders"; interface BillingInfo { company: string | null; @@ -43,7 +44,7 @@ export function AddressConfirmation({ const [error, setError] = useState(null); const [addressConfirmed, setAddressConfirmed] = useState(false); - const isInternetOrder = orderType === "Internet"; + const isInternetOrder = orderType === ORDER_TYPE.INTERNET; const requiresAddressVerification = isInternetOrder; const fetchBillingInfo = useCallback(async () => { diff --git a/apps/portal/src/features/orders/utils/order-presenters.ts b/apps/portal/src/features/orders/utils/order-presenters.ts index 34aedc5d..5f183ffb 100644 --- a/apps/portal/src/features/orders/utils/order-presenters.ts +++ b/apps/portal/src/features/orders/utils/order-presenters.ts @@ -1,103 +1,18 @@ -import { normalizeBillingCycle } from "@customer-portal/domain/orders"; +export { + normalizeBillingCycle, + deriveOrderStatusDescriptor, + getOrderServiceCategory, + getOrderServiceCategory as getServiceCategory, + calculateOrderTotals, + formatScheduledDate, +} from "@customer-portal/domain/orders"; -export { normalizeBillingCycle } from "@customer-portal/domain/orders"; - -export type OrderServiceCategory = "internet" | "sim" | "vpn" | "default"; - -export type StatusTone = "success" | "info" | "warning" | "neutral"; - -export type OrderStatusState = "active" | "review" | "scheduled" | "activating" | "processing"; - -export interface OrderStatusDescriptor { - label: string; - state: OrderStatusState; - tone: StatusTone; - description: string; - nextAction?: string; - timeline?: string; - scheduledDate?: string; -} - -interface StatusInput { - status: string; - activationStatus?: string; - activationType?: string; - scheduledAt?: string; -} - -export function deriveOrderStatusDescriptor({ - status, - activationStatus, - scheduledAt, -}: StatusInput): OrderStatusDescriptor { - if (activationStatus === "Activated") { - return { - label: "Service Active", - state: "active", - tone: "success", - description: "Your service is active and ready to use", - timeline: "Service activated successfully", - }; - } - - if (status === "Draft" || status === "Pending Review") { - return { - label: "Under Review", - state: "review", - tone: "info", - description: "Our team is reviewing your order details", - nextAction: "We will contact you within 1 business day with next steps", - timeline: "Review typically takes 1 business day", - }; - } - - if (activationStatus === "Scheduled") { - const scheduledDate = formatScheduledDate(scheduledAt); - return { - label: "Installation Scheduled", - state: "scheduled", - tone: "warning", - description: "Your installation has been scheduled", - nextAction: scheduledDate - ? `Installation scheduled for ${scheduledDate}` - : "Installation will be scheduled shortly", - timeline: "Please be available during the scheduled time", - scheduledDate, - }; - } - - if (activationStatus === "Activating") { - return { - label: "Setting Up Service", - state: "activating", - tone: "info", - description: "We're configuring your service", - nextAction: "Installation team will contact you to schedule", - timeline: "Setup typically takes 3-5 business days", - }; - } - - return { - label: status || "Processing", - state: "processing", - tone: "neutral", - description: "Your order is being processed", - timeline: "We will update you as progress is made", - }; -} - -export function getServiceCategory(orderType?: string): OrderServiceCategory { - switch (orderType) { - case "Internet": - return "internet"; - case "SIM": - return "sim"; - case "VPN": - return "vpn"; - default: - return "default"; - } -} +export type { + OrderStatusDescriptor, + OrderStatusState, + OrderStatusTone, + OrderServiceCategory, +} from "@customer-portal/domain/orders"; export function summarizePrimaryItem( items: Array<{ name?: string; quantity?: number }> | undefined, @@ -115,49 +30,3 @@ export function summarizePrimaryItem( } return summary; } - -export interface OrderTotals { - monthlyTotal: number; - oneTimeTotal: number; -} - -export function calculateOrderTotals( - items: Array<{ totalPrice?: number; billingCycle?: string }> | undefined, - fallbackTotal?: number -): OrderTotals { - let monthlyTotal = 0; - let oneTimeTotal = 0; - - if (items && items.length > 0) { - for (const item of items) { - const total = item.totalPrice ?? 0; - const billingCycle = normalizeBillingCycle(item.billingCycle); - switch (billingCycle) { - case "monthly": - monthlyTotal += total; - break; - case "onetime": - case "free": - oneTimeTotal += total; - break; - default: - monthlyTotal += total; - } - } - } else if (typeof fallbackTotal === "number") { - monthlyTotal = fallbackTotal; - } - - return { monthlyTotal, oneTimeTotal }; -} - -export function formatScheduledDate(scheduledAt?: string): string | undefined { - if (!scheduledAt) return undefined; - const date = new Date(scheduledAt); - if (Number.isNaN(date.getTime())) return undefined; - return date.toLocaleDateString("en-US", { - weekday: "long", - month: "long", - day: "numeric", - }); -} diff --git a/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx b/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx index 63cc43fc..493fc46b 100644 --- a/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx +++ b/apps/portal/src/features/subscriptions/components/SubscriptionTable/SubscriptionTable.tsx @@ -12,7 +12,11 @@ import { } from "@heroicons/react/24/outline"; import { DataTable } from "@/components/molecules/DataTable/DataTable"; import { StatusPill } from "@/components/atoms/status-pill"; -import type { Subscription } from "@customer-portal/domain/subscriptions"; +import { + SUBSCRIPTION_STATUS, + SUBSCRIPTION_CYCLE, + type Subscription, +} from "@customer-portal/domain/subscriptions"; import { Formatting } from "@customer-portal/domain/toolkit"; import { cn } from "@/lib/utils"; @@ -27,11 +31,11 @@ interface SubscriptionTableProps { const getStatusIcon = (status: string) => { switch (status) { - case "Active": + case SUBSCRIPTION_STATUS.ACTIVE: return ; - case "Completed": + case SUBSCRIPTION_STATUS.COMPLETED: return ; - case "Cancelled": + case SUBSCRIPTION_STATUS.CANCELLED: return ; default: return ; @@ -40,11 +44,11 @@ const getStatusIcon = (status: string) => { const getStatusVariant = (status: string) => { switch (status) { - case "Active": + case SUBSCRIPTION_STATUS.ACTIVE: return "success" as const; - case "Completed": + case SUBSCRIPTION_STATUS.COMPLETED: return "info" as const; - case "Cancelled": + case SUBSCRIPTION_STATUS.CANCELLED: return "neutral" as const; default: return "neutral" as const; @@ -54,21 +58,21 @@ const getStatusVariant = (status: string) => { // Simple UI helper - converts cycle to display text const getBillingPeriodText = (cycle: string): string => { switch (cycle) { - case "Monthly": + case SUBSCRIPTION_CYCLE.MONTHLY: return "per month"; - case "Annually": + case SUBSCRIPTION_CYCLE.ANNUALLY: return "per year"; - case "Quarterly": + case SUBSCRIPTION_CYCLE.QUARTERLY: return "per quarter"; - case "Semi-Annually": + case SUBSCRIPTION_CYCLE.SEMI_ANNUALLY: return "per 6 months"; - case "Biennially": + case SUBSCRIPTION_CYCLE.BIENNIALLY: return "per 2 years"; - case "Triennially": + case SUBSCRIPTION_CYCLE.TRIENNIALLY: return "per 3 years"; - case "One-time": + case SUBSCRIPTION_CYCLE.ONE_TIME: return "one-time"; - case "Free": + case SUBSCRIPTION_CYCLE.FREE: return "free"; default: return cycle.toLowerCase(); diff --git a/packages/domain/orders/helpers.ts b/packages/domain/orders/helpers.ts index ab30a2e6..1ccceb9e 100644 --- a/packages/domain/orders/helpers.ts +++ b/packages/domain/orders/helpers.ts @@ -4,6 +4,8 @@ import { type OrderConfigurations, type OrderSelections, } from "./schema"; +import { ORDER_TYPE } from "./contract"; +import type { CheckoutTotals } from "./contract"; import type { SimConfigureFormData } from "../sim"; import type { WhmcsOrderItem } from "./providers/whmcs/raw.types"; @@ -138,3 +140,160 @@ export function buildSimOrderConfigurations( export function normalizeOrderSelections(value: unknown): OrderSelections { return orderSelectionsSchema.parse(value); } + +export type OrderStatusTone = "success" | "info" | "warning" | "neutral"; + +export type OrderStatusState = "active" | "review" | "scheduled" | "activating" | "processing"; + +export interface OrderStatusDescriptor { + label: string; + state: OrderStatusState; + tone: OrderStatusTone; + description: string; + nextAction?: string; + timeline?: string; + scheduledDate?: string; +} + +export interface OrderStatusInput { + status?: string; + activationStatus?: string; + scheduledAt?: string; +} + +/** + * Convert backend activation status into a UI-ready descriptor so messaging stays consistent. + */ +export function deriveOrderStatusDescriptor({ + status, + activationStatus, + scheduledAt, +}: OrderStatusInput): OrderStatusDescriptor { + if (activationStatus === "Activated") { + return { + label: "Service Active", + state: "active", + tone: "success", + description: "Your service is active and ready to use", + timeline: "Service activated successfully", + }; + } + + if (status === "Draft" || status === "Pending Review") { + return { + label: "Under Review", + state: "review", + tone: "info", + description: "Our team is reviewing your order details", + nextAction: "We will contact you within 1 business day with next steps", + timeline: "Review typically takes 1 business day", + }; + } + + if (activationStatus === "Scheduled") { + const scheduledDate = formatScheduledDate(scheduledAt); + return { + label: "Installation Scheduled", + state: "scheduled", + tone: "warning", + description: "Your installation has been scheduled", + nextAction: scheduledDate + ? `Installation scheduled for ${scheduledDate}` + : "Installation will be scheduled shortly", + timeline: "Please be available during the scheduled time", + scheduledDate, + }; + } + + if (activationStatus === "Activating") { + return { + label: "Setting Up Service", + state: "activating", + tone: "info", + description: "We're configuring your service", + nextAction: "Installation team will contact you to schedule", + timeline: "Setup typically takes 3-5 business days", + }; + } + + return { + label: status || "Processing", + state: "processing", + tone: "neutral", + description: "Your order is being processed", + timeline: "We will update you as progress is made", + }; +} + +export type OrderServiceCategory = "internet" | "sim" | "vpn" | "default"; + +/** + * Normalize order type into a UI category identifier. + */ +export function getOrderServiceCategory(orderType?: string): OrderServiceCategory { + switch (orderType) { + case ORDER_TYPE.INTERNET: + return "internet"; + case ORDER_TYPE.SIM: + return "sim"; + case ORDER_TYPE.VPN: + return "vpn"; + default: + return "default"; + } +} + +export interface OrderTotalsInputItem { + totalPrice?: number | null | undefined; + billingCycle?: string | null | undefined; +} + +/** + * Aggregate order item totals by billing cadence using shared normalization rules. + */ +export function calculateOrderTotals( + items?: OrderTotalsInputItem[] | null, + fallbackTotal?: number | null +): CheckoutTotals { + let monthlyTotal = 0; + let oneTimeTotal = 0; + + if (Array.isArray(items) && items.length > 0) { + for (const item of items) { + const total = typeof item?.totalPrice === "number" ? item.totalPrice : 0; + const billingCycle = normalizeBillingCycle(item?.billingCycle); + switch (billingCycle) { + case "monthly": + monthlyTotal += total; + break; + case "onetime": + case "free": + oneTimeTotal += total; + break; + default: + monthlyTotal += total; + } + } + } else if (typeof fallbackTotal === "number") { + monthlyTotal = fallbackTotal; + } + + return { + monthlyTotal, + oneTimeTotal, + }; +} + +/** + * Format scheduled activation dates consistently for display. + */ +export function formatScheduledDate(scheduledAt?: string | null): string | undefined { + if (!scheduledAt) return undefined; + const date = new Date(scheduledAt); + if (Number.isNaN(date.getTime())) return undefined; + return date.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + }); +} diff --git a/packages/domain/orders/index.ts b/packages/domain/orders/index.ts index 5c25a53c..bdb7b206 100644 --- a/packages/domain/orders/index.ts +++ b/packages/domain/orders/index.ts @@ -45,6 +45,16 @@ export { normalizeBillingCycle, normalizeOrderSelections, type BuildSimOrderConfigurationsOptions, + type OrderStatusDescriptor, + type OrderStatusInput, + type OrderStatusState, + type OrderStatusTone, + type OrderServiceCategory, + type OrderTotalsInputItem, + deriveOrderStatusDescriptor, + getOrderServiceCategory, + calculateOrderTotals, + formatScheduledDate, } from "./helpers"; // Re-export types for convenience export type {