Refactor Salesforce integration and enhance order field handling
- Added OrderFieldConfigModule to Salesforce module for improved order field management. - Refactored SalesforceOrderService to utilize OrderFieldMapService for building query fields, enhancing maintainability and consistency. - Removed deprecated order query builder utility functions, centralizing logic within the new OrderFieldMapService. - Updated OrderBuilder service to leverage field mappings for better clarity and reduced hardcoding of field names. - Improved address handling in order processing to ensure consistent field usage across services.
This commit is contained in:
parent
67691a50b5
commit
b65a49bc2f
@ -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,
|
||||
|
||||
@ -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<string, unknown>): 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(
|
||||
", "
|
||||
);
|
||||
|
||||
|
||||
@ -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 = <T>(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]);
|
||||
}
|
||||
@ -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 {}
|
||||
192
apps/bff/src/modules/orders/config/order-field-map.service.ts
Normal file
192
apps/bff/src/modules/orders/config/order-field-map.service.ts
Normal file
@ -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 = <T>(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<string>(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]);
|
||||
}
|
||||
}
|
||||
@ -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: [
|
||||
|
||||
@ -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<string, unknown>, key: string, value: unknown): void {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
@ -16,7 +17,8 @@ function assignIfString(target: Record<string, unknown>, 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<Record<string, unknown>> {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const orderFieldNames = this.orderFieldMap.fields.order;
|
||||
|
||||
const orderFields: Record<string, unknown> = {
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>, body: OrderBusinessValidation): void {
|
||||
private addSimFields(
|
||||
orderFields: Record<string, unknown>,
|
||||
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<string, unknown>,
|
||||
userId: string,
|
||||
body: OrderBusinessValidation
|
||||
body: OrderBusinessValidation,
|
||||
fieldNames: OrderFieldMapService["fields"]["order"]
|
||||
): Promise<void> {
|
||||
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");
|
||||
|
||||
@ -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<string | null>(null);
|
||||
const [addressConfirmed, setAddressConfirmed] = useState(false);
|
||||
|
||||
const isInternetOrder = orderType === "Internet";
|
||||
const isInternetOrder = orderType === ORDER_TYPE.INTERNET;
|
||||
const requiresAddressVerification = isInternetOrder;
|
||||
|
||||
const fetchBillingInfo = useCallback(async () => {
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 <CheckCircleIcon className="h-5 w-5 text-green-500" />;
|
||||
case "Completed":
|
||||
case SUBSCRIPTION_STATUS.COMPLETED:
|
||||
return <CheckCircleIcon className="h-5 w-5 text-blue-500" />;
|
||||
case "Cancelled":
|
||||
case SUBSCRIPTION_STATUS.CANCELLED:
|
||||
return <XCircleIcon className="h-5 w-5 text-gray-500" />;
|
||||
default:
|
||||
return <ClockIcon className="h-5 w-5 text-gray-500" />;
|
||||
@ -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();
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user