- Introduced WhmcsAccountDiscoveryService to streamline client account discovery processes. - Expanded WhmcsCacheService to include caching for subscription invoices and client email mappings, improving data retrieval efficiency. - Updated WhmcsClientService to utilize caching for client ID lookups by email, enhancing performance. - Implemented new internet cancellation features in SubscriptionsController, allowing users to preview and submit cancellation requests for internet services. - Added validation schemas for internet cancellation requests, ensuring data integrity and user guidance during the cancellation process. - Refactored various components and services to integrate new cancellation functionalities, improving user experience and operational flow.
53 KiB
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:
- Browse catalog without authentication
- Configure products without an account
- Complete checkout with seamless account creation
- 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
- Current State Analysis
- Target Architecture
- User Journeys
- Technical Design
- Development Phases
- API Changes
- Database Changes
- Testing Strategy
- Rollout Plan
- Risks & Mitigations
- 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
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
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
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
// 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
// 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
// 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
// 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
// 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.
// 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
// 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:
- Create SF Account (generates customer number)
- Create SF Contact
- Create WHMCS Client
- Link SF Account to WHMCS (update WH_Account__c)
- Create Portal User + ID Mapping
- 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:
- Orders require a valid SF Account ID
- WHMCS must be linked to SF Account
- 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
// 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:
Usertable already stores auth credentialsIdMappingtable already hassfAccountIdandsfContactIdfields- 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
// 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 |