Assist_Design/docs/PRODUCT-CATALOG-ARCHITECTURE.md

858 lines
25 KiB
Markdown

# 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=<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<SimPlan | null>(null);
const [activationFees, setActivationFees] = useState<ActivationFee[]>([]);
const [addons, setAddons] = useState<SimAddon[]>([]);
// Customer selections
const [simType, setSimType] = useState<"eSIM" | "Physical SIM" | null>(null);
const [selectedAddons, setSelectedAddons] = useState<string[]>([]);
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<ActivationFee[]>("/catalog/sim/activation-fees"),
authenticatedApi.get<SimAddon[]>("/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 <div>Loading...</div>;
}
if (!selectedPlan) {
return <div>Plan not found</div>;
}
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">Configure {selectedPlan.name}</h1>
{/* SIM Type Selection */}
<section className="mb-8">
<h2 className="text-xl font-semibold mb-4">SIM Type</h2>
<div className="grid grid-cols-2 gap-4">
<label className="border rounded-lg p-4 cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="simType"
value="Physical SIM"
checked={simType === "Physical SIM"}
onChange={(e) => setSimType(e.target.value as "Physical SIM")}
className="mr-3"
/>
<div>
<div className="font-medium">Physical SIM</div>
<div className="text-sm text-gray-600">Traditional SIM card</div>
</div>
</label>
<label className="border rounded-lg p-4 cursor-pointer hover:bg-gray-50">
<input
type="radio"
name="simType"
value="eSIM"
checked={simType === "eSIM"}
onChange={(e) => setSimType(e.target.value as "eSIM")}
className="mr-3"
/>
<div>
<div className="font-medium">eSIM</div>
<div className="text-sm text-gray-600">Digital SIM</div>
</div>
</label>
</div>
{/* EID input for eSIM */}
{simType === "eSIM" && (
<div className="mt-4">
<label className="block text-sm font-medium mb-2">
EID (Required for eSIM)
</label>
<input
type="text"
value={eid}
onChange={(e) => setEid(e.target.value)}
className="w-full border rounded-lg px-3 py-2"
placeholder="Enter your device EID"
/>
</div>
)}
</section>
{/* Add-ons Selection */}
<section className="mb-8">
<h2 className="text-xl font-semibold mb-4">Add-ons (Optional)</h2>
<div className="space-y-3">
{addons.map(addon => (
<label key={addon.id} className="flex items-center border rounded-lg p-4 cursor-pointer hover:bg-gray-50">
<input
type="checkbox"
checked={selectedAddons.includes(addon.sku)}
onChange={(e) => {
if (e.target.checked) {
setSelectedAddons([...selectedAddons, addon.sku]);
} else {
setSelectedAddons(selectedAddons.filter(sku => sku !== addon.sku));
}
}}
className="mr-3"
/>
<div className="flex-1">
<div className="font-medium">{addon.name}</div>
{addon.description && (
<div className="text-sm text-gray-600">{addon.description}</div>
)}
</div>
<div className="text-right">
<div className="font-medium">¥{addon.price.toLocaleString()}</div>
<div className="text-sm text-gray-600">{addon.billingCycle}</div>
</div>
</label>
))}
</div>
</section>
{/* Pricing Summary */}
<section className="mb-8">
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold mb-3">Order Summary</h3>
{/* Service */}
<div className="flex justify-between mb-2">
<span>{selectedPlan.name}</span>
<span>¥{(selectedPlan.monthlyPrice || 0).toLocaleString()}/month</span>
</div>
{/* Activation Fee */}
{activationFees.find(f => f.autoAdd || f.isDefault) && (
<div className="flex justify-between mb-2">
<span>Activation Fee</span>
<span>¥{activationFees.find(f => f.autoAdd || f.isDefault)!.price.toLocaleString()} (one-time)</span>
</div>
)}
{/* Add-ons */}
{selectedAddons.map(addonSku => {
const addon = addons.find(a => a.sku === addonSku);
return addon ? (
<div key={addonSku} className="flex justify-between mb-2">
<span>{addon.name}</span>
<span>¥{addon.price.toLocaleString()}/{addon.billingCycle.toLowerCase()}</span>
</div>
) : null;
})}
{/* Totals */}
<hr className="my-3" />
<div className="flex justify-between font-semibold">
<span>Monthly Total</span>
<span>¥{calculatePricing().monthlyTotal.toLocaleString()}</span>
</div>
<div className="flex justify-between font-semibold">
<span>One-time Total</span>
<span>¥{calculatePricing().oneTimeTotal.toLocaleString()}</span>
</div>
</div>
</section>
{/* Submit Button */}
<button
onClick={handleSubmit}
disabled={!selectedPlan || !simType || (simType === "eSIM" && !eid)}
className="w-full bg-blue-600 text-white font-bold py-3 px-6 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Continue to Checkout
</button>
</div>
);
}
```
## 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<void> {
// 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<ActivationFee[]> {
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.