diff --git a/apps/bff/src/integrations/salesforce/utils/catalog-query-builder.ts b/apps/bff/src/integrations/salesforce/utils/catalog-query-builder.ts index d72aadac..091a3c55 100644 --- a/apps/bff/src/integrations/salesforce/utils/catalog-query-builder.ts +++ b/apps/bff/src/integrations/salesforce/utils/catalog-query-builder.ts @@ -4,24 +4,28 @@ * SOQL query builders for Product2 catalog queries. * Extracted from BaseCatalogService for consistency with order query builders. */ - -import { sanitizeSoqlLiteral } from "./soql.util"; +import { sanitizeSoqlLiteral, assertSoqlFieldName } from "./soql.util"; /** * Build base product query with optional filtering */ export function buildProductQuery( portalPricebookId: string, + portalCategoryField: string, category: string, itemClass: string, additionalFields: string[] = [], additionalConditions: string = "" ): string { + const categoryField = assertSoqlFieldName( + portalCategoryField, + "PRODUCT_PORTAL_CATEGORY_FIELD" + ); const baseFields = [ "Id", "Name", "StockKeepingUnit", - "Portal_Category__c", + categoryField, "Item_Class__c", ]; const allFields = [...baseFields, ...additionalFields].join(", "); @@ -37,7 +41,7 @@ export function buildProductQuery( AND IsActive = true LIMIT 1) FROM Product2 - WHERE Portal_Category__c = '${safeCategory}' + WHERE ${categoryField} = '${safeCategory}' AND Item_Class__c = '${safeItemClass}' AND Portal_Accessible__c = true ${additionalConditions} @@ -50,11 +54,13 @@ export function buildProductQuery( */ export function buildCatalogServiceQuery( portalPricebookId: string, + portalCategoryField: string, category: string, additionalFields: string[] = [] ): string { return buildProductQuery( portalPricebookId, + portalCategoryField, category, "Service", additionalFields, @@ -73,4 +79,3 @@ export function buildAccountEligibilityQuery(sfAccountId: string): string { LIMIT 1 `; } - diff --git a/apps/bff/src/integrations/salesforce/utils/soql.util.ts b/apps/bff/src/integrations/salesforce/utils/soql.util.ts index b017198a..961a2b89 100644 --- a/apps/bff/src/integrations/salesforce/utils/soql.util.ts +++ b/apps/bff/src/integrations/salesforce/utils/soql.util.ts @@ -27,6 +27,21 @@ export function sanitizeSoqlLiteral(value: string): string { // Schema for validating non-empty string values const nonEmptyStringSchema = z.string().min(1, "Value cannot be empty").trim(); +const soqlFieldNameSchema = z + .string() + .trim() + .regex(/^[A-Za-z0-9_.]+$/, "Invalid SOQL field name"); + +/** + * Ensures a provided field name is safe for inclusion in SOQL statements. + */ +export function assertSoqlFieldName(value: unknown, fieldName: string): string { + try { + return soqlFieldNameSchema.parse(value); + } catch { + throw new Error(`Invalid SOQL field name for ${fieldName}`); + } +} /** * Builds an IN clause for SOQL queries from a list of literal values. diff --git a/apps/bff/src/modules/catalog/catalog.controller.ts b/apps/bff/src/modules/catalog/catalog.controller.ts index 8fdaf225..432fb7c5 100644 --- a/apps/bff/src/modules/catalog/catalog.controller.ts +++ b/apps/bff/src/modules/catalog/catalog.controller.ts @@ -2,10 +2,12 @@ import { Controller, Get, Request } from "@nestjs/common"; import type { RequestWithUser } from "@bff/modules/auth/auth.types"; import { parseInternetCatalog, + parseSimCatalog, type InternetAddonCatalogItem, type InternetInstallationCatalogItem, type InternetPlanCatalogItem, type SimActivationFeeCatalogItem, + type SimCatalogCollection, type SimCatalogProduct, type VpnCatalogProduct, } from "@customer-portal/domain/catalog"; @@ -53,14 +55,23 @@ export class CatalogController { } @Get("sim/plans") - async getSimPlans(@Request() req: RequestWithUser): Promise { + async getSimCatalogData(@Request() req: RequestWithUser): Promise { const userId = req.user?.id; if (!userId) { - // Fallback to all regular plans if no user context - const allPlans = await this.simCatalog.getPlans(); - return allPlans.filter(plan => !plan.simHasFamilyDiscount); + const catalog = await this.simCatalog.getCatalogData(); + return parseSimCatalog({ + ...catalog, + plans: catalog.plans.filter(plan => !plan.simHasFamilyDiscount), + }); } - return this.simCatalog.getPlansForUser(userId); + + const [plans, activationFees, addons] = await Promise.all([ + this.simCatalog.getPlansForUser(userId), + this.simCatalog.getActivationFees(), + this.simCatalog.getAddons(), + ]); + + return parseSimCatalog({ plans, activationFees, addons }); } @Get("sim/activation-fees") diff --git a/apps/bff/src/modules/catalog/services/base-catalog.service.ts b/apps/bff/src/modules/catalog/services/base-catalog.service.ts index e178a42e..2121762e 100644 --- a/apps/bff/src/modules/catalog/services/base-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/base-catalog.service.ts @@ -4,6 +4,7 @@ import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { assertSalesforceId, + assertSoqlFieldName, } from "@bff/integrations/salesforce/utils/soql.util"; import { buildProductQuery, @@ -20,6 +21,7 @@ import type { SalesforceResponse } from "@customer-portal/domain/common"; @Injectable() export class BaseCatalogService { protected readonly portalPriceBookId: string; + protected readonly portalCategoryField: string; constructor( protected readonly sf: SalesforceConnection, @@ -28,6 +30,11 @@ export class BaseCatalogService { ) { const portalPricebook = this.configService.get("PORTAL_PRICEBOOK_ID")!; this.portalPriceBookId = assertSalesforceId(portalPricebook, "PORTAL_PRICEBOOK_ID"); + const portalCategory = this.configService.get("PRODUCT_PORTAL_CATEGORY_FIELD") ?? "Product2Categories1__c"; + this.portalCategoryField = assertSoqlFieldName( + portalCategory, + "PRODUCT_PORTAL_CATEGORY_FIELD" + ); } protected async executeQuery( @@ -70,6 +77,7 @@ export class BaseCatalogService { ): string { return buildProductQuery( this.portalPriceBookId, + this.portalCategoryField, category, itemClass, additionalFields, @@ -94,6 +102,7 @@ export class BaseCatalogService { protected buildCatalogServiceQuery(category: string, additionalFields: string[] = []): string { return buildCatalogServiceQuery( this.portalPriceBookId, + this.portalCategoryField, category, additionalFields ); diff --git a/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx b/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx index aecf3eb7..c3a8af2a 100644 --- a/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx +++ b/apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx @@ -165,16 +165,16 @@ export function SimConfigureView({ Type:{" "} {plan.simPlanType === "DataSmsVoice" - ? "Data + Voice" + ? "Data + SMS + Voice" : plan.simPlanType === "DataOnly" ? "Data Only" - : "Voice Only"} + : "Voice + SMS Only"}
- ¥{(plan.monthlyPrice ?? plan.unitPrice ?? 0).toLocaleString()}/mo + ¥{(plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0).toLocaleString()}/mo
{plan.simHasFamilyDiscount && (
Discounted Price
diff --git a/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx b/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx index 7b09f675..54642d39 100644 --- a/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx @@ -11,7 +11,7 @@ interface SimPlanCardProps { } export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFamily?: boolean }) { - const monthlyPrice = plan.monthlyPrice ?? 0; + const displayPrice = plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0; const isFamilyPlan = isFamily ?? Boolean(plan.simHasFamilyDiscount); return ( @@ -35,7 +35,7 @@ export function SimPlanCard({ plan, isFamily }: { plan: SimCatalogProduct; isFam
- {monthlyPrice.toLocaleString()} + {displayPrice.toLocaleString()} /month
{isFamilyPlan && ( diff --git a/apps/portal/src/features/catalog/views/CatalogHome.tsx b/apps/portal/src/features/catalog/views/CatalogHome.tsx index 6f97a510..7bf22091 100644 --- a/apps/portal/src/features/catalog/views/CatalogHome.tsx +++ b/apps/portal/src/features/catalog/views/CatalogHome.tsx @@ -55,7 +55,7 @@ export function CatalogHomeView() { icon={} features={[ "Physical SIM & eSIM", - "Data + SMS/Voice plans", + "Data + SMS + Voice plans", "Family discounts", "Multiple data options", ]} diff --git a/apps/portal/src/features/catalog/views/SimPlans.tsx b/apps/portal/src/features/catalog/views/SimPlans.tsx index 6861e571..cd3b11fd 100644 --- a/apps/portal/src/features/catalog/views/SimPlans.tsx +++ b/apps/portal/src/features/catalog/views/SimPlans.tsx @@ -197,7 +197,7 @@ export function SimPlansContainer() { - Data + SMS/Voice + Data + SMS + Voice {plansByType.DataSmsVoice.length > 0 && ( - Voice Only + Voice + SMS Only {plansByType.VoiceOnly.length > 0 && ( } plans={plansByType.DataSmsVoice} @@ -277,11 +277,11 @@ export function SimPlansContainer() { className={`transition-all duration-500 ease-in-out ${activeTab === "voice-only" ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"}`} > } plans={plansByType.VoiceOnly} diff --git a/packages/domain/catalog/providers/salesforce/mapper.ts b/packages/domain/catalog/providers/salesforce/mapper.ts index a5f91a3c..34ac542b 100644 --- a/packages/domain/catalog/providers/salesforce/mapper.ts +++ b/packages/domain/catalog/providers/salesforce/mapper.ts @@ -69,14 +69,35 @@ function baseProduct( // Derive prices const billingCycle = product.Billing_Cycle__c?.toLowerCase(); - const unitPrice = coerceNumber(pricebookEntry?.UnitPrice); + const unitPriceFromPricebook = coerceNumber(pricebookEntry?.UnitPrice); + const priceField = coerceNumber(product.Price__c); + const monthlyField = coerceNumber(product.Monthly_Price__c); + const oneTimeField = coerceNumber(product.One_Time_Price__c); - if (unitPrice !== undefined) { - base.unitPrice = unitPrice; + if (unitPriceFromPricebook !== undefined) { + base.unitPrice = unitPriceFromPricebook; + } else if (priceField !== undefined) { + base.unitPrice = priceField; + } + + if (monthlyField !== undefined) { + base.monthlyPrice = monthlyField; + } + + if (oneTimeField !== undefined) { + base.oneTimePrice = oneTimeField; + } + + const primaryPrice = + unitPriceFromPricebook ?? monthlyField ?? priceField ?? oneTimeField; + + if (primaryPrice !== undefined) { if (billingCycle === "monthly") { - base.monthlyPrice = unitPrice; + base.monthlyPrice = base.monthlyPrice ?? primaryPrice; } else if (billingCycle) { - base.oneTimePrice = unitPrice; + base.oneTimePrice = base.oneTimePrice ?? primaryPrice; + } else { + base.monthlyPrice = base.monthlyPrice ?? primaryPrice; } } @@ -208,4 +229,3 @@ export function extractPricebookEntry( const activeEntry = entries.find(e => e.IsActive === true); return activeEntry ?? entries[0]; } -