# Product Catalog & SKU Architecture - Complete Guide ## Subscription-Aware Catalog Behavior The portal surfaces catalog content and checkout actions based on the customer's existing subscriptions. This avoids duplicate Internet orders and automatically exposes SIM family discounts to eligible customers. - Internet orders - The backend already enforces a duplication guard during order validation (see Orders validation: Internet duplication guard). - The portal Internet catalog disables the Configure button if `/subscriptions/active` contains an active Internet service. The checkout flow also blocks submission client-side with a clear error. - SIM family discounts - The backend `GET /catalog/sim/plans` personalizes the plan list per user. If a user has an existing active SIM service (checked via WHMCS), the response includes family discount plans with `hasFamilyDiscount: true`. - The portal SIM catalog automatically renders a Family Discount section and banner when such plans are present. Implementation notes - Portal uses the `useActiveSubscriptions()` hook which calls `GET /subscriptions/active` and relies on the shared Subscription type (product/group name and status) to detect Internet/SIM services. - No guessing of API response shapes; behavior aligns with the documented BFF controllers in `apps/bff/src/subscriptions/subscriptions.controller.ts` and catalog services. This document consolidates the complete SKU-based architecture for the catalog and order system, including implementation patterns, edge cases, and migration strategy. ## Core Principles ### 1. **Frontend Responsibility** - Frontend loads ALL required SKUs from catalog API - Frontend determines which SKUs to include based on customer selections - Frontend passes explicit SKU list to backend ### 2. **Backend Simplicity** - Backend processes provided SKU list without guessing - No order-type-specific logic for determining products - Clean validation and error handling ### 3. **Salesforce as Source of Truth** - All product relationships defined in Salesforce - Catalog API exposes all necessary product types - Clear product classification via `Item_Class__c` ## Product Classification ### Item_Class\_\_c Values ```sql 'Service' -- Main customer-selectable products (Internet plans, SIM plans, VPN) 'Activation' -- Required one-time activation fees 'Installation' -- Installation options (one-time or monthly) 'Add-on' -- Optional additional services ``` ### Portal Visibility Logic ```sql Portal_Catalog__c = true -- Visible in main catalog (customer selects) Portal_Accessible__c = true -- Can be used in orders (includes hidden fees/add-ons) ``` ## SKU Naming Strategy ### Detailed SKU Format Use explicit product variants with all relevant attributes: **Internet SKUs:** - `INTERNET-SILVER-HOME-1G` - `INTERNET-GOLD-APT-1G` - `INTERNET-PLATINUM-APT-100M` - `INTERNET-INSTALL-SINGLE` - `INTERNET-INSTALL-WEEKEND` - `INTERNET-ADDON-HIKARI-DENWA-INSTALL` **SIM SKUs:** - `SIM-DATA-ONLY-5GB` - `SIM-DATA-VOICE-50GB` - `SIM-DATA-VOICE-50GB-FAMILY` - `SIM-ACTIVATION-FEE` - `SIM-ADDON-VOICE-MAIL` **VPN SKUs:** - `VPN-REMOTE-ACCESS-USA-SF` - `VPN-REMOTE-ACCESS-UK-LONDON` - `VPN-ACTIVATION-FEE` ## New Catalog API Structure ### Main Catalog Endpoint ```typescript GET /catalog // Returns only customer-selectable products (Portal_Catalog__c = true) { internet: Array<{ id: string, name: string, sku: string, // Detailed format: INTERNET-SILVER-APT-1G tier: string, offeringType: string, monthlyPrice: number }>, sim: Array<{ id: string, name: string, sku: string, // Detailed format: SIM-DATA-VOICE-50GB dataSize: string, planType: string, hasFamilyDiscount: boolean }>, vpn: Array<{ id: string, name: string, sku: string // Detailed format: VPN-REMOTE-ACCESS-USA-SF }> } ``` ### Related Products Endpoints ```typescript GET /catalog/sim/activation-fees // Returns SIM activation products (Item_Class__c = 'Activation') Array<{id: string, name: string, sku: string, price: number, isDefault: boolean}> GET /catalog/vpn/activation-fees?region= // Returns VPN activation products (Item_Class__c = 'Activation') Array<{id: string, name: string, sku: string, price: number, isDefault: boolean}> GET /catalog/internet/installations // Returns Internet installation options (Item_Class__c = 'Installation') Array<{id: string, name: string, sku: string, billingCycle: string, price: number, installmentType: string}> GET /catalog/internet/addons // Returns Internet add-ons (Item_Class__c = 'Add-on') Array<{id: string, name: string, sku: string, price: number, billingCycle: string, autoAdd: boolean}> GET /catalog/sim/addons // Returns SIM add-ons (Item_Class__c = 'Add-on') Array<{id: string, name: string, sku: string, price: number, billingCycle: string}> ``` ## Frontend Implementation Patterns ### 1. Data Loading Pattern ```typescript const ConfigurePage = () => { const [serviceProducts, setServiceProducts] = useState([]); const [activationFees, setActivationFees] = useState([]); const [addons, setAddons] = useState([]); useEffect(() => { const loadCatalogData = async () => { try { // Load all required data in parallel const [catalog, activation, addonsData] = await Promise.all([ authenticatedApi.get("/catalog"), authenticatedApi.get("/catalog/sim/activation-fees"), authenticatedApi.get("/catalog/sim/addons"), ]); setServiceProducts(catalog.sim); setActivationFees(activation); setAddons(addonsData); } catch (error) { console.error("Failed to load catalog data:", error); // Handle error - show fallback or redirect } }; loadCatalogData(); }, []); }; ``` ### 2. SKU Building Pattern ```typescript const buildOrderSKUs = () => { const skus = []; // Always add service SKU (detailed format) if (selectedPlan?.sku) { skus.push(selectedPlan.sku); // e.g., "SIM-DATA-VOICE-50GB" } // Add required activation fee const defaultActivation = activationFees.find(f => f.isDefault || f.autoAdd); if (defaultActivation) { skus.push(defaultActivation.sku); // e.g., "SIM-ACTIVATION-FEE" } // Add selected add-ons selectedAddons.forEach(addon => { skus.push(addon.sku); // e.g., "SIM-ADDON-VOICE-MAIL" // Add dependent products if needed if (addon.requiredProducts) { skus.push(...addon.requiredProducts); } }); return [...new Set(skus)]; // Remove duplicates }; ``` ### 3. Order Submission Pattern ```typescript const submitOrder = () => { const skus = buildOrderSKUs(); const params = new URLSearchParams({ orderType: "SIM", skus: JSON.stringify(skus), // Pass complete SKU array simType: selectedSimType, // ... other customer selections for order header }); router.push(`/checkout?${params.toString()}`); }; ``` ## SIM Configure Page - Complete Implementation ```typescript // apps/portal/src/app/catalog/sim/configure/page.tsx "use client"; import { useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { authenticatedApi } from "@/lib/api"; interface SimPlan { id: string; name: string; sku: string; // Detailed format: SIM-DATA-VOICE-50GB dataSize: string; planType: string; hasFamilyDiscount: boolean; monthlyPrice: number; } interface ActivationFee { id: string; name: string; sku: string; // SIM-ACTIVATION-FEE price: number; isDefault: boolean; autoAdd: boolean; } interface SimAddon { id: string; name: string; sku: string; // SIM-ADDON-VOICE-MAIL price: number; billingCycle: string; description?: string; } export default function SimConfigurePage() { const router = useRouter(); const params = useSearchParams(); const planSku = params.get("sku"); // Detailed SKU from catalog // State const [loading, setLoading] = useState(true); const [selectedPlan, setSelectedPlan] = useState(null); const [activationFees, setActivationFees] = useState([]); const [addons, setAddons] = useState([]); // Customer selections const [simType, setSimType] = useState<"eSIM" | "Physical SIM" | null>(null); const [selectedAddons, setSelectedAddons] = useState([]); const [eid, setEid] = useState(""); // Load catalog data useEffect(() => { let mounted = true; const loadCatalogData = async () => { if (!planSku) { router.push('/catalog/sim'); return; } try { // Load all required data in parallel const [catalog, activation, addonsData] = await Promise.all([ authenticatedApi.get<{sim: SimPlan[]}>("/catalog"), authenticatedApi.get("/catalog/sim/activation-fees"), authenticatedApi.get("/catalog/sim/addons") ]); if (mounted) { const plan = catalog.sim.find(p => p.sku === planSku); if (plan) { setSelectedPlan(plan); setActivationFees(activation); setAddons(addonsData); } else { router.push('/catalog/sim'); } } } catch (error) { console.error("Failed to load catalog data:", error); if (mounted) router.push('/catalog/sim'); } finally { if (mounted) setLoading(false); } }; loadCatalogData(); return () => { mounted = false; }; }, [planSku, router]); // Build complete SKU list based on selections const buildOrderSKUs = (): string[] => { const skus: string[] = []; // Always add service SKU (detailed format) if (selectedPlan?.sku) { skus.push(selectedPlan.sku); // e.g., "SIM-DATA-VOICE-50GB" } // Add required activation fee (auto-add or default) const requiredActivation = activationFees.find(f => f.autoAdd || f.isDefault); if (requiredActivation) { skus.push(requiredActivation.sku); // "SIM-ACTIVATION-FEE" } // Add selected add-ons selectedAddons.forEach(addonSku => { skus.push(addonSku); // e.g., "SIM-ADDON-VOICE-MAIL" }); // Remove duplicates return [...new Set(skus)]; }; // Calculate pricing const calculatePricing = () => { let monthlyTotal = 0; let oneTimeTotal = 0; // Service plan (monthly) if (selectedPlan) { monthlyTotal += selectedPlan.monthlyPrice || 0; } // Activation fee (one-time) const requiredActivation = activationFees.find(f => f.autoAdd || f.isDefault); if (requiredActivation) { oneTimeTotal += requiredActivation.price; } // Add-ons (monthly) selectedAddons.forEach(addonSku => { const addon = addons.find(a => a.sku === addonSku); if (addon && addon.billingCycle === 'Monthly') { monthlyTotal += addon.price; } }); return { monthlyTotal, oneTimeTotal }; }; // Submit order const handleSubmit = () => { if (!selectedPlan || !simType) return; const skus = buildOrderSKUs(); const { monthlyTotal, oneTimeTotal } = calculatePricing(); const params = new URLSearchParams({ orderType: "SIM", // Always "SIM", not "eSIM" skus: JSON.stringify(skus), // Complete SKU array // Customer selections (for order header) planName: selectedPlan.name, simType: simType, ...(eid && { eid }), // Pricing (for display) monthlyPrice: monthlyTotal.toString(), oneTimePrice: oneTimeTotal.toString(), }); router.push(`/checkout?${params.toString()}`); }; if (loading) { return
Loading...
; } if (!selectedPlan) { return
Plan not found
; } return (

Configure {selectedPlan.name}

{/* SIM Type Selection */}

SIM Type

{/* EID input for eSIM */} {simType === "eSIM" && (
setEid(e.target.value)} className="w-full border rounded-lg px-3 py-2" placeholder="Enter your device EID" />
)}
{/* Add-ons Selection */}

