Assist_Design/docs/architecture/PUBLIC-CATALOG-UNIFIED-CHECKOUT.md
barsa 7cfac4c32f Enhance Checkout Process with Internet Availability Step
- Added an availability confirmation step in the checkout process for internet orders, ensuring users verify service eligibility before proceeding to payment.
- Updated the CheckoutWizard component to conditionally include the new AvailabilityStep based on the order type.
- Refactored AddressStep to navigate to the AvailabilityStep for internet orders, improving user flow.
- Enhanced the checkout store to track internet availability requests and updated the state management for better handling of checkout steps.
- Updated relevant schemas and documentation to reflect the new checkout flow and requirements.
2025-12-17 18:47:59 +09:00

1431 lines
53 KiB
Markdown

# Public Catalog & Unified Checkout - Development Plan
> **Status**: Planning
> **Created**: 2024-12-17
> **Epic**: Transform Portal into Public-Facing Website with E-commerce Checkout
## Executive Summary
This document outlines the development plan to transform the customer portal from an authenticated-only application into a public-facing website where users can:
1. **Browse catalog without authentication**
2. **Configure products without an account**
3. **Complete checkout with seamless account creation**
4. **Place orders in a single, unified flow**
The goal is to eliminate friction in the customer acquisition funnel by making registration feel like a natural part of the ordering process, rather than a prerequisite.
### Implementation Notes (Current Codebase)
- Public catalog routes are implemented under `/shop` (not `/catalog`).
- Unified checkout is implemented under `/order` (not `/checkout`).
- Internet orders require an availability/eligibility confirmation step (between Address and Payment).
---
## Table of Contents
1. [Current State Analysis](#current-state-analysis)
2. [Target Architecture](#target-architecture)
3. [User Journeys](#user-journeys)
4. [Technical Design](#technical-design)
5. [Development Phases](#development-phases)
6. [API Changes](#api-changes)
7. [Database Changes](#database-changes)
8. [Testing Strategy](#testing-strategy)
9. [Rollout Plan](#rollout-plan)
10. [Risks & Mitigations](#risks--mitigations)
11. [Success Metrics](#success-metrics)
---
## Current State Analysis
### Current Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ CURRENT FLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. User visits portal │
│ 2. Must sign up (requires sfNumber from Salesforce) │
│ 3. Must add payment method (WHMCS SSO) │
│ 4. Can browse catalog │
│ 5. Can configure and order │
│ │
│ Problems: │
│ • sfNumber requirement blocks new customers │
│ • Registration is separate from ordering intent │
│ • High friction = low conversion │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Critical Integration Requirements
**Salesforce is the source of truth** for customer data and must be created for every new customer:
| System | Role | Requirement |
| -------------- | ------------------------------ | ------------------------------------------------------- |
| **Salesforce** | CRM, customer tracking, orders | Account + Contact MUST be created for every customer |
| **WHMCS** | Billing, invoices, payments | Client MUST be linked to SF Account via `WH_Account__c` |
| **Portal DB** | Authentication, session | User record links all systems via `id_mappings` |
The relationship chain:
```
Salesforce Account (has SF_Account_No__c)
└── WH_Account__c → WHMCS Client ID
└── Portal User → id_mappings → whmcsClientId + sfAccountId
```
### Current Route Structure
```
apps/portal/src/app/
├── (public)/ # Only auth pages
│ ├── auth/
│ │ ├── login/
│ │ ├── signup/ # Requires sfNumber
│ │ └── ...
│ └── page.tsx # Landing page
└── (authenticated)/ # Everything else requires auth
├── catalog/ # ← Should be public!
├── checkout/
├── dashboard/
├── billing/
├── subscriptions/
├── orders/
└── support/
```
### Current Dependencies
| Component | Current Behavior | Issue |
| --------------- | ------------------------------------------ | ---------------------------- |
| Catalog API | Works without auth (returns generic plans) | ✅ Ready |
| Catalog UI | Requires authentication | ❌ Needs change |
| Checkout | Requires auth + payment method | ❌ Needs redesign |
| Signup | Requires sfNumber | ❌ Needs to be optional |
| Payment Methods | WHMCS SSO only | ⚠️ Constraint to work around |
---
## Target Architecture
### New Route Structure
```
apps/portal/src/app/
├── (public)/
│ ├── page.tsx # Homepage/Landing
│ ├── layout.tsx # PublicShell
│ │
│ ├── catalog/ # ★ PUBLIC CATALOG
│ │ ├── page.tsx # Catalog home
│ │ ├── layout.tsx # CatalogLayout
│ │ ├── internet/
│ │ │ ├── page.tsx # Internet plans
│ │ │ └── configure/page.tsx # Configure internet
│ │ ├── sim/
│ │ │ ├── page.tsx # SIM plans
│ │ │ └── configure/page.tsx # Configure SIM
│ │ └── vpn/
│ │ └── page.tsx # VPN plans
│ │
│ ├── checkout/ # ★ UNIFIED CHECKOUT
│ │ ├── page.tsx # Multi-step checkout
│ │ └── complete/page.tsx # Order confirmation
│ │
│ ├── support/ # ★ PUBLIC SUPPORT
│ │ ├── page.tsx # FAQ/Help center
│ │ └── contact/page.tsx # Contact form
│ │
│ └── auth/
│ ├── login/page.tsx # Simplified login
│ └── forgot-password/page.tsx
├── (authenticated)/
│ ├── layout.tsx # AppShell
│ ├── dashboard/ # Customer dashboard
│ ├── orders/ # Order history
│ ├── subscriptions/ # Manage subscriptions
│ ├── billing/ # Invoices & payments
│ └── support/
│ └── cases/ # Support case management
```
### Unified Checkout Flow
```
┌─────────────────────────────────────────────────────────────────────┐
│ NEW FLOW │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ PUBLIC CATALOG │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Browse Plans → Configure → "Proceed to Checkout" │ │
│ │ (saves to localStorage) │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ UNIFIED CHECKOUT (/order) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Step 1: Account │ │
│ │ • "Already have account? Sign in" OR │ │
│ │ • Collect: email, name, phone, password │ │
│ ├────────────────────────────────────────────────────────────┤ │
│ │ Step 2: Address │ │
│ │ • Collect service/shipping address │ │
│ │ (Account created in background after this step) │ │
│ ├────────────────────────────────────────────────────────────┤ │
│ │ Step 2.5: Availability (Internet only) │ │
│ │ • Request/confirm serviceability before payment │ │
│ ├────────────────────────────────────────────────────────────┤ │
│ │ Step 3: Payment │ │
│ │ • Open WHMCS to add payment method │ │
│ │ • Poll for completion, show confirmation │ │
│ ├────────────────────────────────────────────────────────────┤ │
│ │ Step 4: Review & Submit │ │
│ │ • Order summary, T&C acceptance, submit │ │
│ └────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ORDER CONFIRMATION → Redirect to /orders/[id] │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## User Journeys
### Journey 1: New Customer - Internet Order
```mermaid
journey
title New Customer Orders Internet
section Browse
Visit homepage: 5: Customer
View internet plans: 5: Customer
Select 10Gbps plan: 5: Customer
Configure addons: 4: Customer
section Checkout
Enter email & name: 4: Customer
Enter address: 4: Customer
Add payment (WHMCS): 3: Customer
Review order: 5: Customer
Submit order: 5: Customer
section Post-Order
View confirmation: 5: Customer
Access dashboard: 5: Customer
```
### Journey 2: Existing Customer - Quick Order
```mermaid
journey
title Existing Customer Quick Order
section Browse
Visit catalog: 5: Customer
Select SIM plan: 5: Customer
Configure: 5: Customer
section Checkout
Click "Sign In": 5: Customer
Login: 4: Customer
Verify address: 5: Customer
Confirm payment: 5: Customer
Submit: 5: Customer
```
### Journey 3: Abandoned Cart Recovery
```mermaid
journey
title Cart Recovery
section Initial
Configure product: 5: Customer
Start checkout: 4: Customer
Enter email: 4: Customer
Leave site: 1: Customer
section Recovery
Receive email: 3: Customer
Click link: 4: Customer
Resume checkout: 5: Customer
Complete order: 5: Customer
```
---
## Technical Design
### 1. Checkout State Store
```typescript
// Location: apps/portal/src/features/checkout/stores/checkout.store.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface CartItem {
orderType: "INTERNET" | "SIM" | "VPN";
planSku: string;
planName: string;
addonSkus: string[];
configuration: {
installationType?: string;
simType?: string;
activationType?: string;
mnpDetails?: MnpDetails;
[key: string]: unknown;
};
pricing: {
monthlyTotal: number;
oneTimeTotal: number;
breakdown: PriceBreakdownItem[];
};
}
interface GuestInfo {
email: string;
firstName: string;
lastName: string;
phone: string;
phoneCountryCode: string;
dateOfBirth?: string;
gender?: "male" | "female" | "other";
password: string;
}
interface CheckoutState {
// Cart data
cartItem: CartItem | null;
// Guest info (pre-registration)
guestInfo: Partial<GuestInfo> | null;
// Address
address: Address | null;
// Registration state
registrationComplete: boolean;
userId: string | null;
// Payment state
paymentMethodVerified: boolean;
// Checkout step
currentStep: "account" | "address" | "payment" | "review";
// Actions
setCartItem: (item: CartItem) => void;
updateGuestInfo: (info: Partial<GuestInfo>) => void;
setAddress: (address: Address) => void;
setRegistrationComplete: (userId: string) => void;
setPaymentVerified: (verified: boolean) => void;
setCurrentStep: (step: CheckoutState["currentStep"]) => void;
clear: () => void;
}
export const useCheckoutStore = create<CheckoutState>()(
persist(
(set, get) => ({
cartItem: null,
guestInfo: null,
address: null,
registrationComplete: false,
userId: null,
paymentMethodVerified: false,
currentStep: "account",
setCartItem: item => set({ cartItem: item }),
updateGuestInfo: info =>
set(state => ({
guestInfo: { ...state.guestInfo, ...info },
})),
setAddress: address => set({ address }),
setRegistrationComplete: userId =>
set({
registrationComplete: true,
userId,
}),
setPaymentVerified: verified => set({ paymentMethodVerified: verified }),
setCurrentStep: step => set({ currentStep: step }),
clear: () =>
set({
cartItem: null,
guestInfo: null,
address: null,
registrationComplete: false,
userId: null,
paymentMethodVerified: false,
currentStep: "account",
}),
}),
{
name: "checkout-store",
version: 1,
}
)
);
```
### 2. Checkout Page Component Structure
```typescript
// Location: apps/portal/src/app/(public)/checkout/page.tsx
export default function CheckoutPage() {
return (
<CheckoutLayout>
<CheckoutWizard />
</CheckoutLayout>
);
}
// Location: apps/portal/src/features/checkout/components/CheckoutWizard.tsx
export function CheckoutWizard() {
const { currentStep, cartItem } = useCheckoutStore();
const { isAuthenticated } = useAuthSession();
// Redirect if no cart
if (!cartItem) {
return <EmptyCartRedirect />;
}
// Skip account step if already authenticated
const effectiveStep = isAuthenticated && currentStep === 'account'
? 'address'
: currentStep;
return (
<div className="max-w-3xl mx-auto">
<CheckoutProgress currentStep={effectiveStep} />
<div className="mt-8 grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
{effectiveStep === 'account' && <AccountStep />}
{effectiveStep === 'address' && <AddressStep />}
{effectiveStep === 'payment' && <PaymentStep />}
{effectiveStep === 'review' && <ReviewStep />}
</div>
<div className="lg:col-span-1">
<OrderSummaryCard item={cartItem} />
</div>
</div>
</div>
);
}
```
### 3. Account Step with Sign-In Option
```typescript
// Location: apps/portal/src/features/checkout/components/steps/AccountStep.tsx
export function AccountStep() {
const [mode, setMode] = useState<'new' | 'signin'>('new');
const { updateGuestInfo, setCurrentStep } = useCheckoutStore();
const handleContinue = async (data: GuestFormData) => {
updateGuestInfo(data);
setCurrentStep('address');
};
const handleSignInSuccess = () => {
// User is now authenticated, skip to address
setCurrentStep('address');
};
return (
<div className="space-y-6">
{/* Sign-in prompt */}
<div className="bg-muted/50 rounded-xl p-6 border border-border">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">Already have an account?</h3>
<p className="text-sm text-muted-foreground">
Sign in to use your saved information
</p>
</div>
<Button
variant="outline"
onClick={() => setMode('signin')}
>
Sign In
</Button>
</div>
</div>
{mode === 'signin' ? (
<SignInForm
onSuccess={handleSignInSuccess}
onCancel={() => setMode('new')}
embedded
/>
) : (
<>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-background px-4 text-muted-foreground">
Or continue as new customer
</span>
</div>
</div>
<GuestInfoForm onSubmit={handleContinue} />
</>
)}
</div>
);
}
```
### 4. Payment Step with WHMCS Integration
```typescript
// Location: apps/portal/src/features/checkout/components/steps/PaymentStep.tsx
export function PaymentStep() {
const { setPaymentVerified, setCurrentStep, registrationComplete } = useCheckoutStore();
const { data: paymentMethods, refetch } = usePaymentMethods();
const createSsoLink = useCreatePaymentMethodsSsoLink();
const [isWaiting, setIsWaiting] = useState(false);
// Poll for payment method after opening WHMCS
useEffect(() => {
if (!isWaiting) return;
const interval = setInterval(async () => {
const result = await refetch();
if (result.data?.paymentMethods?.length > 0) {
setPaymentVerified(true);
setIsWaiting(false);
}
}, 3000);
return () => clearInterval(interval);
}, [isWaiting, refetch, setPaymentVerified]);
// Focus listener for when user returns
useEffect(() => {
const handleFocus = () => {
if (isWaiting) {
void refetch();
}
};
window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus);
}, [isWaiting, refetch]);
const handleAddPayment = async () => {
const { url } = await createSsoLink.mutateAsync();
window.open(url, '_blank');
setIsWaiting(true);
};
const hasPaymentMethod = paymentMethods?.paymentMethods?.length > 0;
return (
<div className="space-y-6">
<div className="bg-card rounded-xl border border-border p-6">
<div className="flex items-center gap-3 mb-4">
<CreditCardIcon className="h-6 w-6 text-primary" />
<h2 className="text-lg font-semibold">Payment Method</h2>
</div>
{hasPaymentMethod ? (
<div className="space-y-4">
<PaymentMethodCard
method={paymentMethods.paymentMethods[0]}
/>
<div className="flex items-center gap-2 text-sm text-success">
<CheckCircleIcon className="h-5 w-5" />
Payment method verified
</div>
</div>
) : (
<div className="text-center py-8">
<CreditCardIcon className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
{isWaiting ? (
<>
<h3 className="font-semibold mb-2">Waiting for payment method...</h3>
<p className="text-sm text-muted-foreground mb-4">
Complete the payment setup in the new tab, then return here.
</p>
<Spinner className="mx-auto" />
</>
) : (
<>
<h3 className="font-semibold mb-2">Add a payment method</h3>
<p className="text-sm text-muted-foreground mb-4">
We'll open our secure payment portal to add your card.
</p>
<Button onClick={handleAddPayment}>
Add Payment Method
</Button>
</>
)}
</div>
)}
</div>
<div className="flex justify-between">
<Button
variant="ghost"
onClick={() => setCurrentStep('address')}
>
← Back
</Button>
<Button
onClick={() => setCurrentStep('review')}
disabled={!hasPaymentMethod}
>
Continue to Review
</Button>
</div>
</div>
);
}
```
### 5. New BFF Endpoints
```typescript
// Location: apps/bff/src/modules/checkout/checkout.controller.ts
@Controller("checkout")
export class CheckoutController {
constructor(
private readonly checkoutService: CheckoutService,
private readonly logger: Logger
) {}
/**
* Register a new user during checkout
*
* IMPORTANT: This creates accounts in ALL systems synchronously:
* 1. Salesforce Account + Contact (for CRM tracking)
* 2. WHMCS Client (for billing)
* 3. Portal User (for authentication)
*
* Returns auth tokens so user is immediately logged in
*/
@Post("register")
@Public()
@RateLimit({ limit: 5, ttl: 60 })
@UsePipes(new ZodValidationPipe(checkoutRegisterSchema))
async register(@Body() body: CheckoutRegisterRequest): Promise<CheckoutRegisterResponse> {
this.logger.log("Checkout registration request", { email: body.email });
return this.checkoutService.registerForCheckout(body);
}
/**
* Check if current user has valid payment method
* Used by checkout to gate the review step
*/
@Get("payment-status")
async getPaymentStatus(@Request() req: RequestWithUser): Promise<PaymentStatusResponse> {
return this.checkoutService.getPaymentStatus(req.user.id);
}
/**
* Build cart preview from configuration
* Works for both authenticated and anonymous users
*/
@Post("cart")
@Public()
@UsePipes(new ZodValidationPipe(checkoutBuildCartRequestSchema))
async buildCart(
@Request() req: RequestWithUser,
@Body() body: CheckoutBuildCartRequest
): Promise<CheckoutCart> {
const userId = req.user?.id;
return this.checkoutService.buildCart(body, userId);
}
/**
* Submit order - requires authentication
*/
@Post("submit")
@UsePipes(new ZodValidationPipe(checkoutSubmitSchema))
async submitOrder(
@Request() req: RequestWithUser,
@Body() body: CheckoutSubmitRequest
): Promise<OrderCreateResponse> {
return this.checkoutService.submitOrder(req.user.id, body);
}
}
```
### 6. Salesforce Account Creation Service
**CRITICAL**: Every new customer MUST have a Salesforce Account for business tracking.
```typescript
// Location: apps/bff/src/integrations/salesforce/services/salesforce-account.service.ts
// ADD these new methods to the existing service
/**
* Create a new Salesforce Account for a new customer
* This is used when customer signs up through checkout (no existing sfNumber)
*/
async createAccount(data: CreateSalesforceAccountRequest): Promise<{
accountId: string;
accountNumber: string;
}> {
this.logger.log('Creating new Salesforce Account', { email: data.email });
// Generate unique account number (SF_Account_No__c)
const accountNumber = await this.generateAccountNumber();
const accountPayload = {
Name: `${data.firstName} ${data.lastName}`,
SF_Account_No__c: accountNumber,
BillingStreet: data.address.address1,
BillingCity: data.address.city,
BillingState: data.address.state,
BillingPostalCode: data.address.postcode,
BillingCountry: data.address.country,
Phone: data.phone,
// Portal tracking fields
[this.portalStatusField]: 'Active',
[this.portalSourceField]: 'Portal Checkout',
// Record type for individual customers
RecordTypeId: this.configService.get('SF_PERSON_ACCOUNT_RECORD_TYPE_ID'),
};
try {
const result = await this.connection.sobject('Account').create(accountPayload);
if (!result.id) {
throw new Error('Salesforce Account creation failed - no ID returned');
}
this.logger.log('Salesforce Account created', {
accountId: result.id,
accountNumber
});
return {
accountId: result.id,
accountNumber
};
} catch (error) {
this.logger.error('Failed to create Salesforce Account', {
error: getErrorMessage(error),
email: data.email,
});
throw new Error('Failed to create customer account in CRM');
}
}
/**
* Create a Contact associated with an Account
*/
async createContact(data: CreateSalesforceContactRequest): Promise<{ contactId: string }> {
this.logger.log('Creating Salesforce Contact', {
accountId: data.accountId,
email: data.email
});
const contactPayload = {
AccountId: data.accountId,
FirstName: data.firstName,
LastName: data.lastName,
Email: data.email,
Phone: data.phone,
MailingStreet: data.address.address1,
MailingCity: data.address.city,
MailingState: data.address.state,
MailingPostalCode: data.address.postcode,
MailingCountry: data.address.country,
};
try {
const result = await this.connection.sobject('Contact').create(contactPayload);
if (!result.id) {
throw new Error('Salesforce Contact creation failed - no ID returned');
}
this.logger.log('Salesforce Contact created', { contactId: result.id });
return { contactId: result.id };
} catch (error) {
this.logger.error('Failed to create Salesforce Contact', {
error: getErrorMessage(error),
accountId: data.accountId,
});
throw new Error('Failed to create customer contact in CRM');
}
}
/**
* Generate a unique customer number for new accounts
* Format: PNNNNNNN (P = Portal, 7 digits)
*/
private async generateAccountNumber(): Promise<string> {
// Query for max existing portal account number
const result = await this.connection.query(
`SELECT SF_Account_No__c FROM Account
WHERE SF_Account_No__c LIKE 'P%'
ORDER BY SF_Account_No__c DESC LIMIT 1`,
{ label: 'auth:getMaxAccountNumber' }
);
let nextNumber = 1000001; // Start from P1000001
if (result.totalSize > 0) {
const lastNumber = result.records[0]?.SF_Account_No__c;
if (lastNumber) {
const numPart = parseInt(lastNumber.substring(1), 10);
if (!isNaN(numPart)) {
nextNumber = numPart + 1;
}
}
}
return `P${nextNumber}`;
}
```
### 7. Checkout Registration Service - Full Flow
```typescript
// Location: apps/bff/src/modules/checkout/checkout.service.ts
@Injectable()
export class CheckoutService {
constructor(
private readonly salesforceAccountService: SalesforceAccountService,
private readonly whmcsService: WhmcsService,
private readonly usersFacade: UsersFacade,
private readonly mappingsService: MappingsService,
private readonly tokenService: AuthTokenService,
private readonly prisma: PrismaService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Register a new customer during checkout
*
* This is a critical workflow that creates accounts in ALL systems:
*
* 1. Create Salesforce Account (generates SF_Account_No__c)
* 2. Create Salesforce Contact (linked to Account)
* 3. Create WHMCS Client (for billing)
* 4. Update SF Account with WH_Account__c
* 5. Create Portal User
* 6. Create ID Mapping (links all systems)
* 7. Generate auth tokens
*
* If any step fails, we attempt rollback of previous steps.
*/
async registerForCheckout(data: CheckoutRegisterRequest): Promise<CheckoutRegisterResponse> {
this.logger.log("Starting checkout registration", { email: data.email });
// Track created resources for rollback
let sfAccountId: string | null = null;
let sfContactId: string | null = null;
let sfAccountNumber: string | null = null;
let whmcsClientId: number | null = null;
let portalUserId: string | null = null;
try {
// Step 1: Create Salesforce Account
this.logger.log("Step 1: Creating Salesforce Account");
const sfAccount = await this.salesforceAccountService.createAccount({
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
phone: this.formatPhone(data.phoneCountryCode, data.phone),
address: data.address,
});
sfAccountId = sfAccount.accountId;
sfAccountNumber = sfAccount.accountNumber;
// Step 2: Create Salesforce Contact
this.logger.log("Step 2: Creating Salesforce Contact");
const sfContact = await this.salesforceAccountService.createContact({
accountId: sfAccountId,
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
phone: this.formatPhone(data.phoneCountryCode, data.phone),
address: data.address,
});
sfContactId = sfContact.contactId;
// Step 3: Create WHMCS Client
this.logger.log("Step 3: Creating WHMCS Client");
const whmcsClient = await this.whmcsService.addClient({
firstname: data.firstName,
lastname: data.lastName,
email: data.email,
phonenumber: this.formatPhone(data.phoneCountryCode, data.phone),
address1: data.address.address1,
address2: data.address.address2 || "",
city: data.address.city,
state: data.address.state,
postcode: data.address.postcode,
country: data.address.country,
password2: data.password,
customfields: this.buildWhmcsCustomFields(sfAccountNumber),
});
whmcsClientId = whmcsClient.clientId;
// Step 4: Update Salesforce Account with WHMCS ID
this.logger.log("Step 4: Linking Salesforce to WHMCS");
await this.salesforceAccountService.updatePortalFields(sfAccountId, {
whmcsAccountId: whmcsClientId,
status: "Active",
source: "Portal Checkout",
});
// Step 5: Create Portal User (in transaction)
this.logger.log("Step 5: Creating Portal User");
const user = await this.prisma.$transaction(async tx => {
const passwordHash = await argon2.hash(data.password);
const newUser = await tx.user.create({
data: {
email: data.email,
passwordHash,
isActive: true,
emailVerified: false,
},
});
// Step 6: Create ID Mapping
await tx.idMapping.create({
data: {
userId: newUser.id,
whmcsClientId: whmcsClientId!,
sfAccountId: sfAccountId!,
sfContactId: sfContactId!,
},
});
return newUser;
});
portalUserId = user.id;
// Step 7: Generate auth tokens
this.logger.log("Step 6: Generating auth tokens");
const tokens = await this.tokenService.generateTokenPair({
sub: user.id,
email: user.email,
});
this.logger.log("Checkout registration completed successfully", {
userId: user.id,
sfAccountId,
sfAccountNumber,
whmcsClientId,
});
return {
success: true,
user: {
id: user.id,
email: user.email,
firstname: data.firstName,
lastname: data.lastName,
},
session: {
expiresAt: tokens.accessTokenExpiresAt,
refreshExpiresAt: tokens.refreshTokenExpiresAt,
},
sfAccountNumber, // Return so it can be shown to user
};
} catch (error) {
this.logger.error("Checkout registration failed, initiating rollback", {
error: getErrorMessage(error),
sfAccountId,
whmcsClientId,
portalUserId,
});
// Rollback in reverse order
await this.rollbackRegistration({
portalUserId,
whmcsClientId,
sfAccountId,
});
throw new BadRequestException("Registration failed. Please try again or contact support.");
}
}
private async rollbackRegistration(resources: {
portalUserId: string | null;
whmcsClientId: number | null;
sfAccountId: string | null;
}) {
// Best-effort rollback - log failures but don't throw
if (resources.portalUserId) {
try {
await this.prisma.user.delete({ where: { id: resources.portalUserId } });
this.logger.log("Rollback: Deleted portal user", { userId: resources.portalUserId });
} catch (e) {
this.logger.error("Rollback failed: Portal user", { error: getErrorMessage(e) });
}
}
if (resources.whmcsClientId) {
try {
await this.whmcsService.deleteClient(resources.whmcsClientId);
this.logger.log("Rollback: Deleted WHMCS client", { clientId: resources.whmcsClientId });
} catch (e) {
this.logger.error("Rollback failed: WHMCS client", { error: getErrorMessage(e) });
}
}
// Note: We intentionally do NOT delete the Salesforce Account
// It's better to have an orphaned SF Account that can be cleaned up
// than to lose potential customer data
if (resources.sfAccountId) {
this.logger.warn("Salesforce Account not rolled back (intentional)", {
sfAccountId: resources.sfAccountId,
action: "Manual cleanup may be required",
});
}
}
private formatPhone(countryCode: string, phone: string): string {
const cc = countryCode.replace(/\D/g, "");
const num = phone.replace(/\D/g, "");
return `+${cc}.${num}`;
}
private buildWhmcsCustomFields(sfAccountNumber: string): string {
const customerNumberFieldId = this.configService.get("WHMCS_CUSTOMER_NUMBER_FIELD_ID");
if (!customerNumberFieldId) return "";
return `${customerNumberFieldId}|${sfAccountNumber}`;
}
}
```
---
## Development Phases
### Phase 0: Preparation (3 days)
| Task | Owner | Effort | Dependencies |
| ---------------------------------------- | ----- | ------ | ------------ |
| Create feature branch | Dev | 0.5d | - |
| Document current catalog/checkout flows | Dev | 0.5d | - |
| Set up feature flags for gradual rollout | Dev | 1d | - |
| Create test accounts for each scenario | QA | 1d | - |
### Phase 1: Public Catalog (5 days)
| Task | Description | Effort | Priority |
| ---------------------------------------------------------- | ------------------------------------ | ------ | -------- |
| **1.1** Create `CatalogLayout` component | Hybrid header for public/auth states | 1d | P0 |
| **1.2** Move catalog routes to `(public)/catalog/` | Copy and adapt existing pages | 1d | P0 |
| **1.3** Add `@Public()` decorator to BFF catalog endpoints | Make API publicly accessible | 0.5d | P0 |
| **1.4** Update catalog service for anonymous users | Handle missing userId gracefully | 0.5d | P0 |
| **1.5** Create "Proceed to Checkout" flow | Save config to store, redirect | 1d | P0 |
| **1.6** Test all catalog pages without auth | E2E testing | 1d | P0 |
**Deliverable**: Users can browse and configure products without logging in.
### Phase 2: Checkout Store & Cart (3 days)
| Task | Description | Effort | Priority |
| ----------------------------------------- | ------------------------------------ | ------ | -------- |
| **2.1** Create checkout Zustand store | With localStorage persistence | 0.5d | P0 |
| **2.2** Implement cart item serialization | Save/restore configuration | 0.5d | P0 |
| **2.3** Add cart validation utilities | Ensure cart is valid before checkout | 0.5d | P0 |
| **2.4** Create cart recovery hook | Detect and restore abandoned carts | 0.5d | P1 |
| **2.5** Unit tests for store | Full coverage | 1d | P0 |
**Deliverable**: Cart state persists across page reloads and sessions.
### Phase 3: Unified Checkout UI (8 days)
| Task | Description | Effort | Priority |
| ------------------------------------------ | --------------------------- | ------ | -------- |
| **3.1** Create `/checkout` page shell | Layout, progress indicator | 0.5d | P0 |
| **3.2** Build `CheckoutWizard` component | Step management, navigation | 1d | P0 |
| **3.3** Build `AccountStep` component | Sign-in + new account form | 1.5d | P0 |
| **3.4** Build `AddressStep` component | Reuse existing address form | 1d | P0 |
| **3.5** Build `PaymentStep` component | WHMCS SSO + polling | 1.5d | P0 |
| **3.6** Build `ReviewStep` component | Summary + terms + submit | 1d | P0 |
| **3.7** Build `OrderSummaryCard` component | Sidebar cart display | 0.5d | P0 |
| **3.8** Build order confirmation page | Success state, next steps | 1d | P0 |
**Deliverable**: Complete checkout UI with all steps functional.
### Phase 4: Backend - Checkout Registration (7 days)
| Task | Description | Effort | Priority |
| --------------------------------------------------------- | -------------------------------------------------- | ------ | -------- |
| **4.1** Update domain schema - make sfNumber optional | Schema changes | 0.5d | P0 |
| **4.2** Add `createAccount()` to SalesforceAccountService | Create SF Account for new customers | 1d | P0 |
| **4.3** Add `createContact()` to SalesforceAccountService | Create SF Contact linked to Account | 0.5d | P0 |
| **4.4** Add `generateAccountNumber()` method | Auto-generate SF_Account_No\_\_c (PNNNNNNN format) | 0.5d | P0 |
| **4.5** Create `CheckoutService` in BFF | Main registration orchestration | 1d | P0 |
| **4.6** Implement `registerForCheckout()` method | Full 7-step registration flow | 1.5d | P0 |
| **4.7** Create `POST /checkout/register` endpoint | Public registration endpoint | 0.5d | P0 |
| **4.8** Implement rollback logic | Compensating transactions on failure | 1d | P0 |
| **4.9** Add comprehensive logging | Track each step for debugging | 0.5d | P0 |
**Deliverable**: Users can create accounts during checkout with **synchronous** Salesforce Account + Contact + WHMCS Client creation.
> **IMPORTANT**: Salesforce Account is created FIRST to ensure CRM tracking. The flow is:
>
> 1. Create SF Account (generates customer number)
> 2. Create SF Contact
> 3. Create WHMCS Client
> 4. Link SF Account to WHMCS (update WH_Account\_\_c)
> 5. Create Portal User + ID Mapping
> 6. Return auth tokens
### Phase 5: Integration & Testing (5 days)
| Task | Description | Effort | Priority |
| ------------------------------------------------------- | ------------------------- | ------ | -------- |
| **5.1** Integrate checkout UI with BFF endpoints | Wire up API calls | 1d | P0 |
| **5.2** Implement auth token handling post-registration | Auto-login after signup | 0.5d | P0 |
| **5.3** E2E tests - new customer flow | Full journey testing | 1d | P0 |
| **5.4** E2E tests - existing customer flow | Sign-in during checkout | 0.5d | P0 |
| **5.5** E2E tests - cart recovery | Abandoned cart scenarios | 0.5d | P1 |
| **5.6** Performance testing | Load testing checkout | 0.5d | P1 |
| **5.7** Security review | Auth, CSRF, rate limiting | 1d | P0 |
**Deliverable**: Fully tested, production-ready checkout flow.
### Phase 6: Public Support (3 days)
| Task | Description | Effort | Priority |
| --------------------------------------------------- | -------------------------- | ------ | -------- |
| **6.1** Create public support landing page | FAQ, contact info | 1d | P1 |
| **6.2** Create public contact form | No auth required | 1d | P1 |
| **6.3** Add `POST /support/contact` public endpoint | Creates Lead in Salesforce | 1d | P1 |
**Deliverable**: Non-authenticated users can get help and contact support.
### Phase 7: Polish & Launch Prep (4 days)
| Task | Description | Effort | Priority |
| ------------------------------------- | ---------------------------- | ------ | -------- |
| **7.1** Error handling polish | User-friendly error messages | 1d | P0 |
| **7.2** Loading states and skeletons | Smooth UX during async ops | 0.5d | P0 |
| **7.3** Mobile responsiveness testing | All breakpoints | 0.5d | P0 |
| **7.4** Analytics integration | Track funnel, conversions | 0.5d | P1 |
| **7.5** Documentation update | README, guides | 0.5d | P1 |
| **7.6** Feature flag configuration | Gradual rollout setup | 0.5d | P0 |
| **7.7** Production deployment prep | Env configs, monitoring | 0.5d | P0 |
**Deliverable**: Production-ready feature.
---
## Timeline Summary
```
Week 1: Phase 0 (Prep) + Phase 1 (Public Catalog)
Week 2: Phase 2 (Cart) + Phase 3 Start (Checkout UI)
Week 3: Phase 3 Complete (Checkout UI)
Week 4: Phase 4 (Backend - SF Account Creation)
Week 5: Phase 4 Complete + Phase 5 (Integration Testing)
Week 6: Phase 6 (Public Support) + Phase 7 (Polish)
Week 7: Buffer + Launch
Total: ~7 weeks
```
### Critical Path
The **Salesforce Account creation** (Phase 4) is on the critical path because:
1. Orders require a valid SF Account ID
2. WHMCS must be linked to SF Account
3. All tracking/reporting depends on SF data
Ensure SF integration user has proper permissions before starting Phase 4.
---
## API Changes
### New Endpoints
| Method | Path | Auth | Description |
| ------ | ------------------------------ | -------- | --------------------------- |
| `POST` | `/api/checkout/register` | Public | Create user during checkout |
| `GET` | `/api/checkout/payment-status` | Required | Check payment method status |
| `POST` | `/api/checkout/cart` | Public | Build cart from config |
| `POST` | `/api/checkout/submit` | Required | Submit order |
| `POST` | `/api/support/contact` | Public | Public contact form |
### Modified Endpoints
| Method | Path | Change |
| ------ | ------------------ | ------------------------- |
| `GET` | `/api/catalog/*` | Add `@Public()` decorator |
| `POST` | `/api/auth/signup` | Make sfNumber optional |
### New Schemas
```typescript
// packages/domain/checkout/schema.ts
export const checkoutRegisterSchema = z.object({
email: z.string().email(),
firstName: z.string().min(1),
lastName: z.string().min(1),
phone: z.string().min(1),
phoneCountryCode: z.string().regex(/^\+\d{1,4}$/),
password: z.string().min(8),
address: addressFormSchema,
acceptTerms: z.literal(true),
marketingConsent: z.boolean().optional(),
});
export const checkoutSubmitSchema = z.object({
orderType: orderTypeSchema,
skus: z.array(z.string()).min(1),
configuration: z.record(z.unknown()).optional(),
});
export const publicContactSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
subject: z.string().min(1),
message: z.string().min(10),
phone: z.string().optional(),
});
```
---
## Database Changes
### Portal Database - No Schema Changes Required
The current database schema supports this feature:
- `User` table already stores auth credentials
- `IdMapping` table already has `sfAccountId` and `sfContactId` fields
- No new tables needed
### Salesforce - New Field Requirements
Ensure these fields exist and are writable by the integration user:
| Object | Field | Type | Purpose |
| ------- | ------------------------------- | -------- | ---------------------------------- |
| Account | `SF_Account_No__c` | Text | Unique customer number |
| Account | `WH_Account__c` | Text | WHMCS Client ID link |
| Account | `Portal_Status__c` | Picklist | Active/Inactive |
| Account | `Portal_Registration_Source__c` | Picklist | Add "Portal Checkout" value |
| Contact | Standard fields | - | First, Last, Email, Phone, Address |
### Salesforce - Customer Number Generation
New accounts created through checkout will have auto-generated customer numbers:
- Format: `PNNNNNNN` (P prefix + 7 digits)
- Example: `P1000001`, `P1000002`
- The "P" prefix distinguishes portal-originated customers from legacy customers
### WHMCS - No Changes Required
WHMCS client creation already works; just need to:
- Set the customer number custom field with the generated SF_Account_No\_\_c
- Ensure the client is created with proper address data
---
## Testing Strategy
### Unit Tests
| Component | Coverage Target |
| ------------------------- | --------------- |
| CheckoutStore | 100% |
| Cart validation utilities | 100% |
| Checkout steps | 90% |
| BFF CheckoutService | 90% |
### Integration Tests
| Flow | Scenarios |
| -------------------------- | ---------------------------------------------- |
| New customer checkout | Happy path, validation errors, payment failure |
| Existing customer checkout | Sign-in flow, pre-filled data |
| Cart persistence | Page refresh, browser close, storage limits |
### E2E Tests
| Test | Description |
| ------------------------------------ | ------------------------------------------------ |
| `checkout-new-customer.spec.ts` | Full flow: browse → configure → checkout → order |
| `checkout-existing-customer.spec.ts` | Sign-in during checkout |
| `checkout-cart-recovery.spec.ts` | Abandoned cart scenarios |
| `public-catalog.spec.ts` | Catalog access without auth |
### Manual Testing Checklist
- [ ] Mobile responsive design
- [ ] Screen reader accessibility
- [ ] Keyboard navigation
- [ ] WHMCS payment flow in different browsers
- [ ] Error recovery scenarios
- [ ] Rate limiting behavior
- [ ] Session timeout during checkout
---
## Rollout Plan
### Stage 1: Internal Testing (1 week)
- Deploy to staging
- Internal team testing
- Bug fixes
### Stage 2: Beta (1 week)
- Enable for 10% of traffic via feature flag
- Monitor error rates, conversion
- Gather feedback
### Stage 3: Gradual Rollout (2 weeks)
- 25% → 50% → 75% → 100%
- Monitor metrics at each stage
- Rollback plan ready
### Stage 4: Full Launch
- Remove feature flag
- Update marketing materials
- Announce to existing customers
---
## Risks & Mitigations
| Risk | Likelihood | Impact | Mitigation |
| ---------------------------------------------- | ---------- | ------------ | --------------------------------------------------------------- |
| **Salesforce Account creation fails** | Medium | **Critical** | Robust error handling, retry logic, clear user messaging |
| **Salesforce API rate limits** | Medium | High | Implement queuing if needed, monitor API usage |
| Salesforce permission issues | Medium | High | Pre-validate permissions in Phase 0, test with integration user |
| WHMCS SSO breaks checkout flow | Medium | High | Implement iframe fallback, clear error messaging |
| Partial registration (SF created, WHMCS fails) | Medium | High | Rollback logic, manual cleanup process documented |
| Cart data corruption | Low | Medium | Robust validation, version migration logic |
| Rate limiting blocks legitimate users | Low | Medium | Tune limits, add captcha fallback |
| Performance degradation (SF calls slow) | Medium | Medium | Add timeouts, show progress indicators |
| Duplicate SF Account creation | Low | Medium | Check for existing account by email before creating |
### Salesforce-Specific Risks
| Risk | Mitigation |
| ---------------------------------------- | ------------------------------------------------------ |
| Integration user lacks create permission | Test all CRUD operations in sandbox first |
| Required fields missing in payload | Document all required fields, validate before API call |
| Customer number collision | Use database sequence or SF auto-number field |
| SF sandbox vs production differences | Test in production-like sandbox with same config |
---
## Success Metrics
### Primary Metrics
| Metric | Current | Target | Measurement |
| ------------------------ | -------- | ------ | ------------------------- |
| Catalog page views | N/A | +200% | Analytics |
| Checkout start rate | ~20% | 40% | Cart creation / plan view |
| Checkout completion rate | ~30% | 50% | Orders / checkout starts |
| New customer acquisition | Baseline | +50% | New accounts |
### Secondary Metrics
| Metric | Target |
| -------------------------- | ------------ |
| Time to first order | < 10 minutes |
| Cart abandonment rate | < 60% |
| Payment step completion | > 70% |
| Error rate during checkout | < 2% |
---
## Appendix A: Component File Structure
```
apps/portal/src/features/checkout/
├── components/
│ ├── CheckoutLayout.tsx
│ ├── CheckoutProgress.tsx
│ ├── CheckoutWizard.tsx
│ ├── OrderSummaryCard.tsx
│ ├── EmptyCartRedirect.tsx
│ └── steps/
│ ├── AccountStep.tsx
│ ├── AddressStep.tsx
│ ├── PaymentStep.tsx
│ └── ReviewStep.tsx
├── hooks/
│ ├── useCheckout.ts # Main checkout logic
│ ├── useCheckoutNavigation.ts # Step navigation
│ ├── usePaymentPolling.ts # WHMCS polling
│ └── useCartRecovery.ts # Abandoned cart
├── services/
│ ├── checkout.service.ts # API calls
│ └── cart.service.ts # Cart utilities
├── stores/
│ └── checkout.store.ts # Zustand store
├── utils/
│ ├── cart-validation.ts
│ └── checkout-analytics.ts
└── index.ts
```
---
## Appendix B: Feature Flag Configuration
```typescript
// Feature flags for gradual rollout
export const FEATURE_FLAGS = {
PUBLIC_CATALOG: 'public-catalog-enabled',
UNIFIED_CHECKOUT: 'unified-checkout-enabled',
CHECKOUT_REGISTRATION: 'checkout-registration-enabled',
PUBLIC_SUPPORT: 'public-support-enabled',
};
// Usage in components
function CatalogPage() {
const isPublicCatalogEnabled = useFeatureFlag(FEATURE_FLAGS.PUBLIC_CATALOG);
if (!isPublicCatalogEnabled && !isAuthenticated) {
redirect('/auth/login');
}
return <CatalogContent />;
}
```
---
## Document History
| Version | Date | Author | Changes |
| ------- | ---------- | ------ | --------------- |
| 1.0 | 2024-12-17 | - | Initial version |