Onboarding System Architecture
Flow Overview
/ (LoadingRedirect) → checks auth status (onboardingComplete) → /onboarding or /main OnboardingPage drives a 5-step state machine based on OnboardingStep enum:
Step Component API Call Backend Action business OnboardingBusinessForm POST /users/me/onboarding/business Create business + business_user, set Firebase claims location OnboardingLocationForm POST /users/me/onboarding/:id/location Create location + address via Google Places billing OnboardingBillingForm PATCH /users/me/onboarding/:id/bill Encrypt FEL creds, validate taxId, Firebase claims complete OnboardingComplete PATCH /users/me/onboarding/:id/status Set status = done, navigate to /main done — — Redirect
Frontend (apps/frontend-pwa/src/)
Components (onboarding/):
OnboardingBusinessForm.tsx — name, businessType, countryId; has a "Skip" button OnboardingLocationForm.tsx — Google Places autocomplete, timezone (IANA), optional contact info OnboardingBillingForm.tsx — taxId, legalName, optional FEL credentials (encrypted) OnboardingComplete.tsx — review summary, triggers final status patch, clears localStorage State Management: Each form uses a useXxxFormState hook persisting to localStorage (keys: ONBOARDING_BUSINESS_FORM_DATA, ONBOARDING_LOCATION_FORM_DATA, ONBOARDING_BILLING_FORM_DATA)
Service layer: onboardingService.ts with 6 functions mapping directly to backend endpoints.
Retry logic: OnboardingPage fetches status with up to 5 retries / 1s delay on mount.
Backend (apps/backend/src/users/)
Controller (me-onboarding.controller.ts) — 7 endpoints under users/me/onboarding.
Key services:
OnboardingOrchestratorService — orchestrates the 3 mutation steps:
upsertOnboardingBusiness() → creates business + business_user (Owner role) + Firebase custom claims upsertUserLocation() → Google Places lookup → creates address + location entity patchOnboardingBill() → encrypts FEL password/token/accessCode via CryptoService, validates taxId per country UsersService:
getUserOnboardingStatus(userId) — returns { step, business, location } based on DB state getStatusCreatingBusiness(userId) — same but uses findCreatingBusinessByUserId (finds business where onboardingStatus <> 'done') BusinessesService:
findCreatingBusinessByUserId(userId) — finds active business not yet done findBusinessByIdWithRelations(id) — full business graph with location + address DTOs (apps/backend/src/users/interfaces/dtos/): UpsertOnboardingBusinessDTO, UpsertOnboardingLocationDTO, PatchOnboardingBillDTO, PatchOnboardingStatusDTO
Enum (packages/global/enums/user.enums.ts)
enum OnboardingStep { business = "business", // step 1 location = "location", // step 2 billing = "billing", // step 3 complete = "complete", // step 4 (review) done = "done" // final state }
What Works Well Clear state machine: OnboardingStep enum drives both backend state and frontend rendering cleanly Orchestrator pattern: The OnboardingOrchestratorService correctly isolates multi-step coordination from the controller localStorage persistence: Good UX — form data survives refreshes without requiring a round-trip Design Issues Worth Addressing
- Firebase claims are inconsistent with DB state (biggest concern) onboarding_completed claim is set after billing is submitted (complete step), but the final done status is set separately via patchOnboardingStatus. A user who completes billing but closes the browser before clicking the final confirm button will have onboarding_completed: true in Firebase but onboardingStatus = complete in the DB. The /auth/me endpoint derives onboardingComplete from the DB, so the frontend redirect works correctly — but the Firebase claim is misleading and could break any future code that reads it directly.
Suggestion: Set onboarding_completed only when patchOnboardingStatus(done) is called, not after billing.
- billing is cast from business — type system abuse In OnboardingPage.tsx:56:
const billing = result.business as BillingData | null; Both business and billing point to the same object, cast to different interfaces. A proper response shape from /status should return { step, business, location, billing } as separate fields.
-
No transactional behavior In upsertUserLocation, the address and location are written first, then updateBusiness advances the status. If the second call fails, the location exists in the DB but the step doesn't advance — the user would be stuck retrying without the UI knowing why.
-
Retry-on-all-errors OnboardingPage retries getOnboardingStatus on any error, including 401/403. A permanent auth failure burns through all 5 retries before showing the error message, adding a 5-second delay.
-
Error state is shadowed by step state In OnboardingPage.tsx:178, if (error) is checked after all the step conditionals. If a step is set alongside an error, the error is silently swallowed. The error check should come right after the loading check.
-
GET /users/me/onboarding is dead code No frontend code calls this endpoint — the PWA uses /status and /creating-business. The endpoint is also redundant with /status conceptually. Consider removing it or documenting its purpose.
-
Sensitive data in localStorage FEL passwords and access codes are persisted in ONBOARDING_BILLING_FORM_DATA. They're only cleared in OnboardingComplete on success. A failed or abandoned onboarding leaves credentials in localStorage.
-
Hardcoded locale defaults timezone ?? "America/Guatemala" and postalCodeGuatemala["zona 1"] are hardcoded in the orchestrator. Since countryId is already collected in the business step, defaults could be derived from it.
Minor Observations @IsPublic() on POST /business is likely intentional (pre-DB-user state), but should be documented with a comment explaining why The context variable in getOnboarding() is set but only used in error logging — fine, but worth noticing since it was accidentally leaked in the response before the fix OnboardingStep.done has two handlers in OnboardingPage: a useEffect that navigates, and a return null below — the return null is unreachable since navigation fires first Overall: The flow logic is sound and the orchestrator structure is clean. The main risks are the Firebase claim timing mismatch (#1) and the type unsafety of billing/business sharing an object (#2). The others are quality-of-life improvements.