Add-ons (Optional)

{addons.map(addon => ( ))}
{/* Pricing Summary */}

Order Summary

{/* Service */}
{selectedPlan.name} ¥{(selectedPlan.monthlyPrice || 0).toLocaleString()}/month
{/* Activation Fee */} {activationFees.find(f => f.autoAdd || f.isDefault) && (
Activation Fee ¥{activationFees.find(f => f.autoAdd || f.isDefault)!.price.toLocaleString()} (one-time)
)} {/* Add-ons */} {selectedAddons.map(addonSku => { const addon = addons.find(a => a.sku === addonSku); return addon ? (
{addon.name} ¥{addon.price.toLocaleString()}/{addon.billingCycle.toLowerCase()}
) : null; })} {/* Totals */}
Monthly Total ¥{calculatePricing().monthlyTotal.toLocaleString()}
One-time Total ¥{calculatePricing().oneTimeTotal.toLocaleString()}
{/* Submit Button */}
); } ``` ## Backend Implementation ### Enhanced Catalog Controller ```typescript @Controller("catalog") export class CatalogController { @Get("sim/activation-fees") @ApiOperation({ summary: "Get SIM activation fee options" }) async getSimActivationFees() { return this.catalogService.getSimActivationFees(); } @Get("vpn/activation-fees") @ApiOperation({ summary: "Get VPN activation fee options" }) async getVpnActivationFees(@Query("region") region?: string) { return this.catalogService.getVpnActivationFees(region); } @Get("internet/installations") @ApiOperation({ summary: "Get Internet installation options" }) async getInternetInstallations() { return this.catalogService.getInternetInstallations(); } @Get("internet/addons") @ApiOperation({ summary: "Get Internet add-on options" }) async getInternetAddons() { return this.catalogService.getInternetAddons(); } @Get("sim/addons") @ApiOperation({ summary: "Get SIM add-on options" }) async getSimAddons() { return this.catalogService.getSimAddons(); } } ``` ### Simplified Order Processing ```typescript private async createOrderItems(orderId: string, body: CreateOrderBody): Promise { // Parse SKUs from selections (detailed format) let skus: string[] = []; if (body.selections.skus && typeof body.selections.skus === 'string') { try { skus = JSON.parse(body.selections.skus); } catch (e) { this.logger.warn("Failed to parse SKUs JSON, falling back to individual fields"); } } // Fallback to individual SKU fields for backward compatibility if (skus.length === 0) { if (body.selections.skuService) skus.push(body.selections.skuService as string); if (body.selections.skuActivation) skus.push(body.selections.skuActivation as string); if (body.selections.skuInstall) skus.push(body.selections.skuInstall as string); if (body.selections.skuAddons) { const addons = Array.isArray(body.selections.skuAddons) ? body.selections.skuAddons : [body.selections.skuAddons]; skus.push(...addons.map(String)); } } // Remove duplicates and empty values skus = [...new Set(skus.filter(Boolean))]; if (skus.length === 0) { throw new BadRequestException("No products specified for order"); } // Create OrderItems for each detailed SKU const pricebookId = await this.findPortalPricebookId(); for (const sku of skus) { const meta = await this.getProductMetaBySku(pricebookId, sku); if (!meta?.pbeId) { this.logger.error({ sku }, "PricebookEntry not found for SKU"); throw new NotFoundException(`Product not found: ${sku}`); } await this.sf.sobject("OrderItem").create({ OrderId: orderId, PricebookEntryId: meta.pbeId, Quantity: 1, UnitPrice: null, // Use pricebook price }); this.logger.log({ orderId, sku, pbeId: meta.pbeId }, "OrderItem created"); } } ``` ## Edge Case Handling ### 1. Multiple Activation Fees Found **Solution**: Add `Is_Default__c` field to Product2 ```sql -- Product2 fields for disambiguation Is_Default__c Checkbox -- Default choice when multiple options exist Display_Order__c Number -- Ordering for UI selection Auto_Add__c Checkbox -- Automatically add to orders (required fees) ``` **Implementation**: ```typescript // Catalog service - return default activation fee const activationFees = records .filter(r => r.Item_Class__c === "Activation") .map(r => ({ id: r.Id, sku: r.StockKeepingUnit, // Correct field name name: r.Name, price: r.PricebookEntries?.records?.[0]?.UnitPrice || 0, isDefault: r.Is_Default__c === true, autoAdd: r.Auto_Add__c === true, })) .sort((a, b) => (b.isDefault ? 1 : 0) - (a.isDefault ? 1 : 0)); ``` ### 2. Missing Required Products **Solution**: Validation in catalog service with fallback ```typescript async getSimActivationFees(): Promise { const fees = await this.queryActivationFees('SIM'); if (fees.length === 0) { this.logger.warn("No SIM activation fees found in catalog"); return []; } if (!fees.some(f => f.isDefault)) { this.logger.warn("No default SIM activation fee, marking first as default"); fees[0].isDefault = true; } return fees; } ``` ### 3. Product Relationships **Solution**: Add relationship fields to Product2 ```sql -- Product2 relationship fields Required_Products__c Text(1000) -- JSON array of required SKUs Dependent_Products__c Text(1000) -- JSON array of auto-added SKUs Mutually_Exclusive__c Text(1000) -- JSON array of conflicting SKUs ``` **Example Usage**: ```sql -- Hikari Denwa service Required_Products__c = '["INTERNET-ADDON-HIKARI-DENWA-INSTALL"]' -- Weekend installation Mutually_Exclusive__c = '["INTERNET-INSTALL-WEEKDAY"]' ``` ### 4. Regional/Conditional Products **Solution**: Use personalization logic ```typescript GET /catalog/personalized // Already filters by account eligibility // Extend to include conditional activation fees GET /catalog/vpn/activation-fees?region=USA-SF // Filter activation fees by region if needed ``` ## Key Changes Summary ### 1. Field Name Corrections ```typescript // OLD: Inconsistent field names (Portal_Catalog__c, StockKeepingUnit, Product2Categories1__c); // NEW: Correct Salesforce field names (Portal_Catalog__c, Portal_Accessible__c, StockKeepingUnit, Product2Categories1__c); ``` ### 2. SKU Strategy Change ```typescript // OLD: Simple SKUs ("SIM-DATA-VOICE-50GB", "INTERNET-SILVER"); // NEW: Detailed SKUs with all attributes ("SIM-DATA-VOICE-50GB-FAMILY", "INTERNET-SILVER-APT-1G"); ``` ### 3. Order Type Normalization ```typescript // OLD: Separate eSIM and SIM types orderType: simType === "eSIM" ? "eSIM" : "SIM" // NEW: Always use "SIM", differentiate with simType field orderType: "SIM", simType: selectedSimType, // "eSIM" or "Physical SIM" ``` ### 4. Complete SKU Control ```typescript // OLD: Incomplete product coverage const params = new URLSearchParams({ orderType: "SIM", skuService: plan.sku, // Missing activation fee! }); // NEW: Complete SKU coverage const skus = [ selectedPlan.sku, // Service: "SIM-DATA-VOICE-50GB" activationFees.find(f => f.isDefault).sku, // Activation: "SIM-ACTIVATION-FEE" ...selectedAddons, // Add-ons: ["SIM-ADDON-VOICE-MAIL"] ]; const params = new URLSearchParams({ orderType: "SIM", skus: JSON.stringify(skus), simType: selectedSimType, }); ``` ## Migration Strategy ### Phase 1: ✅ Backend Infrastructure (Completed) - ✅ New catalog endpoints - ✅ Enhanced order processing with backward compatibility - ✅ Comprehensive validation and error handling ### Phase 2: 🔄 Salesforce Configuration (Next) ```sql -- Add edge case handling fields to Product2 ALTER TABLE Product2 ADD COLUMN Is_Default__c BOOLEAN DEFAULT false; ALTER TABLE Product2 ADD COLUMN Display_Order__c NUMBER(18,0); ALTER TABLE Product2 ADD COLUMN Auto_Add__c BOOLEAN DEFAULT false; -- Mark existing activation fees as default UPDATE Product2 SET Is_Default__c = true, Auto_Add__c = true WHERE Item_Class__c = 'Activation' AND StockKeepingUnit IN ('SIM-ACTIVATION-FEE', 'VPN-ACTIVATION-FEE'); ``` ### Phase 3: 🔄 Frontend Updates (Next) - Update SIM configure page to use new endpoints - Update VPN page to load activation fees - Update Internet page to use installation/addon endpoints - Test with both old and new SKU formats ### Phase 4: 🔄 Cleanup (Final) - Remove legacy individual SKU field support - Remove order-type-specific validation - Clean up deprecated frontend code ## Benefits Achieved ### 1. **Eliminated Guessing Logic** - ❌ No more hint-based product resolution - ✅ Explicit SKU arrays from frontend - ✅ Clear validation and error messages ### 2. **Fixed Missing Activation Fees** - ❌ SIM orders were missing activation fees - ✅ All order types now include required fees - ✅ Configurable via Salesforce Product2 fields ### 3. **Improved Maintainability** - ❌ Complex order-type-specific logic - ✅ Generic SKU processing - ✅ Easy to add new product types ### 4. **Enhanced Flexibility** - ❌ Hard-coded product relationships - ✅ Configurable product dependencies - ✅ Regional and conditional products ### 5. **Better Error Handling** - ❌ Cryptic "product not found" errors - ✅ Specific SKU validation messages - ✅ Business rule validation This architecture provides **complete SKU control**, **predictable behavior**, and **easy debugging** while maintaining **backward compatibility** during the migration period.