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:
barsa 2025-10-08 11:22:01 +09:00
parent 88d58f9ac5
commit e5c5f352f2
15 changed files with 129 additions and 740 deletions

View File

@ -4,7 +4,6 @@ import { QueueModule } from "@bff/core/queue/queue.module";
import { SalesforceService } from "./salesforce.service"; import { SalesforceService } from "./salesforce.service";
import { SalesforceConnection } from "./services/salesforce-connection.service"; import { SalesforceConnection } from "./services/salesforce-connection.service";
import { SalesforceAccountService } from "./services/salesforce-account.service"; import { SalesforceAccountService } from "./services/salesforce-account.service";
import { SalesforceFieldMapService } from "./services/salesforce-field-config.service";
@Module({ @Module({
imports: [QueueModule, ConfigModule], imports: [QueueModule, ConfigModule],
@ -12,8 +11,7 @@ import { SalesforceFieldMapService } from "./services/salesforce-field-config.se
SalesforceConnection, SalesforceConnection,
SalesforceAccountService, SalesforceAccountService,
SalesforceService, SalesforceService,
SalesforceFieldMapService,
], ],
exports: [SalesforceService, SalesforceConnection, SalesforceFieldMapService], exports: [SalesforceService, SalesforceConnection],
}) })
export class SalesforceModule {} export class SalesforceModule {}

View File

