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

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:

  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
  2. Target Architecture
  3. User Journeys
  4. Technical Design
  5. Development Phases
  6. API Changes
  7. Database Changes
  8. Testing Strategy
  9. Rollout Plan
  10. Risks & Mitigations
  11. 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:

  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

// 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

// 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