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:
barsa 2025-11-04 11:26:21 +09:00
parent 67691a50b5
commit b65a49bc2f
12 changed files with 473 additions and 291 deletions

View File

@ -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,

View File

@ -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(
", "
);

View File

@ -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]);
}

View File

@ -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 {}

View 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]);
}
}

View File

@ -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: [

View File

@ -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");

View File

@ -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 () => {

View File

@ -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",
});
}

View File

@ -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();

View File

@ -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",
});
}

View File

@ -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 {