@ -3,7 +3,6 @@ import { Logger } from "nestjs-pino";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import { SalesforceConnection } from "./services/salesforce-connection.service"; import { SalesforceConnection } from "./services/salesforce-connection.service";
import { SalesforceFieldMapService } from "@bff/integrations/salesforce/services/salesforce-field-config.service";
import { import {
SalesforceAccountService, SalesforceAccountService,
type AccountData, type AccountData,
@ -27,7 +26,6 @@ export class SalesforceService implements OnModuleInit {
private configService: ConfigService, private configService: ConfigService,
private connection: SalesforceConnection, private connection: SalesforceConnection,
private accountService: SalesforceAccountService, private accountService: SalesforceAccountService,
private fieldMapService: SalesforceFieldMapService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -118,10 +116,9 @@ export class SalesforceService implements OnModuleInit {
throw new Error("Salesforce connection not available"); throw new Error("Salesforce connection not available");
} }
const fields = this.fieldMapService.getFieldMap();
const result = (await this.connection.query( const result = (await this.connection.query(
`SELECT Id, Status, ${fields.order.activationStatus}, ${fields.order.whmcsOrderId}, `SELECT Id, Status, Type, Activation_Status__c, WHMCS_Order_ID__c,
${fields.order.lastErrorCode}, ${fields.order.lastErrorMessage}, Activation_Error_Code__c, Activation_Error_Message__c,
AccountId, Account.Name AccountId, Account.Name
FROM Order FROM Order
WHERE Id = '${orderId}' WHERE Id = '${orderId}'

View File

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

View File

@ -2,7 +2,6 @@ import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { SalesforceFieldMapService } from "@bff/integrations/salesforce/services/salesforce-field-config.service";
import { import {
assertSalesforceId, assertSalesforceId,
sanitizeSoqlLiteral, sanitizeSoqlLiteral,
@ -12,6 +11,7 @@ import type {
SalesforceProduct2WithPricebookEntries, SalesforceProduct2WithPricebookEntries,
SalesforcePricebookEntryRecord, SalesforcePricebookEntryRecord,
} from "@customer-portal/domain/catalog"; } from "@customer-portal/domain/catalog";
import { Providers as CatalogProviders } from "@customer-portal/domain/catalog";
import type { SalesforceQueryResult } from "@customer-portal/domain/orders"; import type { SalesforceQueryResult } from "@customer-portal/domain/orders";
@Injectable() @Injectable()
@ -20,7 +20,6 @@ export class BaseCatalogService {
constructor( constructor(
protected readonly sf: SalesforceConnection, protected readonly sf: SalesforceConnection,
protected readonly fieldMapService: SalesforceFieldMapService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
@Inject(Logger) protected readonly logger: Logger @Inject(Logger) protected readonly logger: Logger
) { ) {
@ -28,10 +27,6 @@ export class BaseCatalogService {
this.portalPriceBookId = assertSalesforceId(portalPricebook, "PORTAL_PRICEBOOK_ID"); this.portalPriceBookId = assertSalesforceId(portalPricebook, "PORTAL_PRICEBOOK_ID");
} }
protected getFields() {
return this.fieldMapService.getFieldMap();
}
protected async executeQuery<TRecord extends SalesforceProduct2WithPricebookEntries>( protected async executeQuery<TRecord extends SalesforceProduct2WithPricebookEntries>(
soql: string, soql: string,
context: string context: string
@ -52,13 +47,9 @@ export class BaseCatalogService {
protected extractPricebookEntry( protected extractPricebookEntry(
record: SalesforceProduct2WithPricebookEntries record: SalesforceProduct2WithPricebookEntries
): SalesforcePricebookEntryRecord | undefined { ): SalesforcePricebookEntryRecord | undefined {
const pricebookEntries = record.PricebookEntries?.records; const entry = CatalogProviders.Salesforce.extractPricebookEntry(record);
const entry = Array.isArray(pricebookEntries) ? pricebookEntries[0] : undefined;
if (!entry) { if (!entry) {
const fields = this.getFields(); const sku = record.StockKeepingUnit ?? undefined;
const skuField = fields.product.sku;
const skuRaw = Reflect.get(record, skuField) as unknown;
const sku = typeof skuRaw === "string" ? skuRaw : undefined;
this.logger.warn( this.logger.warn(
`No pricebook entry found for product ${String(record.Name)} (SKU: ${String(sku ?? "")}). Pricebook ID: ${this.portalPriceBookId}.` `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[] = [], additionalFields: string[] = [],
additionalConditions: string = "" additionalConditions: string = ""
): string { ): string {
const fields = this.getFields();
const baseFields = [ const baseFields = [
"Id", "Id",
"Name", "Name",
fields.product.sku, "StockKeepingUnit",
fields.product.portalCategory, "Portal_Category__c",
fields.product.itemClass, "Item_Class__c",
]; ];
const allFields = [...baseFields, ...additionalFields].join(", "); const allFields = [...baseFields, ...additionalFields].join(", ");
@ -91,11 +81,11 @@ export class BaseCatalogService {
SELECT ${allFields}, SELECT ${allFields},
(SELECT Id, UnitPrice, Pricebook2Id, Product2Id, IsActive FROM PricebookEntries WHERE Pricebook2Id = '${this.portalPriceBookId}' AND IsActive = true LIMIT 1) (SELECT Id, UnitPrice, Pricebook2Id, Product2Id, IsActive FROM PricebookEntries WHERE Pricebook2Id = '${this.portalPriceBookId}' AND IsActive = true LIMIT 1)
FROM Product2 FROM Product2
WHERE ${fields.product.portalCategory} = '${safeCategory}' WHERE Portal_Category__c = '${safeCategory}'
AND ${fields.product.itemClass} = '${safeItemClass}' AND Item_Class__c = '${safeItemClass}'
AND ${fields.product.portalAccessible} = true AND Portal_Accessible__c = true
${additionalConditions} ${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 { protected buildCatalogServiceQuery(category: string, additionalFields: string[] = []): string {
const fields = this.getFields();
return this.buildProductQuery( return this.buildProductQuery(
category, category,
"Service", "Service",
additionalFields, additionalFields,
`AND ${fields.product.portalCatalog} = true` `AND Portal_Catalog__c = true`
); );
} }
} }

View File

@ -7,17 +7,12 @@ import type {
InternetInstallationCatalogItem, InternetInstallationCatalogItem,
InternetAddonCatalogItem, InternetAddonCatalogItem,
} from "@customer-portal/domain/catalog"; } from "@customer-portal/domain/catalog";
import { Providers as CatalogProviders } from "@customer-portal/domain/catalog";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.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 { Logger } from "nestjs-pino";
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util";
import {
mapInternetPlan,
mapInternetInstallation,
mapInternetAddon,
} from "@bff/modules/catalog/utils/salesforce-product.mapper";
interface SalesforceAccount { interface SalesforceAccount {
Id: string; Id: string;
@ -28,20 +23,18 @@ interface SalesforceAccount {
export class InternetCatalogService extends BaseCatalogService { export class InternetCatalogService extends BaseCatalogService {
constructor( constructor(
sf: SalesforceConnection, sf: SalesforceConnection,
fieldMapService: SalesforceFieldMapService,
configService: ConfigService, configService: ConfigService,
@Inject(Logger) logger: Logger, @Inject(Logger) logger: Logger,
private mappingsService: MappingsService private mappingsService: MappingsService
) { ) {
super(sf, fieldMapService, configService, logger); super(sf, configService, logger);
} }
async getPlans(): Promise<InternetPlanCatalogItem[]> { async getPlans(): Promise<InternetPlanCatalogItem[]> {
const fields = this.getFields();
const soql = this.buildCatalogServiceQuery("Internet", [ const soql = this.buildCatalogServiceQuery("Internet", [
fields.product.internetPlanTier, "Internet_Plan_Tier__c",
fields.product.internetOfferingType, "Internet_Offering_Type__c",
fields.product.displayOrder, "Catalog_Order__c",
]); ]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>( const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql, soql,
@ -50,15 +43,14 @@ export class InternetCatalogService extends BaseCatalogService {
return records.map(record => { return records.map(record => {
const entry = this.extractPricebookEntry(record); const entry = this.extractPricebookEntry(record);
return mapInternetPlan(record, fields, entry); return CatalogProviders.Salesforce.mapInternetPlan(record, entry);
}); });
} }
async getInstallations(): Promise<InternetInstallationCatalogItem[]> { async getInstallations(): Promise<InternetInstallationCatalogItem[]> {
const fields = this.getFields();
const soql = this.buildProductQuery("Internet", "Installation", [ const soql = this.buildProductQuery("Internet", "Installation", [
fields.product.billingCycle, "Billing_Cycle__c",
fields.product.displayOrder, "Catalog_Order__c",
]); ]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>( const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql, soql,
@ -70,18 +62,17 @@ export class InternetCatalogService extends BaseCatalogService {
return records return records
.map(record => { .map(record => {
const entry = this.extractPricebookEntry(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)); .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
} }
async getAddons(): Promise<InternetAddonCatalogItem[]> { async getAddons(): Promise<InternetAddonCatalogItem[]> {
const fields = this.getFields();
const soql = this.buildProductQuery("Internet", "Add-on", [ const soql = this.buildProductQuery("Internet", "Add-on", [
fields.product.billingCycle, "Billing_Cycle__c",
fields.product.displayOrder, "Catalog_Order__c",
fields.product.bundledAddon, "Bundled_Addon__c",
fields.product.isBundledAddon, "Is_Bundled_Addon__c",
]); ]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>( const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql, soql,
@ -93,7 +84,7 @@ export class InternetCatalogService extends BaseCatalogService {
return records return records
.map(record => { .map(record => {
const entry = this.extractPricebookEntry(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)); .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
} }
@ -120,9 +111,8 @@ export class InternetCatalogService extends BaseCatalogService {
} }
// Get customer's eligibility from Salesforce // Get customer's eligibility from Salesforce
const fields = this.getFields();
const sfAccountId = assertSalesforceId(mapping.sfAccountId, "sfAccountId"); 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"); const accounts = await this.executeQuery(soql, "Customer Eligibility");
if (accounts.length === 0) { if (accounts.length === 0) {

View File

@ -1,16 +1,12 @@
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { BaseCatalogService } from "./base-catalog.service"; import { BaseCatalogService } from "./base-catalog.service";
import { SalesforceFieldMapService } from "@bff/integrations/salesforce/services/salesforce-field-config.service";
import type { import type {
SalesforceProduct2WithPricebookEntries, SalesforceProduct2WithPricebookEntries,
SimCatalogProduct, SimCatalogProduct,
SimActivationFeeCatalogItem, SimActivationFeeCatalogItem,
} from "@customer-portal/domain/catalog"; } from "@customer-portal/domain/catalog";
import { import { Providers as CatalogProviders } from "@customer-portal/domain/catalog";
mapSimProduct,
mapSimActivationFee,
} from "@bff/modules/catalog/utils/salesforce-product.mapper";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
@ -20,22 +16,20 @@ import { WhmcsConnectionOrchestratorService } from "@bff/integrations/whmcs/conn
export class SimCatalogService extends BaseCatalogService { export class SimCatalogService extends BaseCatalogService {
constructor( constructor(
sf: SalesforceConnection, sf: SalesforceConnection,
fieldMapService: SalesforceFieldMapService,
configService: ConfigService, configService: ConfigService,
@Inject(Logger) logger: Logger, @Inject(Logger) logger: Logger,
private mappingsService: MappingsService, private mappingsService: MappingsService,
private whmcs: WhmcsConnectionOrchestratorService private whmcs: WhmcsConnectionOrchestratorService
) { ) {
super(sf, fieldMapService, configService, logger); super(sf, configService, logger);
} }
async getPlans(): Promise<SimCatalogProduct[]> { async getPlans(): Promise<SimCatalogProduct[]> {
const fields = this.getFields();
const soql = this.buildCatalogServiceQuery("SIM", [ const soql = this.buildCatalogServiceQuery("SIM", [
fields.product.simDataSize, "SIM_Data_Size__c",
fields.product.simPlanType, "SIM_Plan_Type__c",
fields.product.simHasFamilyDiscount, "SIM_Has_Family_Discount__c",
fields.product.displayOrder, "Catalog_Order__c",
]); ]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>( const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql, soql,
@ -44,7 +38,7 @@ export class SimCatalogService extends BaseCatalogService {
return records.map(record => { return records.map(record => {
const entry = this.extractPricebookEntry(record); const entry = this.extractPricebookEntry(record);
const product = mapSimProduct(record, fields, entry); const product = CatalogProviders.Salesforce.mapSimProduct(record, entry);
return { return {
...product, ...product,
@ -54,7 +48,6 @@ export class SimCatalogService extends BaseCatalogService {
} }
async getActivationFees(): Promise<SimActivationFeeCatalogItem[]> { async getActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
const fields = this.getFields();
const soql = this.buildProductQuery("SIM", "Activation", []); const soql = this.buildProductQuery("SIM", "Activation", []);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>( const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql, soql,
@ -63,17 +56,16 @@ export class SimCatalogService extends BaseCatalogService {
return records.map(record => { return records.map(record => {
const entry = this.extractPricebookEntry(record); const entry = this.extractPricebookEntry(record);
return mapSimActivationFee(record, fields, entry); return CatalogProviders.Salesforce.mapSimActivationFee(record, entry);
}); });
} }
async getAddons(): Promise<SimCatalogProduct[]> { async getAddons(): Promise<SimCatalogProduct[]> {
const fields = this.getFields();
const soql = this.buildProductQuery("SIM", "Add-on", [ const soql = this.buildProductQuery("SIM", "Add-on", [
fields.product.billingCycle, "Billing_Cycle__c",
fields.product.displayOrder, "Catalog_Order__c",
fields.product.bundledAddon, "Bundled_Addon__c",
fields.product.isBundledAddon, "Is_Bundled_Addon__c",
]); ]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>( const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql, soql,
@ -83,7 +75,7 @@ export class SimCatalogService extends BaseCatalogService {
return records return records
.map(record => { .map(record => {
const entry = this.extractPricebookEntry(record); const entry = this.extractPricebookEntry(record);
const product = mapSimProduct(record, fields, entry); const product = CatalogProviders.Salesforce.mapSimProduct(record, entry);
return { return {
...product, ...product,

View File

@ -2,26 +2,23 @@ import { Injectable, Inject } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; 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 { BaseCatalogService } from "./base-catalog.service";
import type { SalesforceProduct2WithPricebookEntries, VpnCatalogProduct } from "@customer-portal/domain/catalog"; 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() @Injectable()
export class VpnCatalogService extends BaseCatalogService { export class VpnCatalogService extends BaseCatalogService {
constructor( constructor(
sf: SalesforceConnection, sf: SalesforceConnection,
fieldMapService: SalesforceFieldMapService,
configService: ConfigService, configService: ConfigService,
@Inject(Logger) logger: Logger @Inject(Logger) logger: Logger
) { ) {
super(sf, fieldMapService, configService, logger); super(sf, configService, logger);
} }
async getPlans(): Promise<VpnCatalogProduct[]> { async getPlans(): Promise<VpnCatalogProduct[]> {
const fields = this.getFields();
const soql = this.buildCatalogServiceQuery("VPN", [ const soql = this.buildCatalogServiceQuery("VPN", [
fields.product.vpnRegion, "VPN_Region__c",
fields.product.displayOrder, "Catalog_Order__c",
]); ]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>( const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql, soql,
@ -30,7 +27,7 @@ export class VpnCatalogService extends BaseCatalogService {
return records.map(record => { return records.map(record => {
const entry = this.extractPricebookEntry(record); const entry = this.extractPricebookEntry(record);
const product = mapVpnProduct(record, fields, entry); const product = CatalogProviders.Salesforce.mapVpnProduct(record, entry);
return { return {
...product, ...product,
description: product.description || product.name, description: product.description || product.name,
@ -39,8 +36,7 @@ export class VpnCatalogService extends BaseCatalogService {
} }
async getActivationFees(): Promise<VpnCatalogProduct[]> { async getActivationFees(): Promise<VpnCatalogProduct[]> {
const fields = this.getFields(); const soql = this.buildProductQuery("VPN", "Activation", ["VPN_Region__c"]);
const soql = this.buildProductQuery("VPN", "Activation", [fields.product.vpnRegion]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>( const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql, soql,
"VPN Activation Fees" "VPN Activation Fees"
@ -48,7 +44,7 @@ export class VpnCatalogService extends BaseCatalogService {
return records.map(record => { return records.map(record => {
const pricebookEntry = this.extractPricebookEntry(record); const pricebookEntry = this.extractPricebookEntry(record);
const product = mapVpnProduct(record, fields, pricebookEntry); const product = CatalogProviders.Salesforce.mapVpnProduct(record, pricebookEntry);
return { return {
...product, ...product,

View File

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

View File

@ -3,7 +3,6 @@ import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { OrderFulfillmentOrchestrator } from "../services/order-fulfillment-orchestrator.service"; import { OrderFulfillmentOrchestrator } from "../services/order-fulfillment-orchestrator.service";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.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 type { ProvisioningJobData } from "./provisioning.queue";
import { CacheService } from "@bff/infra/cache/cache.service"; import { CacheService } from "@bff/infra/cache/cache.service";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
@ -16,7 +15,6 @@ export class ProvisioningProcessor extends WorkerHost {
constructor( constructor(
private readonly orchestrator: OrderFulfillmentOrchestrator, private readonly orchestrator: OrderFulfillmentOrchestrator,
private readonly salesforceService: SalesforceService, private readonly salesforceService: SalesforceService,
private readonly fieldMapService: SalesforceFieldMapService,
private readonly cache: CacheService, private readonly cache: CacheService,
private readonly config: ConfigService, private readonly config: ConfigService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
@ -34,15 +32,10 @@ export class ProvisioningProcessor extends WorkerHost {
}); });
// Guard: Only process if Salesforce Order is currently 'Activating' // Guard: Only process if Salesforce Order is currently 'Activating'
const fields = this.fieldMapService.getFieldMap();
const order = await this.salesforceService.getOrder(sfOrderId); const order = await this.salesforceService.getOrder(sfOrderId);
const status = order const status = order?.Activation_Status__c ?? "";
? ((Reflect.get(order, fields.order.activationStatus) as string | undefined) ?? "") const lastErrorCode = order?.Activation_Error_Code__c ?? "";
: "";
const lastErrorCodeField = fields.order.lastErrorCode;
const lastErrorCode = lastErrorCodeField
? ((order ? (Reflect.get(order, lastErrorCodeField) as string | undefined) : undefined) ?? "")
: "";
if (status !== "Activating") { if (status !== "Activating") {
this.logger.log("Skipping provisioning job: Order not in Activating state", { this.logger.log("Skipping provisioning job: Order not in Activating state", {
sfOrderId, sfOrderId,

View File

@ -15,7 +15,6 @@ import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service"
import { SimFulfillmentService } from "./sim-fulfillment.service"; import { SimFulfillmentService } from "./sim-fulfillment.service";
import { DistributedTransactionService } from "@bff/core/database/services/distributed-transaction.service"; import { DistributedTransactionService } from "@bff/core/database/services/distributed-transaction.service";
import { getErrorMessage } from "@bff/core/utils/error.util"; 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 OrderSummary, type OrderDetails, type SalesforceOrderRecord, type SalesforceOrderItemRecord } from "@customer-portal/domain/orders";
import type { FulfillmentOrderDetails, FulfillmentOrderItem } from "../types/fulfillment.types"; import type { FulfillmentOrderDetails, FulfillmentOrderItem } from "../types/fulfillment.types";
@ -45,7 +44,6 @@ export interface OrderFulfillmentContext {
export class OrderFulfillmentOrchestrator { export class OrderFulfillmentOrchestrator {
constructor( constructor(
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
private readonly fieldMapService: SalesforceFieldMapService,
private readonly salesforceService: SalesforceService, private readonly salesforceService: SalesforceService,
private readonly whmcsOrderService: WhmcsOrderService, private readonly whmcsOrderService: WhmcsOrderService,
private readonly orderOrchestrator: OrderOrchestrator, private readonly orderOrchestrator: OrderOrchestrator,
@ -135,17 +133,17 @@ export class OrderFulfillmentOrchestrator {
id: "sf_status_update", id: "sf_status_update",
description: "Update Salesforce order status to Activating", description: "Update Salesforce order status to Activating",
execute: async () => { execute: async () => {
const fields = this.fieldMapService.getFieldMap();
return await this.salesforceService.updateOrder({ return await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
[fields.order.activationStatus]: "Activating", Activation_Status__c: "Activating",
}); });
}, },
rollback: async () => { rollback: async () => {
const fields = this.fieldMapService.getFieldMap();
await this.salesforceService.updateOrder({ await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
[fields.order.activationStatus]: "Failed", Activation_Status__c: "Failed",
}); });
}, },
critical: true, critical: true,
@ -270,20 +268,20 @@ export class OrderFulfillmentOrchestrator {
id: "sf_success_update", id: "sf_success_update",
description: "Update Salesforce with success", description: "Update Salesforce with success",
execute: async () => { execute: async () => {
const fields = this.fieldMapService.getFieldMap();
return await this.salesforceService.updateOrder({ return await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
Status: "Completed", Status: "Completed",
[fields.order.activationStatus]: "Activated", Activation_Status__c: "Activated",
[fields.order.whmcsOrderId]: whmcsAcceptResult?.orderId?.toString(), WHMCS_Order_ID__c: whmcsAcceptResult?.orderId?.toString(),
}); });
}, },
rollback: async () => { rollback: async () => {
const fields = this.fieldMapService.getFieldMap();
await this.salesforceService.updateOrder({ await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
[fields.order.activationStatus]: "Failed", Activation_Status__c: "Failed",
}); });
}, },
critical: true, critical: true,
@ -370,10 +368,10 @@ export class OrderFulfillmentOrchestrator {
// Step 2: Update Salesforce status to "Activating" // Step 2: Update Salesforce status to "Activating"
await this.executeStep(context, "sf_status_update", async () => { await this.executeStep(context, "sf_status_update", async () => {
const fields = this.fieldMapService.getFieldMap();
await this.salesforceService.updateOrder({ await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
[fields.order.activationStatus]: "Activating", Activation_Status__c: "Activating",
}); });
}); });
@ -472,12 +470,12 @@ export class OrderFulfillmentOrchestrator {
// Step 8: Update Salesforce with success // Step 8: Update Salesforce with success
await this.executeStep(context, "sf_success_update", async () => { await this.executeStep(context, "sf_success_update", async () => {
const fields = this.fieldMapService.getFieldMap();
await this.salesforceService.updateOrder({ await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
Status: "Completed", Status: "Completed",
[fields.order.activationStatus]: "Activated", Activation_Status__c: "Activated",
[fields.order.whmcsOrderId]: context.whmcsResult!.orderId.toString(), WHMCS_Order_ID__c: context.whmcsResult!.orderId.toString(),
}); });
}); });
@ -667,7 +665,7 @@ export class OrderFulfillmentOrchestrator {
): Promise<void> { ): Promise<void> {
const errorCode = this.orderFulfillmentErrorService.determineErrorCode(error); const errorCode = this.orderFulfillmentErrorService.determineErrorCode(error);
const userMessage = error.message; const userMessage = error.message;
const fields = this.fieldMapService.getFieldMap();
this.logger.error("Fulfillment orchestration failed", { this.logger.error("Fulfillment orchestration failed", {
sfOrderId: context.sfOrderId, sfOrderId: context.sfOrderId,
@ -683,14 +681,14 @@ export class OrderFulfillmentOrchestrator {
Id: context.sfOrderId, Id: context.sfOrderId,
// Set overall Order.Status to Pending Review for manual attention // Set overall Order.Status to Pending Review for manual attention
Status: "Pending Review", 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 }); await this.salesforceService.updateOrder(updates as { Id: string; [key: string]: unknown });

View File

@ -5,7 +5,6 @@ import { WhmcsPaymentService } from "@bff/integrations/whmcs/services/whmcs-paym
import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders"; 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"; import { sfOrderIdParamSchema } from "@customer-portal/domain/orders";
type OrderStringFieldKey = "activationStatus"; type OrderStringFieldKey = "activationStatus";
@ -24,7 +23,6 @@ export interface OrderFulfillmentValidationResult {
export class OrderFulfillmentValidator { export class OrderFulfillmentValidator {
constructor( constructor(
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
private readonly fieldMapService: SalesforceFieldMapService,
private readonly salesforceService: SalesforceService, private readonly salesforceService: SalesforceService,
private readonly whmcsPaymentService: WhmcsPaymentService, private readonly whmcsPaymentService: WhmcsPaymentService,
private readonly mappingsService: MappingsService private readonly mappingsService: MappingsService
@ -50,9 +48,8 @@ export class OrderFulfillmentValidator {
const sfOrder = await this.validateSalesforceOrder(sfOrderId); const sfOrder = await this.validateSalesforceOrder(sfOrderId);
// 2. Check if already provisioned (idempotency) // 2. Check if already provisioned (idempotency)
const fieldMap = this.fieldMapService.getFieldMap();
const rawWhmcs = Reflect.get(sfOrder, fieldMap.order.whmcsOrderId) as unknown; const existingWhmcsOrderId = sfOrder.WHMCS_Order_ID__c;
const existingWhmcsOrderId = typeof rawWhmcs === "string" ? rawWhmcs : undefined;
if (existingWhmcsOrderId) { if (existingWhmcsOrderId) {
this.logger.log("Order already provisioned", { this.logger.log("Order already provisioned", {
sfOrderId, sfOrderId,
@ -116,11 +113,11 @@ export class OrderFulfillmentValidator {
throw new BadRequestException(`Cannot provision cancelled order ${sfOrderId}`); throw new BadRequestException(`Cannot provision cancelled order ${sfOrderId}`);
} }
const fieldMap = this.fieldMapService.getFieldMap();
this.logger.log("Salesforce order validated", { this.logger.log("Salesforce order validated", {
sfOrderId, sfOrderId,
status: order.Status, status: order.Status,
activationStatus: pickOrderString(order, "activationStatus", fieldMap), activationStatus: order.Activation_Status__c,
accountId: order.AccountId, 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;
}

View File

@ -109,18 +109,15 @@ export class OrderOrchestrator {
const safeOrderId = assertSalesforceId(orderId, "orderId"); const safeOrderId = assertSalesforceId(orderId, "orderId");
this.logger.log({ orderId: safeOrderId }, "Fetching order details with items"); this.logger.log({ orderId: safeOrderId }, "Fetching order details with items");
const fieldMap = this.fieldMapService.getFieldMap(); const orderQueryFields = buildOrderSelectFields(["Account.Name"]).join(", ");
const orderQueryFields = this.fieldMapService.getOrderQueryFields(); const orderItemProduct2Fields = buildOrderItemProduct2Fields().map(f => `PricebookEntry.Product2.${f}`);
const orderItemSelect = [ const orderItemSelect = [
this.fieldMapService.getOrderItemQueryFields(), ...buildOrderItemSelectFields(),
this.fieldMapService.getOrderItemProduct2Select(), ...orderItemProduct2Fields,
] ].join(", ");
.filter(Boolean)
.join(", ");
const orderSoql = ` const orderSoql = `
SELECT ${orderQueryFields}, OrderNumber, TotalAmount, SELECT ${orderQueryFields}
Account.Name, CreatedDate, LastModifiedDate
FROM Order FROM Order
WHERE Id = '${safeOrderId}' WHERE Id = '${safeOrderId}'
LIMIT 1 LIMIT 1
@ -155,8 +152,7 @@ export class OrderOrchestrator {
return OrderProviders.Salesforce.transformSalesforceOrderDetails( return OrderProviders.Salesforce.transformSalesforceOrderDetails(
order, order,
orderItems, orderItems
fieldMap
); );
} catch (error: unknown) { } catch (error: unknown) {
this.logger.error("Failed to fetch order with items", { this.logger.error("Failed to fetch order with items", {
@ -183,17 +179,15 @@ export class OrderOrchestrator {
return []; return [];
} }
const fieldMap = this.fieldMapService.getFieldMap(); const orderQueryFields = buildOrderSelectFields().join(", ");
const orderQueryFields = this.fieldMapService.getOrderQueryFields(); const orderItemProduct2Fields = buildOrderItemProduct2Fields().map(f => `PricebookEntry.Product2.${f}`);
const orderItemSelect = [ const orderItemSelect = [
this.fieldMapService.getOrderItemQueryFields(), ...buildOrderItemSelectFields(),
this.fieldMapService.getOrderItemProduct2Select(), ...orderItemProduct2Fields,
] ].join(", ");
.filter(Boolean)
.join(", ");
const ordersSoql = ` const ordersSoql = `
SELECT ${orderQueryFields}, OrderNumber, TotalAmount, CreatedDate, LastModifiedDate SELECT ${orderQueryFields}
FROM Order FROM Order
WHERE AccountId = '${sfAccountId}' WHERE AccountId = '${sfAccountId}'
ORDER BY CreatedDate DESC ORDER BY CreatedDate DESC
@ -219,42 +213,52 @@ export class OrderOrchestrator {
return []; return [];
} }
const orderIdsClause = buildInClause(rawOrderIds, "orderIds"); const orderIds = rawOrderIds.map(id => assertSalesforceId(id, "orderId"));
const inClause = buildInClause(orderIds, "orderIds");
const itemsSoql = ` const itemsSoql = `
SELECT ${orderItemSelect} SELECT ${orderItemSelect}
FROM OrderItem FROM OrderItem
WHERE OrderId IN (${orderIdsClause}) WHERE OrderId IN ${inClause}
ORDER BY OrderId, CreatedDate ASC ORDER BY CreatedDate ASC
`; `;
const itemsResult = (await this.sf.query( const itemsResult = (await this.sf.query(
itemsSoql itemsSoql
)) as SalesforceQueryResult<SalesforceOrderItemRecord>; )) as SalesforceQueryResult<SalesforceOrderItemRecord>;
const allItems = itemsResult.records ?? []; const allItems = itemsResult.records || [];
const itemsByOrder = allItems.reduce<Record<string, SalesforceOrderItemRecord[]>>( // Group items by order ID
(acc, record) => { const itemsByOrder: Record<string, SalesforceOrderItemRecord[]> = {};
const orderId = typeof record.OrderId === "string" ? record.OrderId : undefined; for (const item of allItems) {
if (!orderId) return acc; const orderId = item.OrderId;
if (!acc[orderId]) acc[orderId] = []; if (typeof orderId === "string") {
acc[orderId].push(record); if (!itemsByOrder[orderId]) {
return acc; 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 return orders
.filter((order): order is SalesforceOrderRecord & { Id: string } => typeof order.Id === "string") .filter((order): order is SalesforceOrderRecord & { Id: string } => typeof order.Id === "string")
.map(order => .map(order =>
OrderProviders.Salesforce.transformSalesforceOrderSummary( OrderProviders.Salesforce.transformSalesforceOrderSummary(
order, order,
itemsByOrder[order.Id] ?? [], itemsByOrder[order.Id] ?? []
fieldMap
) )
); );
} catch (error: unknown) { } 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), error: getErrorMessage(error),
userId, userId,
}); });

View File

@ -2,8 +2,6 @@ import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; 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 { z } from "zod";
import type { import type {
SalesforceProduct2Record, SalesforceProduct2Record,
@ -33,7 +31,6 @@ export class OrderPricebookService {
constructor( constructor(
private readonly sf: SalesforceConnection, private readonly sf: SalesforceConnection,
private readonly fieldMapService: SalesforceFieldMapService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -81,7 +78,7 @@ export class OrderPricebookService {
return new Map(); return new Map();
} }
const fields = this.fieldMapService.getFieldMap();
const meta = new Map<string, PricebookProductMeta>(); const meta = new Map<string, PricebookProductMeta>();
for (let i = 0; i < uniqueSkus.length; i += this.chunkSize) { for (let i = 0; i < uniqueSkus.length; i += this.chunkSize) {
@ -89,13 +86,13 @@ export class OrderPricebookService {
const whereIn = buildInClause(slice, "skus"); const whereIn = buildInClause(slice, "skus");
const soql = const soql =
`SELECT Id, Product2Id, UnitPrice, ` + `SELECT Id, Product2Id, UnitPrice, ` +
`Product2.${fields.product.sku}, ` + `Product2.StockKeepingUnit, ` +
`Product2.${fields.product.itemClass}, ` + `Product2.Item_Class__c, ` +
`Product2.${fields.product.internetOfferingType}, ` + `Product2.Internet_Offering_Type__c, ` +
`Product2.${fields.product.internetPlanTier}, ` + `Product2.Internet_Plan_Tier__c, ` +
`Product2.${fields.product.vpnRegion} ` + `Product2.VPN_Region__c ` +
`FROM PricebookEntry ` + `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 { try {
const res = (await this.sf.query(soql)) as SalesforceQueryResult< const res = (await this.sf.query(soql)) as SalesforceQueryResult<
@ -104,7 +101,7 @@ export class OrderPricebookService {
for (const record of res.records ?? []) { for (const record of res.records ?? []) {
const product = record.Product2 ?? undefined; const product = record.Product2 ?? undefined;
const sku = product ? getStringField(product, "sku", fields) : undefined; const sku = product?.StockKeepingUnit ?? undefined;
if (!sku) continue; if (!sku) continue;
const normalizedSku = sku.trim().toUpperCase(); const normalizedSku = sku.trim().toUpperCase();
@ -113,14 +110,10 @@ export class OrderPricebookService {
pricebookEntryId: assertSalesforceId(record.Id ?? undefined, "pricebookEntryId"), pricebookEntryId: assertSalesforceId(record.Id ?? undefined, "pricebookEntryId"),
product2Id: record.Product2Id ?? undefined, product2Id: record.Product2Id ?? undefined,
unitPrice: typeof record.UnitPrice === "number" ? record.UnitPrice : undefined, unitPrice: typeof record.UnitPrice === "number" ? record.UnitPrice : undefined,
itemClass: product ? getStringField(product, "itemClass", fields) : undefined, itemClass: product?.Item_Class__c ?? undefined,
internetOfferingType: product internetOfferingType: product?.Internet_Offering_Type__c ?? undefined,
? getStringField(product, "internetOfferingType", fields) internetPlanTier: product?.Internet_Plan_Tier__c ?? undefined,
: undefined, vpnRegion: product?.VPN_Region__c ?? undefined,
internetPlanTier: product
? getStringField(product, "internetPlanTier", fields)
: undefined,
vpnRegion: product ? getStringField(product, "vpnRegion", fields) : undefined,
}); });
} }
} catch (error) { } catch (error) {

View File

@ -94,88 +94,6 @@ export type OrderType = string;
*/ */
export type UserMapping = Pick<UserIdMapping, "userId" | "whmcsClientId" | "sfAccountId">; 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) // Re-export Types from Schema (Schema-First Approach)
// ============================================================================ // ============================================================================

View File

@ -9,15 +9,6 @@
// Business types // Business types
export { type OrderCreationType, type OrderStatus, type OrderType, type UserMapping } from "./contract"; 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) // Schemas (includes derived types)
export * from "./schema"; export * from "./schema";