# Product Catalog & SKU Architecture - Complete Guide 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.