Refactor Salesforce integration by removing the SalesforceFieldMapService from various modules and updating import paths to utilize direct field references. This change enhances type safety and maintainability while streamlining data handling in order-related services and catalog modules.
This commit is contained in:
parent
88d58f9ac5
commit
e5c5f352f2
@ -4,7 +4,6 @@ import { QueueModule } from "@bff/core/queue/queue.module";
|
||||
import { SalesforceService } from "./salesforce.service";
|
||||
import { SalesforceConnection } from "./services/salesforce-connection.service";
|
||||
import { SalesforceAccountService } from "./services/salesforce-account.service";
|
||||
import { SalesforceFieldMapService } from "./services/salesforce-field-config.service";
|
||||
|
||||
@Module({
|
||||
imports: [QueueModule, ConfigModule],
|
||||
@ -12,8 +11,7 @@ import { SalesforceFieldMapService } from "./services/salesforce-field-config.se
|
||||
SalesforceConnection,
|
||||
SalesforceAccountService,
|
||||
SalesforceService,
|
||||
SalesforceFieldMapService,
|
||||
],
|
||||
exports: [SalesforceService, SalesforceConnection, SalesforceFieldMapService],
|
||||
exports: [SalesforceService, SalesforceConnection],
|
||||
})
|
||||
export class SalesforceModule {}
|
||||
|
||||
@ -3,7 +3,6 @@ import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { SalesforceConnection } from "./services/salesforce-connection.service";
|
||||
import { SalesforceFieldMapService } from "@bff/integrations/salesforce/services/salesforce-field-config.service";
|
||||
import {
|
||||
SalesforceAccountService,
|
||||
type AccountData,
|
||||
@ -27,7 +26,6 @@ export class SalesforceService implements OnModuleInit {
|
||||
private configService: ConfigService,
|
||||
private connection: SalesforceConnection,
|
||||
private accountService: SalesforceAccountService,
|
||||
private fieldMapService: SalesforceFieldMapService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@ -118,10 +116,9 @@ export class SalesforceService implements OnModuleInit {
|
||||
throw new Error("Salesforce connection not available");
|
||||
}
|
||||
|
||||
const fields = this.fieldMapService.getFieldMap();
|
||||
const result = (await this.connection.query(
|
||||
`SELECT Id, Status, ${fields.order.activationStatus}, ${fields.order.whmcsOrderId},
|
||||
${fields.order.lastErrorCode}, ${fields.order.lastErrorMessage},
|
||||
`SELECT Id, Status, Type, Activation_Status__c, WHMCS_Order_ID__c,
|
||||
Activation_Error_Code__c, Activation_Error_Message__c,
|
||||
AccountId, Account.Name
|
||||
FROM Order
|
||||
WHERE Id = '${orderId}'
|
||||
|
||||
@ -1,152 +0,0 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type { SalesforceProductFieldMap } from "@customer-portal/domain/catalog";
|
||||
import {
|
||||
buildOrderItemProduct2Fields,
|
||||
buildOrderItemSelectFields,
|
||||
buildOrderSelectFields,
|
||||
type SalesforceOrdersFieldConfig,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import type { SalesforceAccountFieldMap } from "@customer-portal/domain/customer";
|
||||
|
||||
/**
|
||||
* Salesforce field configuration service
|
||||
* Provides the mapping between logical field names and actual Salesforce custom field names
|
||||
*/
|
||||
export type SalesforceFieldConfig = SalesforceOrdersFieldConfig;
|
||||
|
||||
// Legacy alias for backwards compatibility
|
||||
export type SalesforceFieldMap = SalesforceFieldConfig;
|
||||
|
||||
@Injectable()
|
||||
export class SalesforceFieldMapService {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
getFieldMap(): SalesforceFieldConfig {
|
||||
const product: SalesforceProductFieldMap = {
|
||||
sku: this.configService.get<string>("PRODUCT_SKU_FIELD")!,
|
||||
portalCategory: this.configService.get<string>("PRODUCT_PORTAL_CATEGORY_FIELD")!,
|
||||
portalCatalog: this.configService.get<string>("PRODUCT_PORTAL_CATALOG_FIELD")!,
|
||||
portalAccessible: this.configService.get<string>("PRODUCT_PORTAL_ACCESSIBLE_FIELD")!,
|
||||
itemClass: this.configService.get<string>("PRODUCT_ITEM_CLASS_FIELD")!,
|
||||
billingCycle: this.configService.get<string>("PRODUCT_BILLING_CYCLE_FIELD")!,
|
||||
whmcsProductId: this.configService.get<string>("PRODUCT_WHMCS_PRODUCT_ID_FIELD")!,
|
||||
whmcsProductName: this.configService.get<string>("PRODUCT_WHMCS_PRODUCT_NAME_FIELD")!,
|
||||
internetPlanTier: this.configService.get<string>("PRODUCT_INTERNET_PLAN_TIER_FIELD")!,
|
||||
internetOfferingType: this.configService.get<string>("PRODUCT_INTERNET_OFFERING_TYPE_FIELD")!,
|
||||
displayOrder: this.configService.get<string>("PRODUCT_DISPLAY_ORDER_FIELD")!,
|
||||
bundledAddon: this.configService.get<string>("PRODUCT_BUNDLED_ADDON_FIELD")!,
|
||||
isBundledAddon: this.configService.get<string>("PRODUCT_IS_BUNDLED_ADDON_FIELD")!,
|
||||
simDataSize: this.configService.get<string>("PRODUCT_SIM_DATA_SIZE_FIELD")!,
|
||||
simPlanType: this.configService.get<string>("PRODUCT_SIM_PLAN_TYPE_FIELD")!,
|
||||
simHasFamilyDiscount: this.configService.get<string>("PRODUCT_SIM_HAS_FAMILY_DISCOUNT_FIELD")!,
|
||||
vpnRegion: this.configService.get<string>("PRODUCT_VPN_REGION_FIELD")!,
|
||||
};
|
||||
|
||||
const fieldConfig: SalesforceFieldConfig = {
|
||||
account: {
|
||||
internetEligibility: this.configService.get<string>("ACCOUNT_INTERNET_ELIGIBILITY_FIELD")!,
|
||||
customerNumber: this.configService.get<string>("ACCOUNT_CUSTOMER_NUMBER_FIELD")!,
|
||||
},
|
||||
product,
|
||||
order: {
|
||||
orderType: this.configService.get<string>("ORDER_TYPE_FIELD")!,
|
||||
activationType: this.configService.get<string>("ORDER_ACTIVATION_TYPE_FIELD")!,
|
||||
activationScheduledAt: this.configService.get<string>(
|
||||
"ORDER_ACTIVATION_SCHEDULED_AT_FIELD"
|
||||
)!,
|
||||
activationStatus: this.configService.get<string>("ORDER_ACTIVATION_STATUS_FIELD")!,
|
||||
internetPlanTier: this.configService.get<string>("ORDER_INTERNET_PLAN_TIER_FIELD")!,
|
||||
installationType: this.configService.get<string>("ORDER_INSTALLATION_TYPE_FIELD")!,
|
||||
weekendInstall: this.configService.get<string>("ORDER_WEEKEND_INSTALL_FIELD")!,
|
||||
accessMode: this.configService.get<string>("ORDER_ACCESS_MODE_FIELD")!,
|
||||
hikariDenwa: this.configService.get<string>("ORDER_HIKARI_DENWA_FIELD")!,
|
||||
vpnRegion: this.configService.get<string>("ORDER_VPN_REGION_FIELD")!,
|
||||
simType: this.configService.get<string>("ORDER_SIM_TYPE_FIELD")!,
|
||||
eid: this.configService.get<string>("ORDER_EID_FIELD")!,
|
||||
simVoiceMail: this.configService.get<string>("ORDER_SIM_VOICE_MAIL_FIELD")!,
|
||||
simCallWaiting: this.configService.get<string>("ORDER_SIM_CALL_WAITING_FIELD")!,
|
||||
mnp: {
|
||||
application: this.configService.get<string>("ORDER_MNP_APPLICATION_FIELD")!,
|
||||
reservationNumber: this.configService.get<string>("ORDER_MNP_RESERVATION_FIELD")!,
|
||||
expiryDate: this.configService.get<string>("ORDER_MNP_EXPIRY_FIELD")!,
|
||||
phoneNumber: this.configService.get<string>("ORDER_MNP_PHONE_FIELD")!,
|
||||
mvnoAccountNumber: this.configService.get<string>("ORDER_MVNO_ACCOUNT_NUMBER_FIELD")!,
|
||||
portingDateOfBirth: this.configService.get<string>("ORDER_PORTING_DOB_FIELD")!,
|
||||
portingFirstName: this.configService.get<string>("ORDER_PORTING_FIRST_NAME_FIELD")!,
|
||||
portingLastName: this.configService.get<string>("ORDER_PORTING_LAST_NAME_FIELD")!,
|
||||
portingFirstNameKatakana: this.configService.get<string>(
|
||||
"ORDER_PORTING_FIRST_NAME_KATAKANA_FIELD"
|
||||
)!,
|
||||
portingLastNameKatakana: this.configService.get<string>(
|
||||
"ORDER_PORTING_LAST_NAME_KATAKANA_FIELD"
|
||||
)!,
|
||||
portingGender: this.configService.get<string>("ORDER_PORTING_GENDER_FIELD")!,
|
||||
},
|
||||
whmcsOrderId: this.configService.get<string>("ORDER_WHMCS_ORDER_ID_FIELD")!,
|
||||
lastErrorCode: this.configService.get<string>("ORDER_ACTIVATION_ERROR_CODE_FIELD"),
|
||||
lastErrorMessage: this.configService.get<string>("ORDER_ACTIVATION_ERROR_MESSAGE_FIELD"),
|
||||
lastAttemptAt: this.configService.get<string>("ORDER_ACTIVATION_LAST_ATTEMPT_AT_FIELD"),
|
||||
addressChanged: this.configService.get<string>("ORDER_ADDRESS_CHANGED_FIELD")!,
|
||||
billing: {
|
||||
street: this.configService.get<string>("ORDER_BILLING_STREET_FIELD")!,
|
||||
city: this.configService.get<string>("ORDER_BILLING_CITY_FIELD")!,
|
||||
state: this.configService.get<string>("ORDER_BILLING_STATE_FIELD")!,
|
||||
postalCode: this.configService.get<string>("ORDER_BILLING_POSTAL_CODE_FIELD")!,
|
||||
country: this.configService.get<string>("ORDER_BILLING_COUNTRY_FIELD")!,
|
||||
},
|
||||
},
|
||||
orderItem: {
|
||||
billingCycle: this.configService.get<string>("ORDER_ITEM_BILLING_CYCLE_FIELD")!,
|
||||
whmcsServiceId: this.configService.get<string>("ORDER_ITEM_WHMCS_SERVICE_ID_FIELD")!,
|
||||
},
|
||||
};
|
||||
|
||||
return fieldConfig;
|
||||
}
|
||||
|
||||
getProductQueryFields(): string {
|
||||
const fields = this.getFieldMap();
|
||||
return [
|
||||
"Id",
|
||||
"Name",
|
||||
fields.product.sku,
|
||||
fields.product.portalCategory,
|
||||
fields.product.portalCatalog,
|
||||
fields.product.portalAccessible,
|
||||
fields.product.itemClass,
|
||||
fields.product.billingCycle,
|
||||
fields.product.whmcsProductId,
|
||||
fields.product.whmcsProductName,
|
||||
fields.product.internetPlanTier,
|
||||
fields.product.internetOfferingType,
|
||||
fields.product.displayOrder,
|
||||
fields.product.bundledAddon,
|
||||
fields.product.isBundledAddon,
|
||||
fields.product.simDataSize,
|
||||
fields.product.simPlanType,
|
||||
fields.product.simHasFamilyDiscount,
|
||||
fields.product.vpnRegion,
|
||||
"UnitPrice",
|
||||
"IsActive",
|
||||
].join(", ");
|
||||
}
|
||||
|
||||
getOrderQueryFields(): string {
|
||||
const fieldConfig = this.getFieldMap();
|
||||
const fields = buildOrderSelectFields(fieldConfig, ["Account.Name"]);
|
||||
return fields.join(", ");
|
||||
}
|
||||
|
||||
getOrderItemQueryFields(additional: string[] = []): string {
|
||||
const fieldConfig = this.getFieldMap();
|
||||
const fields = buildOrderItemSelectFields(fieldConfig, additional);
|
||||
return fields.join(", ");
|
||||
}
|
||||
|
||||
getOrderItemProduct2Select(additional: string[] = []): string {
|
||||
const fieldConfig = this.getFieldMap();
|
||||
const productFields = buildOrderItemProduct2Fields(fieldConfig, additional);
|
||||
return productFields.map(f => `PricebookEntry.Product2.${f}`).join(", ");
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,6 @@ import { Injectable, Inject } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
||||
import { SalesforceFieldMapService } from "@bff/integrations/salesforce/services/salesforce-field-config.service";
|
||||
import {
|
||||
assertSalesforceId,
|
||||
sanitizeSoqlLiteral,
|
||||
@ -12,6 +11,7 @@ import type {
|
||||
SalesforceProduct2WithPricebookEntries,
|
||||
SalesforcePricebookEntryRecord,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import { Providers as CatalogProviders } from "@customer-portal/domain/catalog";
|
||||
import type { SalesforceQueryResult } from "@customer-portal/domain/orders";
|
||||
|
||||
@Injectable()
|
||||
@ -20,7 +20,6 @@ export class BaseCatalogService {
|
||||
|
||||
constructor(
|
||||
protected readonly sf: SalesforceConnection,
|
||||
protected readonly fieldMapService: SalesforceFieldMapService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) protected readonly logger: Logger
|
||||
) {
|
||||
@ -28,10 +27,6 @@ export class BaseCatalogService {
|
||||
this.portalPriceBookId = assertSalesforceId(portalPricebook, "PORTAL_PRICEBOOK_ID");
|
||||
}
|
||||
|
||||
protected getFields() {
|
||||
return this.fieldMapService.getFieldMap();
|
||||
}
|
||||
|
||||
protected async executeQuery<TRecord extends SalesforceProduct2WithPricebookEntries>(
|
||||
soql: string,
|
||||
context: string
|
||||
@ -52,13 +47,9 @@ export class BaseCatalogService {
|
||||
protected extractPricebookEntry(
|
||||
record: SalesforceProduct2WithPricebookEntries
|
||||
): SalesforcePricebookEntryRecord | undefined {
|
||||
const pricebookEntries = record.PricebookEntries?.records;
|
||||
const entry = Array.isArray(pricebookEntries) ? pricebookEntries[0] : undefined;
|
||||
const entry = CatalogProviders.Salesforce.extractPricebookEntry(record);
|
||||
if (!entry) {
|
||||
const fields = this.getFields();
|
||||
const skuField = fields.product.sku;
|
||||
const skuRaw = Reflect.get(record, skuField) as unknown;
|
||||
const sku = typeof skuRaw === "string" ? skuRaw : undefined;
|
||||
const sku = record.StockKeepingUnit ?? undefined;
|
||||
this.logger.warn(
|
||||
`No pricebook entry found for product ${String(record.Name)} (SKU: ${String(sku ?? "")}). Pricebook ID: ${this.portalPriceBookId}.`
|
||||
);
|
||||
@ -74,13 +65,12 @@ export class BaseCatalogService {
|
||||
additionalFields: string[] = [],
|
||||
additionalConditions: string = ""
|
||||
): string {
|
||||
const fields = this.getFields();
|
||||
const baseFields = [
|
||||
"Id",
|
||||
"Name",
|
||||
fields.product.sku,
|
||||
fields.product.portalCategory,
|
||||
fields.product.itemClass,
|
||||
"StockKeepingUnit",
|
||||
"Portal_Category__c",
|
||||
"Item_Class__c",
|
||||
];
|
||||
const allFields = [...baseFields, ...additionalFields].join(", ");
|
||||
|
||||
@ -91,11 +81,11 @@ export class BaseCatalogService {
|
||||
SELECT ${allFields},
|
||||
(SELECT Id, UnitPrice, Pricebook2Id, Product2Id, IsActive FROM PricebookEntries WHERE Pricebook2Id = '${this.portalPriceBookId}' AND IsActive = true LIMIT 1)
|
||||
FROM Product2
|
||||
WHERE ${fields.product.portalCategory} = '${safeCategory}'
|
||||
AND ${fields.product.itemClass} = '${safeItemClass}'
|
||||
AND ${fields.product.portalAccessible} = true
|
||||
WHERE Portal_Category__c = '${safeCategory}'
|
||||
AND Item_Class__c = '${safeItemClass}'
|
||||
AND Portal_Accessible__c = true
|
||||
${additionalConditions}
|
||||
ORDER BY ${fields.product.displayOrder} NULLS LAST, Name
|
||||
ORDER BY Catalog_Order__c NULLS LAST, Name
|
||||
`;
|
||||
}
|
||||
|
||||
@ -114,12 +104,11 @@ export class BaseCatalogService {
|
||||
}
|
||||
|
||||
protected buildCatalogServiceQuery(category: string, additionalFields: string[] = []): string {
|
||||
const fields = this.getFields();
|
||||
return this.buildProductQuery(
|
||||
category,
|
||||
"Service",
|
||||
additionalFields,
|
||||
`AND ${fields.product.portalCatalog} = true`
|
||||
`AND Portal_Catalog__c = true`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,17 +7,12 @@ import type {
|
||||
InternetInstallationCatalogItem,
|
||||
InternetAddonCatalogItem,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import { Providers as CatalogProviders } from "@customer-portal/domain/catalog";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
||||
import { SalesforceFieldMapService } from "@bff/integrations/salesforce/services/salesforce-field-config.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util";
|
||||
import {
|
||||
mapInternetPlan,
|
||||
mapInternetInstallation,
|
||||
mapInternetAddon,
|
||||
} from "@bff/modules/catalog/utils/salesforce-product.mapper";
|
||||
|
||||
interface SalesforceAccount {
|
||||
Id: string;
|
||||
@ -28,20 +23,18 @@ interface SalesforceAccount {
|
||||
export class InternetCatalogService extends BaseCatalogService {
|
||||
constructor(
|
||||
sf: SalesforceConnection,
|
||||
fieldMapService: SalesforceFieldMapService,
|
||||
configService: ConfigService,
|
||||
@Inject(Logger) logger: Logger,
|
||||
private mappingsService: MappingsService
|
||||
) {
|
||||
super(sf, fieldMapService, configService, logger);
|
||||
super(sf, configService, logger);
|
||||
}
|
||||
|
||||
async getPlans(): Promise<InternetPlanCatalogItem[]> {
|
||||
const fields = this.getFields();
|
||||
const soql = this.buildCatalogServiceQuery("Internet", [
|
||||
fields.product.internetPlanTier,
|
||||
fields.product.internetOfferingType,
|
||||
fields.product.displayOrder,
|
||||
"Internet_Plan_Tier__c",
|
||||
"Internet_Offering_Type__c",
|
||||
"Catalog_Order__c",
|
||||
]);
|
||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||
soql,
|
||||
@ -50,15 +43,14 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
|
||||
return records.map(record => {
|
||||
const entry = this.extractPricebookEntry(record);
|
||||
return mapInternetPlan(record, fields, entry);
|
||||
return CatalogProviders.Salesforce.mapInternetPlan(record, entry);
|
||||
});
|
||||
}
|
||||
|
||||
async getInstallations(): Promise<InternetInstallationCatalogItem[]> {
|
||||
const fields = this.getFields();
|
||||
const soql = this.buildProductQuery("Internet", "Installation", [
|
||||
fields.product.billingCycle,
|
||||
fields.product.displayOrder,
|
||||
"Billing_Cycle__c",
|
||||
"Catalog_Order__c",
|
||||
]);
|
||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||
soql,
|
||||
@ -70,18 +62,17 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
return records
|
||||
.map(record => {
|
||||
const entry = this.extractPricebookEntry(record);
|
||||
return mapInternetInstallation(record, fields, entry);
|
||||
return CatalogProviders.Salesforce.mapInternetInstallation(record, entry);
|
||||
})
|
||||
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
||||
}
|
||||
|
||||
async getAddons(): Promise<InternetAddonCatalogItem[]> {
|
||||
const fields = this.getFields();
|
||||
const soql = this.buildProductQuery("Internet", "Add-on", [
|
||||
fields.product.billingCycle,
|
||||
fields.product.displayOrder,
|
||||
fields.product.bundledAddon,
|
||||
fields.product.isBundledAddon,
|
||||
"Billing_Cycle__c",
|
||||
"Catalog_Order__c",
|
||||
"Bundled_Addon__c",
|
||||
"Is_Bundled_Addon__c",
|
||||
]);
|
||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||
soql,
|
||||
@ -93,7 +84,7 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
return records
|
||||
.map(record => {
|
||||
const entry = this.extractPricebookEntry(record);
|
||||
return mapInternetAddon(record, fields, entry);
|
||||
return CatalogProviders.Salesforce.mapInternetAddon(record, entry);
|
||||
})
|
||||
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
|
||||
}
|
||||
@ -120,9 +111,8 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
}
|
||||
|
||||
// Get customer's eligibility from Salesforce
|
||||
const fields = this.getFields();
|
||||
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId");
|
||||
const soql = `SELECT Id, ${fields.account.internetEligibility} FROM Account WHERE Id = '${sfAccountId}' LIMIT 1`;
|
||||
const soql = `SELECT Id, Internet_Eligibility__c FROM Account WHERE Id = '${sfAccountId}' LIMIT 1`;
|
||||
const accounts = await this.executeQuery(soql, "Customer Eligibility");
|
||||
|
||||
if (accounts.length === 0) {
|
||||
|
||||
@ -1,16 +1,12 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { BaseCatalogService } from "./base-catalog.service";
|
||||
import { SalesforceFieldMapService } from "@bff/integrations/salesforce/services/salesforce-field-config.service";
|
||||
import type {
|
||||
SalesforceProduct2WithPricebookEntries,
|
||||
SimCatalogProduct,
|
||||
SimActivationFeeCatalogItem,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import {
|
||||
mapSimProduct,
|
||||
mapSimActivationFee,
|
||||
} from "@bff/modules/catalog/utils/salesforce-product.mapper";
|
||||
import { Providers as CatalogProviders } from "@customer-portal/domain/catalog";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
@ -20,22 +16,20 @@ import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/conn
|
||||
export class SimCatalogService extends BaseCatalogService {
|
||||
constructor(
|
||||
sf: SalesforceConnection,
|
||||
fieldMapService: SalesforceFieldMapService,
|
||||
configService: ConfigService,
|
||||
@Inject(Logger) logger: Logger,
|
||||
private mappingsService: MappingsService,
|
||||
private whmcs: WhmcsConnectionOrchestratorService
|
||||
) {
|
||||
super(sf, fieldMapService, configService, logger);
|
||||
super(sf, configService, logger);
|
||||
}
|
||||
|
||||
async getPlans(): Promise<SimCatalogProduct[]> {
|
||||
const fields = this.getFields();
|
||||
const soql = this.buildCatalogServiceQuery("SIM", [
|
||||
fields.product.simDataSize,
|
||||
fields.product.simPlanType,
|
||||
fields.product.simHasFamilyDiscount,
|
||||
fields.product.displayOrder,
|
||||
"SIM_Data_Size__c",
|
||||
"SIM_Plan_Type__c",
|
||||
"SIM_Has_Family_Discount__c",
|
||||
"Catalog_Order__c",
|
||||
]);
|
||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||
soql,
|
||||
@ -44,7 +38,7 @@ export class SimCatalogService extends BaseCatalogService {
|
||||
|
||||
return records.map(record => {
|
||||
const entry = this.extractPricebookEntry(record);
|
||||
const product = mapSimProduct(record, fields, entry);
|
||||
const product = CatalogProviders.Salesforce.mapSimProduct(record, entry);
|
||||
|
||||
return {
|
||||
...product,
|
||||
@ -54,7 +48,6 @@ export class SimCatalogService extends BaseCatalogService {
|
||||
}
|
||||
|
||||
async getActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
||||
const fields = this.getFields();
|
||||
const soql = this.buildProductQuery("SIM", "Activation", []);
|
||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||
soql,
|
||||
@ -63,17 +56,16 @@ export class SimCatalogService extends BaseCatalogService {
|
||||
|
||||
return records.map(record => {
|
||||
const entry = this.extractPricebookEntry(record);
|
||||
return mapSimActivationFee(record, fields, entry);
|
||||
return CatalogProviders.Salesforce.mapSimActivationFee(record, entry);
|
||||
});
|
||||
}
|
||||
|
||||
async getAddons(): Promise<SimCatalogProduct[]> {
|
||||
const fields = this.getFields();
|
||||
const soql = this.buildProductQuery("SIM", "Add-on", [
|
||||
fields.product.billingCycle,
|
||||
fields.product.displayOrder,
|
||||
fields.product.bundledAddon,
|
||||
fields.product.isBundledAddon,
|
||||
"Billing_Cycle__c",
|
||||
"Catalog_Order__c",
|
||||
"Bundled_Addon__c",
|
||||
"Is_Bundled_Addon__c",
|
||||
]);
|
||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||
soql,
|
||||
@ -83,7 +75,7 @@ export class SimCatalogService extends BaseCatalogService {
|
||||
return records
|
||||
.map(record => {
|
||||
const entry = this.extractPricebookEntry(record);
|
||||
const product = mapSimProduct(record, fields, entry);
|
||||
const product = CatalogProviders.Salesforce.mapSimProduct(record, entry);
|
||||
|
||||
return {
|
||||
...product,
|
||||
|
||||
@ -2,26 +2,23 @@ import { Injectable, Inject } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
||||
import { SalesforceFieldMapService } from "@bff/integrations/salesforce/services/salesforce-field-config.service";
|
||||
import { BaseCatalogService } from "./base-catalog.service";
|
||||
import type { SalesforceProduct2WithPricebookEntries, VpnCatalogProduct } from "@customer-portal/domain/catalog";
|
||||
import { mapVpnProduct } from "@bff/modules/catalog/utils/salesforce-product.mapper";
|
||||
import { Providers as CatalogProviders } from "@customer-portal/domain/catalog";
|
||||
|
||||
@Injectable()
|
||||
export class VpnCatalogService extends BaseCatalogService {
|
||||
constructor(
|
||||
sf: SalesforceConnection,
|
||||
fieldMapService: SalesforceFieldMapService,
|
||||
configService: ConfigService,
|
||||
@Inject(Logger) logger: Logger
|
||||
) {
|
||||
super(sf, fieldMapService, configService, logger);
|
||||
super(sf, configService, logger);
|
||||
}
|
||||
async getPlans(): Promise<VpnCatalogProduct[]> {
|
||||
const fields = this.getFields();
|
||||
const soql = this.buildCatalogServiceQuery("VPN", [
|
||||
fields.product.vpnRegion,
|
||||
fields.product.displayOrder,
|
||||
"VPN_Region__c",
|
||||
"Catalog_Order__c",
|
||||
]);
|
||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||
soql,
|
||||
@ -30,7 +27,7 @@ export class VpnCatalogService extends BaseCatalogService {
|
||||
|
||||
return records.map(record => {
|
||||
const entry = this.extractPricebookEntry(record);
|
||||
const product = mapVpnProduct(record, fields, entry);
|
||||
const product = CatalogProviders.Salesforce.mapVpnProduct(record, entry);
|
||||
return {
|
||||
...product,
|
||||
description: product.description || product.name,
|
||||
@ -39,8 +36,7 @@ export class VpnCatalogService extends BaseCatalogService {
|
||||
}
|
||||
|
||||
async getActivationFees(): Promise<VpnCatalogProduct[]> {
|
||||
const fields = this.getFields();
|
||||
const soql = this.buildProductQuery("VPN", "Activation", [fields.product.vpnRegion]);
|
||||
const soql = this.buildProductQuery("VPN", "Activation", ["VPN_Region__c"]);
|
||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||
soql,
|
||||
"VPN Activation Fees"
|
||||
@ -48,7 +44,7 @@ export class VpnCatalogService extends BaseCatalogService {
|
||||
|
||||
return records.map(record => {
|
||||
const pricebookEntry = this.extractPricebookEntry(record);
|
||||
const product = mapVpnProduct(record, fields, pricebookEntry);
|
||||
const product = CatalogProviders.Salesforce.mapVpnProduct(record, pricebookEntry);
|
||||
|
||||
return {
|
||||
...product,
|
||||
|
||||
@ -1,302 +0,0 @@
|
||||
import type {
|
||||
CatalogProductBase,
|
||||
InternetAddonCatalogItem,
|
||||
InternetInstallationCatalogItem,
|
||||
InternetPlanCatalogItem,
|
||||
InternetPlanTemplate,
|
||||
SimActivationFeeCatalogItem,
|
||||
SimCatalogProduct,
|
||||
VpnCatalogProduct,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import type {
|
||||
SalesforceProduct2WithPricebookEntries,
|
||||
SalesforcePricebookEntryRecord,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import type { SalesforceFieldMap } from "@bff/integrations/salesforce/services/salesforce-field-config.service";
|
||||
|
||||
export type SalesforceCatalogProductRecord = SalesforceProduct2WithPricebookEntries;
|
||||
|
||||
const DEFAULT_PLAN_TEMPLATE: InternetPlanTemplate = {
|
||||
tierDescription: "Standard plan",
|
||||
description: undefined,
|
||||
features: undefined,
|
||||
};
|
||||
|
||||
function getTierTemplate(tier?: string): InternetPlanTemplate {
|
||||
if (!tier) {
|
||||
return DEFAULT_PLAN_TEMPLATE;
|
||||
}
|
||||
|
||||
const normalized = tier.toLowerCase();
|
||||
switch (normalized) {
|
||||
case "silver":
|
||||
return {
|
||||
tierDescription: "Simple package with broadband-modem and ISP only",
|
||||
description: "Simple package with broadband-modem and ISP only",
|
||||
features: [
|
||||
"NTT modem + ISP connection",
|
||||
"Two ISP connection protocols: IPoE (recommended) or PPPoE",
|
||||
"Self-configuration of router (you provide your own)",
|
||||
"Monthly: ¥6,000 | One-time: ¥22,800",
|
||||
],
|
||||
};
|
||||
case "gold":
|
||||
return {
|
||||
tierDescription: "Standard all-inclusive package with basic Wi-Fi",
|
||||
description: "Standard all-inclusive package with basic Wi-Fi",
|
||||
features: [
|
||||
"NTT modem + wireless router (rental)",
|
||||
"ISP (IPoE) configured automatically within 24 hours",
|
||||
"Basic wireless router included",
|
||||
"Optional: TP-LINK RE650 range extender (¥500/month)",
|
||||
"Monthly: ¥6,500 | One-time: ¥22,800",
|
||||
],
|
||||
};
|
||||
case "platinum":
|
||||
return {
|
||||
tierDescription: "Tailored set up with premier Wi-Fi management support",
|
||||
description:
|
||||
"Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²",
|
||||
features: [
|
||||
"NTT modem + Netgear INSIGHT Wi-Fi routers",
|
||||
"Cloud management support for remote router management",
|
||||
"Automatic updates and quicker support",
|
||||
"Seamless wireless network setup",
|
||||
"Monthly: ¥6,500 | One-time: ¥22,800",
|
||||
"Cloud management: ¥500/month per router",
|
||||
],
|
||||
};
|
||||
default:
|
||||
return {
|
||||
tierDescription: `${tier} plan`,
|
||||
description: undefined,
|
||||
features: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function inferInstallationTypeFromSku(sku: string): "One-time" | "12-Month" | "24-Month" {
|
||||
const normalized = sku.toLowerCase();
|
||||
if (normalized.includes("24")) return "24-Month";
|
||||
if (normalized.includes("12")) return "12-Month";
|
||||
return "One-time";
|
||||
}
|
||||
|
||||
function getProductField<T = unknown>(
|
||||
product: SalesforceCatalogProductRecord,
|
||||
fieldKey: keyof SalesforceFieldMap["product"],
|
||||
fieldMap: SalesforceFieldMap
|
||||
): T | undefined {
|
||||
const salesforceField = fieldMap.product[fieldKey] as keyof SalesforceCatalogProductRecord;
|
||||
const value = product[salesforceField];
|
||||
return value as T | undefined;
|
||||
}
|
||||
|
||||
export function getStringField(
|
||||
product: SalesforceCatalogProductRecord,
|
||||
fieldKey: keyof SalesforceFieldMap["product"],
|
||||
fieldMap: SalesforceFieldMap
|
||||
): string | undefined {
|
||||
const value = getProductField(product, fieldKey, fieldMap);
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function coerceNumber(value: unknown): number | undefined {
|
||||
if (typeof value === "number") return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function baseProduct(
|
||||
product: SalesforceCatalogProductRecord,
|
||||
fieldMap: SalesforceFieldMap
|
||||
): CatalogProductBase {
|
||||
const sku = getStringField(product, "sku", fieldMap) ?? "";
|
||||
const base: CatalogProductBase = {
|
||||
id: product.Id,
|
||||
sku,
|
||||
name: product.Name ?? sku,
|
||||
};
|
||||
|
||||
const description = product.Description;
|
||||
if (description) base.description = description;
|
||||
|
||||
const billingCycle = getStringField(product, "billingCycle", fieldMap);
|
||||
if (billingCycle) base.billingCycle = billingCycle;
|
||||
|
||||
const displayOrder = getProductField(product, "displayOrder", fieldMap);
|
||||
if (typeof displayOrder === "number") base.displayOrder = displayOrder;
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
function getBoolean(
|
||||
product: SalesforceCatalogProductRecord,
|
||||
key: keyof SalesforceFieldMap["product"],
|
||||
fieldMap: SalesforceFieldMap
|
||||
) {
|
||||
const value = getProductField(product, key, fieldMap);
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
function resolveBundledAddonId(
|
||||
product: SalesforceCatalogProductRecord,
|
||||
fieldMap: SalesforceFieldMap
|
||||
): string | undefined {
|
||||
const raw = getProductField(product, "bundledAddon", fieldMap);
|
||||
return typeof raw === "string" && raw.length > 0 ? raw : undefined;
|
||||
}
|
||||
|
||||
function resolveBundledAddon(
|
||||
product: SalesforceCatalogProductRecord,
|
||||
fieldMap: SalesforceFieldMap
|
||||
) {
|
||||
return {
|
||||
bundledAddonId: resolveBundledAddonId(product, fieldMap),
|
||||
isBundledAddon: Boolean(getBoolean(product, "isBundledAddon", fieldMap)),
|
||||
};
|
||||
}
|
||||
|
||||
function derivePrices(
|
||||
product: SalesforceCatalogProductRecord,
|
||||
fieldMap: SalesforceFieldMap,
|
||||
pricebookEntry?: SalesforcePricebookEntryRecord
|
||||
): Pick<CatalogProductBase, "monthlyPrice" | "oneTimePrice"> {
|
||||
const billingCycle = getStringField(product, "billingCycle", fieldMap)?.toLowerCase();
|
||||
const unitPrice = coerceNumber(pricebookEntry?.UnitPrice);
|
||||
|
||||
let monthlyPrice: number | undefined;
|
||||
let oneTimePrice: number | undefined;
|
||||
|
||||
if (unitPrice !== undefined) {
|
||||
if (billingCycle === "monthly") {
|
||||
monthlyPrice = unitPrice;
|
||||
} else if (billingCycle) {
|
||||
oneTimePrice = unitPrice;
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Monthly_Price__c and One_Time_Price__c fields would be used here if they exist in Salesforce
|
||||
// For now, we rely on pricebook entries for pricing
|
||||
|
||||
return { monthlyPrice, oneTimePrice };
|
||||
}
|
||||
|
||||
export function mapInternetPlan(
|
||||
product: SalesforceCatalogProductRecord,
|
||||
fieldMap: SalesforceFieldMap,
|
||||
pricebookEntry?: SalesforcePricebookEntryRecord
|
||||
): InternetPlanCatalogItem {
|
||||
const base = baseProduct(product, fieldMap);
|
||||
const prices = derivePrices(product, fieldMap, pricebookEntry);
|
||||
const tier = getStringField(product, "internetPlanTier", fieldMap);
|
||||
const offeringType = getStringField(product, "internetOfferingType", fieldMap);
|
||||
|
||||
const tierData = getTierTemplate(tier);
|
||||
|
||||
return {
|
||||
...base,
|
||||
...prices,
|
||||
internetPlanTier: tier,
|
||||
internetOfferingType: offeringType,
|
||||
features: tierData.features, // Use hardcoded tier features since no featureList field
|
||||
catalogMetadata: {
|
||||
tierDescription: tierData.tierDescription,
|
||||
features: tierData.features,
|
||||
isRecommended: tier === "Gold",
|
||||
},
|
||||
// Use Salesforce description if available, otherwise fall back to tier description
|
||||
description: base.description ?? tierData.description,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapInternetInstallation(
|
||||
product: SalesforceCatalogProductRecord,
|
||||
fieldMap: SalesforceFieldMap,
|
||||
pricebookEntry?: SalesforcePricebookEntryRecord
|
||||
): InternetInstallationCatalogItem {
|
||||
const base = baseProduct(product, fieldMap);
|
||||
const prices = derivePrices(product, fieldMap, pricebookEntry);
|
||||
|
||||
return {
|
||||
...base,
|
||||
...prices,
|
||||
catalogMetadata: {
|
||||
installationTerm: inferInstallationTypeFromSku(base.sku),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mapInternetAddon(
|
||||
product: SalesforceCatalogProductRecord,
|
||||
fieldMap: SalesforceFieldMap,
|
||||
pricebookEntry?: SalesforcePricebookEntryRecord
|
||||
): InternetAddonCatalogItem {
|
||||
const base = baseProduct(product, fieldMap);
|
||||
const prices = derivePrices(product, fieldMap, pricebookEntry);
|
||||
const { bundledAddonId, isBundledAddon } = resolveBundledAddon(product, fieldMap);
|
||||
|
||||
return {
|
||||
...base,
|
||||
...prices,
|
||||
bundledAddonId,
|
||||
isBundledAddon,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSimProduct(
|
||||
product: SalesforceCatalogProductRecord,
|
||||
fieldMap: SalesforceFieldMap,
|
||||
pricebookEntry?: SalesforcePricebookEntryRecord
|
||||
): SimCatalogProduct {
|
||||
const base = baseProduct(product, fieldMap);
|
||||
const prices = derivePrices(product, fieldMap, pricebookEntry);
|
||||
const dataSize = getStringField(product, "simDataSize", fieldMap);
|
||||
const planType = getStringField(product, "simPlanType", fieldMap);
|
||||
const hasFamilyDiscount = getBoolean(product, "simHasFamilyDiscount", fieldMap);
|
||||
const { bundledAddonId, isBundledAddon } = resolveBundledAddon(product, fieldMap);
|
||||
|
||||
return {
|
||||
...base,
|
||||
...prices,
|
||||
simDataSize: dataSize,
|
||||
simPlanType: planType,
|
||||
simHasFamilyDiscount: hasFamilyDiscount,
|
||||
bundledAddonId,
|
||||
isBundledAddon,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSimActivationFee(
|
||||
product: SalesforceCatalogProductRecord,
|
||||
fieldMap: SalesforceFieldMap,
|
||||
pricebookEntry?: SalesforcePricebookEntryRecord
|
||||
): SimActivationFeeCatalogItem {
|
||||
const simProduct = mapSimProduct(product, fieldMap, pricebookEntry);
|
||||
|
||||
return {
|
||||
...simProduct,
|
||||
catalogMetadata: {
|
||||
isDefault: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mapVpnProduct(
|
||||
product: SalesforceCatalogProductRecord,
|
||||
fieldMap: SalesforceFieldMap,
|
||||
pricebookEntry?: SalesforcePricebookEntryRecord
|
||||
): VpnCatalogProduct {
|
||||
const base = baseProduct(product, fieldMap);
|
||||
const prices = derivePrices(product, fieldMap, pricebookEntry);
|
||||
const vpnRegion = getStringField(product, "vpnRegion", fieldMap);
|
||||
|
||||
return {
|
||||
...base,
|
||||
...prices,
|
||||
vpnRegion,
|
||||
};
|
||||
}
|
||||
@ -3,7 +3,6 @@ import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { OrderFulfillmentOrchestrator } from "../services/order-fulfillment-orchestrator.service";
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
||||
import { SalesforceFieldMapService } from "@bff/integrations/salesforce/services/salesforce-field-config.service";
|
||||
import type { ProvisioningJobData } from "./provisioning.queue";
|
||||
import { CacheService } from "@bff/infra/cache/cache.service";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
@ -16,7 +15,6 @@ export class ProvisioningProcessor extends WorkerHost {
|
||||
constructor(
|
||||
private readonly orchestrator: OrderFulfillmentOrchestrator,
|
||||
private readonly salesforceService: SalesforceService,
|
||||
private readonly fieldMapService: SalesforceFieldMapService,
|
||||
private readonly cache: CacheService,
|
||||
private readonly config: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
@ -34,15 +32,10 @@ export class ProvisioningProcessor extends WorkerHost {
|
||||
});
|
||||
|
||||
// Guard: Only process if Salesforce Order is currently 'Activating'
|
||||
const fields = this.fieldMapService.getFieldMap();
|
||||
|
||||
const order = await this.salesforceService.getOrder(sfOrderId);
|
||||
const status = order
|
||||
? ((Reflect.get(order, fields.order.activationStatus) as string | undefined) ?? "")
|
||||
: "";
|
||||
const lastErrorCodeField = fields.order.lastErrorCode;
|
||||
const lastErrorCode = lastErrorCodeField
|
||||
? ((order ? (Reflect.get(order, lastErrorCodeField) as string | undefined) : undefined) ?? "")
|
||||
: "";
|
||||
const status = order?.Activation_Status__c ?? "";
|
||||
const lastErrorCode = order?.Activation_Error_Code__c ?? "";
|
||||
if (status !== "Activating") {
|
||||
this.logger.log("Skipping provisioning job: Order not in Activating state", {
|
||||
sfOrderId,
|
||||
|
||||
@ -15,7 +15,6 @@ import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service"
|
||||
import { SimFulfillmentService } from "./sim-fulfillment.service";
|
||||
import { DistributedTransactionService } from "@bff/core/database/services/distributed-transaction.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { SalesforceFieldMapService } from "@bff/integrations/salesforce/services/salesforce-field-config.service";
|
||||
import { type OrderSummary, type OrderDetails, type SalesforceOrderRecord, type SalesforceOrderItemRecord } from "@customer-portal/domain/orders";
|
||||
import type { FulfillmentOrderDetails, FulfillmentOrderItem } from "../types/fulfillment.types";
|
||||
|
||||
@ -45,7 +44,6 @@ export interface OrderFulfillmentContext {
|
||||
export class OrderFulfillmentOrchestrator {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly fieldMapService: SalesforceFieldMapService,
|
||||
private readonly salesforceService: SalesforceService,
|
||||
private readonly whmcsOrderService: WhmcsOrderService,
|
||||
private readonly orderOrchestrator: OrderOrchestrator,
|
||||
@ -135,17 +133,17 @@ export class OrderFulfillmentOrchestrator {
|
||||
id: "sf_status_update",
|
||||
description: "Update Salesforce order status to Activating",
|
||||
execute: async () => {
|
||||
const fields = this.fieldMapService.getFieldMap();
|
||||
|
||||
return await this.salesforceService.updateOrder({
|
||||
Id: sfOrderId,
|
||||
[fields.order.activationStatus]: "Activating",
|
||||
Activation_Status__c: "Activating",
|
||||
});
|
||||
},
|
||||
rollback: async () => {
|
||||
const fields = this.fieldMapService.getFieldMap();
|
||||
|
||||
await this.salesforceService.updateOrder({
|
||||
Id: sfOrderId,
|
||||
[fields.order.activationStatus]: "Failed",
|
||||
Activation_Status__c: "Failed",
|
||||
});
|
||||
},
|
||||
critical: true,
|
||||
@ -270,20 +268,20 @@ export class OrderFulfillmentOrchestrator {
|
||||
id: "sf_success_update",
|
||||
description: "Update Salesforce with success",
|
||||
execute: async () => {
|
||||
const fields = this.fieldMapService.getFieldMap();
|
||||
|
||||
|
||||
return await this.salesforceService.updateOrder({
|
||||
Id: sfOrderId,
|
||||
Status: "Completed",
|
||||
[fields.order.activationStatus]: "Activated",
|
||||
[fields.order.whmcsOrderId]: whmcsAcceptResult?.orderId?.toString(),
|
||||
Activation_Status__c: "Activated",
|
||||
WHMCS_Order_ID__c: whmcsAcceptResult?.orderId?.toString(),
|
||||
});
|
||||
},
|
||||
rollback: async () => {
|
||||
const fields = this.fieldMapService.getFieldMap();
|
||||
|
||||
await this.salesforceService.updateOrder({
|
||||
Id: sfOrderId,
|
||||
[fields.order.activationStatus]: "Failed",
|
||||
Activation_Status__c: "Failed",
|
||||
});
|
||||
},
|
||||
critical: true,
|
||||
@ -370,10 +368,10 @@ export class OrderFulfillmentOrchestrator {
|
||||
|
||||
// Step 2: Update Salesforce status to "Activating"
|
||||
await this.executeStep(context, "sf_status_update", async () => {
|
||||
const fields = this.fieldMapService.getFieldMap();
|
||||
|
||||
await this.salesforceService.updateOrder({
|
||||
Id: sfOrderId,
|
||||
[fields.order.activationStatus]: "Activating",
|
||||
Activation_Status__c: "Activating",
|
||||
});
|
||||
});
|
||||
|
||||
@ -472,12 +470,12 @@ export class OrderFulfillmentOrchestrator {
|
||||
|
||||
// Step 8: Update Salesforce with success
|
||||
await this.executeStep(context, "sf_success_update", async () => {
|
||||
const fields = this.fieldMapService.getFieldMap();
|
||||
|
||||
await this.salesforceService.updateOrder({
|
||||
Id: sfOrderId,
|
||||
Status: "Completed",
|
||||
[fields.order.activationStatus]: "Activated",
|
||||
[fields.order.whmcsOrderId]: context.whmcsResult!.orderId.toString(),
|
||||
Activation_Status__c: "Activated",
|
||||
WHMCS_Order_ID__c: context.whmcsResult!.orderId.toString(),
|
||||
});
|
||||
});
|
||||
|
||||
@ -667,7 +665,7 @@ export class OrderFulfillmentOrchestrator {
|
||||
): Promise<void> {
|
||||
const errorCode = this.orderFulfillmentErrorService.determineErrorCode(error);
|
||||
const userMessage = error.message;
|
||||
const fields = this.fieldMapService.getFieldMap();
|
||||
|
||||
|
||||
this.logger.error("Fulfillment orchestration failed", {
|
||||
sfOrderId: context.sfOrderId,
|
||||
@ -683,14 +681,14 @@ export class OrderFulfillmentOrchestrator {
|
||||
Id: context.sfOrderId,
|
||||
// Set overall Order.Status to Pending Review for manual attention
|
||||
Status: "Pending Review",
|
||||
[fields.order.activationStatus]: "Failed",
|
||||
Activation_Status__c: "Failed",
|
||||
Activation_Error_Code__c: (
|
||||
this.orderFulfillmentErrorService.getShortCode(error) || String(errorCode)
|
||||
)
|
||||
.toString()
|
||||
.substring(0, 60),
|
||||
Activation_Error_Message__c: userMessage?.substring(0, 255),
|
||||
};
|
||||
updates[fields.order.lastErrorCode as string] = (
|
||||
this.orderFulfillmentErrorService.getShortCode(error) || String(errorCode)
|
||||
)
|
||||
.toString()
|
||||
.substring(0, 60);
|
||||
updates[fields.order.lastErrorMessage as string] = userMessage?.substring(0, 255);
|
||||
|
||||
await this.salesforceService.updateOrder(updates as { Id: string; [key: string]: unknown });
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-paym
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders";
|
||||
import { SalesforceFieldMapService, type SalesforceFieldMap } from "@bff/integrations/salesforce/services/salesforce-field-config.service";
|
||||
import { sfOrderIdParamSchema } from "@customer-portal/domain/orders";
|
||||
type OrderStringFieldKey = "activationStatus";
|
||||
|
||||
@ -24,7 +23,6 @@ export interface OrderFulfillmentValidationResult {
|
||||
export class OrderFulfillmentValidator {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly fieldMapService: SalesforceFieldMapService,
|
||||
private readonly salesforceService: SalesforceService,
|
||||
private readonly whmcsPaymentService: WhmcsPaymentService,
|
||||
private readonly mappingsService: MappingsService
|
||||
@ -50,9 +48,8 @@ export class OrderFulfillmentValidator {
|
||||
const sfOrder = await this.validateSalesforceOrder(sfOrderId);
|
||||
|
||||
// 2. Check if already provisioned (idempotency)
|
||||
const fieldMap = this.fieldMapService.getFieldMap();
|
||||
const rawWhmcs = Reflect.get(sfOrder, fieldMap.order.whmcsOrderId) as unknown;
|
||||
const existingWhmcsOrderId = typeof rawWhmcs === "string" ? rawWhmcs : undefined;
|
||||
|
||||
const existingWhmcsOrderId = sfOrder.WHMCS_Order_ID__c;
|
||||
if (existingWhmcsOrderId) {
|
||||
this.logger.log("Order already provisioned", {
|
||||
sfOrderId,
|
||||
@ -116,11 +113,11 @@ export class OrderFulfillmentValidator {
|
||||
throw new BadRequestException(`Cannot provision cancelled order ${sfOrderId}`);
|
||||
}
|
||||
|
||||
const fieldMap = this.fieldMapService.getFieldMap();
|
||||
|
||||
this.logger.log("Salesforce order validated", {
|
||||
sfOrderId,
|
||||
status: order.Status,
|
||||
activationStatus: pickOrderString(order, "activationStatus", fieldMap),
|
||||
activationStatus: order.Activation_Status__c,
|
||||
accountId: order.AccountId,
|
||||
});
|
||||
|
||||
@ -159,16 +156,3 @@ export class OrderFulfillmentValidator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pickOrderString(
|
||||
order: SalesforceOrderRecord,
|
||||
key: OrderStringFieldKey,
|
||||
fieldMap: SalesforceFieldMap
|
||||
): string | undefined {
|
||||
const field = fieldMap.order[key];
|
||||
if (typeof field !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const raw = Reflect.get(order, field) as unknown;
|
||||
return typeof raw === "string" ? raw : undefined;
|
||||
}
|
||||
|
||||
@ -109,18 +109,15 @@ export class OrderOrchestrator {
|
||||
const safeOrderId = assertSalesforceId(orderId, "orderId");
|
||||
this.logger.log({ orderId: safeOrderId }, "Fetching order details with items");
|
||||
|
||||
const fieldMap = this.fieldMapService.getFieldMap();
|
||||
const orderQueryFields = this.fieldMapService.getOrderQueryFields();
|
||||
const orderQueryFields = buildOrderSelectFields(["Account.Name"]).join(", ");
|
||||
const orderItemProduct2Fields = buildOrderItemProduct2Fields().map(f => `PricebookEntry.Product2.${f}`);
|
||||
const orderItemSelect = [
|
||||
this.fieldMapService.getOrderItemQueryFields(),
|
||||
this.fieldMapService.getOrderItemProduct2Select(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
...buildOrderItemSelectFields(),
|
||||
...orderItemProduct2Fields,
|
||||
].join(", ");
|
||||
|
||||
const orderSoql = `
|
||||
SELECT ${orderQueryFields}, OrderNumber, TotalAmount,
|
||||
Account.Name, CreatedDate, LastModifiedDate
|
||||
SELECT ${orderQueryFields}
|
||||
FROM Order
|
||||
WHERE Id = '${safeOrderId}'
|
||||
LIMIT 1
|
||||
@ -155,8 +152,7 @@ export class OrderOrchestrator {
|
||||
|
||||
return OrderProviders.Salesforce.transformSalesforceOrderDetails(
|
||||
order,
|
||||
orderItems,
|
||||
fieldMap
|
||||
orderItems
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to fetch order with items", {
|
||||
@ -183,17 +179,15 @@ export class OrderOrchestrator {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fieldMap = this.fieldMapService.getFieldMap();
|
||||
const orderQueryFields = this.fieldMapService.getOrderQueryFields();
|
||||
const orderQueryFields = buildOrderSelectFields().join(", ");
|
||||
const orderItemProduct2Fields = buildOrderItemProduct2Fields().map(f => `PricebookEntry.Product2.${f}`);
|
||||
const orderItemSelect = [
|
||||
this.fieldMapService.getOrderItemQueryFields(),
|
||||
this.fieldMapService.getOrderItemProduct2Select(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
...buildOrderItemSelectFields(),
|
||||
...orderItemProduct2Fields,
|
||||
].join(", ");
|
||||
|
||||
const ordersSoql = `
|
||||
SELECT ${orderQueryFields}, OrderNumber, TotalAmount, CreatedDate, LastModifiedDate
|
||||
SELECT ${orderQueryFields}
|
||||
FROM Order
|
||||
WHERE AccountId = '${sfAccountId}'
|
||||
ORDER BY CreatedDate DESC
|
||||
@ -219,42 +213,52 @@ export class OrderOrchestrator {
|
||||
return [];
|
||||
}
|
||||
|
||||
const orderIdsClause = buildInClause(rawOrderIds, "orderIds");
|
||||
const orderIds = rawOrderIds.map(id => assertSalesforceId(id, "orderId"));
|
||||
const inClause = buildInClause(orderIds, "orderIds");
|
||||
|
||||
const itemsSoql = `
|
||||
SELECT ${orderItemSelect}
|
||||
FROM OrderItem
|
||||
WHERE OrderId IN (${orderIdsClause})
|
||||
ORDER BY OrderId, CreatedDate ASC
|
||||
WHERE OrderId IN ${inClause}
|
||||
ORDER BY CreatedDate ASC
|
||||
`;
|
||||
|
||||
const itemsResult = (await this.sf.query(
|
||||
itemsSoql
|
||||
)) as SalesforceQueryResult<SalesforceOrderItemRecord>;
|
||||
const allItems = itemsResult.records ?? [];
|
||||
const allItems = itemsResult.records || [];
|
||||
|
||||
const itemsByOrder = allItems.reduce<Record<string, SalesforceOrderItemRecord[]>>(
|
||||
(acc, record) => {
|
||||
const orderId = typeof record.OrderId === "string" ? record.OrderId : undefined;
|
||||
if (!orderId) return acc;
|
||||
if (!acc[orderId]) acc[orderId] = [];
|
||||
acc[orderId].push(record);
|
||||
return acc;
|
||||
// Group items by order ID
|
||||
const itemsByOrder: Record<string, SalesforceOrderItemRecord[]> = {};
|
||||
for (const item of allItems) {
|
||||
const orderId = item.OrderId;
|
||||
if (typeof orderId === "string") {
|
||||
if (!itemsByOrder[orderId]) {
|
||||
itemsByOrder[orderId] = [];
|
||||
}
|
||||
itemsByOrder[orderId].push(item);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
{
|
||||
userId,
|
||||
orderCount: orders.length,
|
||||
totalItems: allItems.length,
|
||||
},
|
||||
{}
|
||||
"User orders retrieved with item summaries"
|
||||
);
|
||||
|
||||
// Transform orders to domain types and return summary
|
||||
return orders
|
||||
.filter((order): order is SalesforceOrderRecord & { Id: string } => typeof order.Id === "string")
|
||||
.map(order =>
|
||||
OrderProviders.Salesforce.transformSalesforceOrderSummary(
|
||||
order,
|
||||
itemsByOrder[order.Id] ?? [],
|
||||
fieldMap
|
||||
itemsByOrder[order.Id] ?? []
|
||||
)
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to fetch user orders with items", {
|
||||
this.logger.error("Failed to fetch orders for user", {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
});
|
||||
|
||||
@ -2,8 +2,6 @@ import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
||||
import { SalesforceFieldMapService } from "@bff/integrations/salesforce/services/salesforce-field-config.service";
|
||||
import { getStringField } from "@bff/modules/catalog/utils/salesforce-product.mapper";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
SalesforceProduct2Record,
|
||||
@ -33,7 +31,6 @@ export class OrderPricebookService {
|
||||
|
||||
constructor(
|
||||
private readonly sf: SalesforceConnection,
|
||||
private readonly fieldMapService: SalesforceFieldMapService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
@ -81,7 +78,7 @@ export class OrderPricebookService {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const fields = this.fieldMapService.getFieldMap();
|
||||
|
||||
const meta = new Map<string, PricebookProductMeta>();
|
||||
|
||||
for (let i = 0; i < uniqueSkus.length; i += this.chunkSize) {
|
||||
@ -89,13 +86,13 @@ export class OrderPricebookService {
|
||||
const whereIn = buildInClause(slice, "skus");
|
||||
const soql =
|
||||
`SELECT Id, Product2Id, UnitPrice, ` +
|
||||
`Product2.${fields.product.sku}, ` +
|
||||
`Product2.${fields.product.itemClass}, ` +
|
||||
`Product2.${fields.product.internetOfferingType}, ` +
|
||||
`Product2.${fields.product.internetPlanTier}, ` +
|
||||
`Product2.${fields.product.vpnRegion} ` +
|
||||
`Product2.StockKeepingUnit, ` +
|
||||
`Product2.Item_Class__c, ` +
|
||||
`Product2.Internet_Offering_Type__c, ` +
|
||||
`Product2.Internet_Plan_Tier__c, ` +
|
||||
`Product2.VPN_Region__c ` +
|
||||
`FROM PricebookEntry ` +
|
||||
`WHERE Pricebook2Id='${safePricebookId}' AND IsActive=true AND Product2.${fields.product.sku} IN (${whereIn})`;
|
||||
`WHERE Pricebook2Id='${safePricebookId}' AND IsActive=true AND Product2.StockKeepingUnit IN ${whereIn}`;
|
||||
|
||||
try {
|
||||
const res = (await this.sf.query(soql)) as SalesforceQueryResult<
|
||||
@ -104,7 +101,7 @@ export class OrderPricebookService {
|
||||
|
||||
for (const record of res.records ?? []) {
|
||||
const product = record.Product2 ?? undefined;
|
||||
const sku = product ? getStringField(product, "sku", fields) : undefined;
|
||||
const sku = product?.StockKeepingUnit ?? undefined;
|
||||
if (!sku) continue;
|
||||
|
||||
const normalizedSku = sku.trim().toUpperCase();
|
||||
@ -113,14 +110,10 @@ export class OrderPricebookService {
|
||||
pricebookEntryId: assertSalesforceId(record.Id ?? undefined, "pricebookEntryId"),
|
||||
product2Id: record.Product2Id ?? undefined,
|
||||
unitPrice: typeof record.UnitPrice === "number" ? record.UnitPrice : undefined,
|
||||
itemClass: product ? getStringField(product, "itemClass", fields) : undefined,
|
||||
internetOfferingType: product
|
||||
? getStringField(product, "internetOfferingType", fields)
|
||||
: undefined,
|
||||
internetPlanTier: product
|
||||
? getStringField(product, "internetPlanTier", fields)
|
||||
: undefined,
|
||||
vpnRegion: product ? getStringField(product, "vpnRegion", fields) : undefined,
|
||||
itemClass: product?.Item_Class__c ?? undefined,
|
||||
internetOfferingType: product?.Internet_Offering_Type__c ?? undefined,
|
||||
internetPlanTier: product?.Internet_Plan_Tier__c ?? undefined,
|
||||
vpnRegion: product?.VPN_Region__c ?? undefined,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@ -94,88 +94,6 @@ export type OrderType = string;
|
||||
*/
|
||||
export type UserMapping = Pick<UserIdMapping, "userId" | "whmcsClientId" | "sfAccountId">;
|
||||
|
||||
// ============================================================================
|
||||
// Salesforce Field Configuration (Provider-Specific, Not Validated)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for MNP (Mobile Number Portability) field mappings.
|
||||
* Maps logical field names to actual Salesforce custom field names.
|
||||
*/
|
||||
export interface SalesforceOrderMnpFieldConfig {
|
||||
application: string;
|
||||
reservationNumber: string;
|
||||
expiryDate: string;
|
||||
phoneNumber: string;
|
||||
mvnoAccountNumber: string;
|
||||
portingDateOfBirth: string;
|
||||
portingFirstName: string;
|
||||
portingLastName: string;
|
||||
portingFirstNameKatakana: string;
|
||||
portingLastNameKatakana: string;
|
||||
portingGender: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for billing address field mappings.
|
||||
* Maps logical field names to actual Salesforce custom field names.
|
||||
*/
|
||||
export interface SalesforceOrderBillingFieldConfig {
|
||||
street: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for Order field mappings.
|
||||
* Maps logical field names to actual Salesforce custom field names.
|
||||
*/
|
||||
export interface SalesforceOrderFieldConfig {
|
||||
orderType: string;
|
||||
activationType: string;
|
||||
activationScheduledAt: string;
|
||||
activationStatus: string;
|
||||
internetPlanTier: string;
|
||||
installationType: string;
|
||||
weekendInstall: string;
|
||||
accessMode: string;
|
||||
hikariDenwa: string;
|
||||
vpnRegion: string;
|
||||
simType: string;
|
||||
eid: string;
|
||||
simVoiceMail: string;
|
||||
simCallWaiting: string;
|
||||
mnp: SalesforceOrderMnpFieldConfig;
|
||||
whmcsOrderId: string;
|
||||
lastErrorCode?: string;
|
||||
lastErrorMessage?: string;
|
||||
lastAttemptAt?: string;
|
||||
addressChanged: string;
|
||||
billing: SalesforceOrderBillingFieldConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for OrderItem field mappings.
|
||||
* Maps logical field names to actual Salesforce custom field names.
|
||||
*/
|
||||
export interface SalesforceOrderItemFieldConfig {
|
||||
billingCycle: string;
|
||||
whmcsServiceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete Salesforce field configuration for orders.
|
||||
* Aggregates all field configurations needed for order operations.
|
||||
*/
|
||||
export interface SalesforceOrdersFieldConfig {
|
||||
account: SalesforceAccountFieldMap;
|
||||
product: SalesforceProductFieldMap;
|
||||
order: SalesforceOrderFieldConfig;
|
||||
orderItem: SalesforceOrderItemFieldConfig;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Re-export Types from Schema (Schema-First Approach)
|
||||
// ============================================================================
|
||||
|
||||
@ -9,15 +9,6 @@
|
||||
// Business types
|
||||
export { type OrderCreationType, type OrderStatus, type OrderType, type UserMapping } from "./contract";
|
||||
|
||||
// Provider-specific types
|
||||
export type {
|
||||
SalesforceOrderMnpFieldConfig,
|
||||
SalesforceOrderBillingFieldConfig,
|
||||
SalesforceOrderFieldConfig,
|
||||
SalesforceOrderItemFieldConfig,
|
||||
SalesforceOrdersFieldConfig,
|
||||
} from "./contract";
|
||||
|
||||
// Schemas (includes derived types)
|
||||
export * from "./schema";
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user