- Updated address retrieval in user service to replace billing info with a dedicated address method. - Adjusted API endpoints to use `PATCH /api/me/address` for address updates instead of billing updates. - Enhanced documentation to reflect changes in address management processes and API usage. - Removed deprecated types and services related to billing address handling, streamlining the codebase.
857 lines
25 KiB
Markdown
857 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.
|