A. Current Flow (signup → onboarding → payment → access)
Self-service path (/get-started)
- Visitor lands on /get-started (Onboarding.jsx). The page requires login — clicking "Continue to payment" when not signed in redirects to the platform login, returning to /get-started afterward. No account is created by visiting the page; nothing is created until the form is submitted.
- Visitor fills a single-page form: company name, their name/title/phone, seat quantities (Full/Lite/LOTO/LOTO Lite), consulting hours, their own seat type, billing contact/email/phone/address, Terms checkbox, marketing opt-in.
- startSelfServiceOnboarding runs and atomically: creates a CustomerOrganization with is_active: false (provisional/inactive); links the user as role: customer, is_org_admin: true, customer_id: <new org>, seat_type: 'none' (no seat yet); creates a Subscription with status: 'pending_payment', payment_status: 'unpaid', onboarding_pending: true, onboarding_owner_id, onboarding_self_seat_type, seat totals set, renewal_date = +1 year; generates a QuickBooks customer + annual invoice + hosted payment link (createQuickBooksInvoice), voiding any prior unpaid invoices for that QB customer; returns the payment URL → Onboarding switches to the "pay" step.
- User pays in QuickBooks (external hosted page). The app polls verifyOnboardingPayment every 30s; a workflow (Onboarding Payment Recheck, every 15 min) also rechecks all onboarding_pending subscriptions.
- When the invoice Balance hits $0 (activateOnboarding): Subscription → status: 'active', payment_status: 'paid', onboarding_pending: false; CustomerOrganization → is_active: true, service_type re-derived from paid seats; purchaser assigned their chosen seat_type (server-validated against purchased totals); admins notified.
- User is redirected to /CustomerPortal. The frontend access gate (getCustomerAccess) now returns has_access: true.
Abandonment behavior
- An abandoned org sits with is_active: false, onboarding_pending: true forever until paid. The purchaser is already a customer with seat_type: 'none', so getCustomerAccess returns reason: 'pending_payment' and blocks the portal — redirecting back to the Onboarding pay step on a refresh. No scheduled trial expiry, no cleanup, no nurture is currently wired to this abandoned state (the nurture sequence targets abandoned onboarding leads via a separate NurtureEnrollment entity, not subscription reactivation).
- startSelfServiceOnboarding blocks a user from starting a second org only if an existing subscription is !onboarding_pending. An abandoned user can re-submit (it reuses the existing pending subscription via getOrCreateSubscription).
Admin-assisted path (CustomerManagement)
- An admin creates a CustomerOrganization via createCustomerWithProvisioning → is_active defaults to true, a Subscription is created on demand (defaults to status: 'active'), seats assigned via manageUser/inviteCustomerUser, and adminProcessPurchase generates the QuickBooks invoice. Admin customers are active immediately — they are never gated by onboarding_pending.
B. Technical Architecture
Entities
| Entity | Role in flow |
|---|
| User (built-in) | role (admin/inspector/customer), customer_id, is_org_admin, seat_type (none/full/lite/loto/loto_lite/full_loto/lite_loto), display_name, phone, title, company_name. |
| CustomerOrganization | is_active, service_type (inspection/loto/both/superior_safety_package), contact_*, secondary_emails, marketing_opt_in. |
| Subscription | The billing brain — see Section C. |
| PricingConfig | Single record; seat/hour prices (fallback defaults in pricing.ts). |
| Invitation | Invite-based seat assignment; getCustomerAccess resolves customer_id from accepted invitations as a fallback. |
| NurtureEnrollment / NurtureEmail | Abandoned-onboarding email sequence (separate from access logic). |
Subscription fields that gate access/behavior
status, payment_status, onboarding_pending, onboarding_owner_id, onboarding_self_seat_type, renewal_pending_payment, renewal_date, *_seats_total, superior_safety_package_count, granted_full_seats, granted_loto_seats, quickbooks_customer_id, quickbooks_invoice_id, payment_link_url, billing_*.
Pages / components
- Onboarding.jsx (/get-started) — the only self-service signup.
- LandingPage.jsx (/landing) — public marketing; CTAs link to /get-started?plan=….
- App.jsx — runs the access gate for every customer-role user: calls getCustomerAccess, and if !has_access renders CustomerNoSubscription (or a retry screen on error). Also redirects customers to /CustomerPortal and enforces serviceAccess (Inspections/LOTO) on allowed routes.
- Billing.jsx + EditPlanSheet/EditPlanDialog/AdminEditPlanDialog — plan changes via processCheckout (customer add-ons) and adminProcessPurchase (admin).
- CustomerNoSubscription.jsx — the "no active subscription" blocker.
Backend functions
startSelfServiceOnboarding, verifyOnboardingPayment, getCustomerAccess, getCustomerBilling, processCheckout, payCurrentPlan, renewSubscription, adminProcessPurchase, chargeSavedCard, syncSubscriptions, plus shared quickbooks.ts, subscriptionResolve.ts, seatValidation.ts, serviceType.ts, pricing.ts, sspPackage.ts, customerContext.ts.
Access-control logic (the gate)
getCustomerAccess is the single authority. Decision order:
- Non-customer (admin/inspector) → has_access: true always.
- Resolve org (via customer_id, or accepted Invitation fallback). No org → reason: 'no_org', blocked.
- SSP org → access unless status === 'cancelled'.
- User provisioned (seat_type !== 'none' OR is_org_admin) → access unless status === 'cancelled'. (This is how admin-created customers with seats but a zero/active subscription get in.)
- onboarding_pending: true → blocked, reason: 'pending_payment', returns the payment URL (so Onboarding can resume).
- Otherwise: access if paidSeats > 0 || sspCount > 0 and status !== 'cancelled'; else reason: 'no_seats' or 'cancelled'.
Feature scoping is layered on top of the gate, in App.jsx, via getServiceAccess(org.service_type) → allowed routes per Inspections/LOTO.
C. Current Subscription Status Logic
| status | Set by | Gate effect | Notes |
|---|
| pending_payment | startSelfServiceOnboarding | Blocked (handled via onboarding_pending flag, not this status alone) | The real blocker is the onboarding_pending boolean, which short-circuits before seat/status checks. |
| active | verifyOnboardingPayment (on paid), processCheckout, payCurrentPlan, renewSubscription, adminProcessPurchase | Allowed (if seats exist / provisioned / SSP) | The normal operating state. |
| past_due | syncSubscriptions (renewal_date passed + active) | Allowed (still has seats) | Renewal reminder path; cleared back to active when the renewal invoice is paid. |
| suspended | (declared in enum; not written by any current function) | Not currently checked by the gate — a suspended sub with seats would still pass the provisioned/SSP/seats>0 checks. | Effectively unused. |
| cancelled | (declared in enum; not actively set by any function) | Blocked where explicitly checked (sub.status !== 'cancelled') | Only consulted as a negative condition; nothing sets it today. |
Key flags independent of status
- onboarding_pending: true → hard block (checked before seats/status). Cleared on payment.
- renewal_pending_payment: true → does not block access; just signals the daily sync to roll renewal_date forward once the renewal invoice clears.
- payment_status (unpaid/partial/paid) → informational; not directly consulted by the gate (payment is inferred from onboarding_pending + QuickBooks invoice balance).
D. Potential Trial Integration (analysis only)
The cleanest insertion point is a new boolean trialing + two date fields trial_started_at / trial_ends_at on Subscription, plus a new reason ('trial_expired') returned by getCustomerAccess. Where it fits:
Signup flow change: startSelfServiceOnboarding would stop generating a QuickBooks invoice immediately. Instead it would: create the org with is_active: true (so the product is usable); create the Subscription with status: 'triailing', trialing: true, trial_started_at = now, trial_ends_at = now + 14d, onboarding_pending: false, and provision the purchaser's self-seat immediately (so they experience the real product).
getCustomerAccess trial branch (inserted between the SSP branch and the provisioned/seats check):
- if (sub.trialing && now < trial_ends_at) → has_access: true (full product, gated by service_type like everyone else).
- if (sub.trialing && now >= trial_ends_at) → has_access: false, reason: 'trial_expired', with an "Upgrade" CTA pointing to a new processCheckout/payCurrentPlan call. The org and all data stay intact; only paid actions are blocked. "Paid actions" would need a second lightweight gate (see F) since the current gate is all-or-nothing portal entry.
Upgrade path: the existing processCheckout/payCurrentPlan + QuickBooks flow already turns an unpaid/sub-seat org into a paid active one. On payment, verifyOnboardingPayment-style logic would clear trialing, set status: 'active', and roll renewal_date. No new payment system is needed — QuickBooks stays the paid path.
Trial expiry job: a new scheduled workflow (or an extension of the daily syncSubscriptions) that flips trialing subs past trial_ends_at to a trial_expired state — without touching data.
Admin-assisted: admin-created orgs never set trialing, so they're unaffected.
E. Risks / Conflicts
- getCustomerAccess is binary (in/out of portal) — it cannot express "logged in but can't perform paid actions." The user's requirement (expired trial users still log in but can't perform paid actions) needs a second layer beyond the portal gate. The current gate has no per-action hook.
- syncSubscriptions queries { status: { $in: ["active", "past_due"] } }. A trialing status would be invisible to renewal reminders and past-due logic unless added to that filter. (Low risk for trial — trials don't renew — but must be excluded so the sync doesn't try to invoice them.)
- verifyOnboardingPayment batch mode filters onboarding_pending: true. A trialing org created with onboarding_pending: false is correctly excluded — but if the trial creation reuses onboarding_pending as a signal, it would conflict. Keep trial off the onboarding_pending path.
- Billing UI / analytics assumptions: getCustomerBilling, SubscriptionsList, getAdminCustomerBilling, CustomerCompliancePanel, and likely Analytics/OshaAnalytics filter or label by status/payment_status/renewal_date. A trialing sub with renewal_date = +1yr and payment_status = 'unpaid' would show up as "unpaid/unrenewed" in admin dashboards unless triaging is excluded from those views. Need to audit every Subscription.filter/list render.
- Seat assignment during trial: validateSeatAssignment reads *_seats_total from the Subscription. A trial would need real seat totals allocated (the purchaser's seat + any trial seats) so invites during the trial pass validation — but these seats are unpaid. The "seats only added on payment" invariant in processCheckout ("Save plan without payment is a no-op for seat totals") must not be violated: trial seat totals should be granted, not billable, until upgrade.
- granted_*_seats already exists for non-billable seats (admin grants). A trial could reuse this concept (trial seats are "granted" until conversion), but the conversion logic in adminProcessPurchase clears granted counters — must ensure self-service upgrade mirrors that.
- Existing paying customers: adding a trialing status is additive; the gate's existing branches are unchanged. Risk is only in places that enumerate statuses and assume the closed set {pending_payment, active, past_due, suspended, cancelled} — any switch/label without a default could render "triailing" as blank or "—".
- renewal_date semantics: trials don't have a real renewal date. Setting it to +1 year at trial start would make the daily sync think they're a year from renewal — harmless, but the 30-day reminder logic could eventually fire. Safer to leave renewal_date null until upgrade, but renewal_date is a required field on the Subscription schema. This schema constraint is a concrete blocker worth noting.
- Nurture/abandoned-onboarding overlap: the existing nurture targets abandoned pending_payment leads. A trial path creates a different abandonment (trial expired, not paid). The nurture enrollment lead_type enum is ['onboarding','signup'] — a trial-expired lead would need a third type or reuse, or the trial expiry job handles its own reminder.
- Onboarding page resume logic: Onboarding.jsx checks access?.reason === 'pending_payment' to resume the pay step. A trial user whose trial expired would get reason: 'trial_expired' and must be routed to an Upgrade screen, not the pay step.
F. Recommended Implementation Approach (safest, no code yet)
- New fields on Subscription (additive, nullable): trialing (boolean, default false), trial_started_at (date-time), trial_ends_at (date-time), and add 'triailing' to the status enum. Do not overload onboarding_pending — keep trial on its own flag so the two flows never interact.
- New access reason in getCustomerAccess: insert a trial branch after the SSP branch and before the provisioned/seats checks: sub.trialing && now < trial_ends_at → has_access: true (let service_type gate features as normal); sub.trialing && now >= trial_ends_at → has_access: false, reason: 'trial_expired', upgrade_url. Because getCustomerAccess already returns has_access true for provisioned users (seat_type !== 'none' / is_org_admin), a trial user must be given a real seat_type at trial start so the provisioned branch also admits them — meaning the trial branch and the provisioned branch will both pass. That's fine (defense in depth).
- Trial creation function (new, e.g. startTrial): mirrors startSelfServiceOnboarding but creates the org is_active: true, sets trialing: true + dates, assigns the purchaser their seat immediately, and does not touch QuickBooks. Reuse validateSeatAssignment and deriveServiceType.
- Trial-expiry workflow (scheduled, daily): flips trialing subs past trial_ends_at to status: 'trial_expired'. No data deletion. Send one expiry email (Gmail). This is separate from syncSubscriptions to avoid entangling renewal logic.
- Upgrade path = existing flow: when a trial user upgrades, call the existing processCheckout/payCurrentPlan → QuickBooks invoice → a new verifyTrialPayment (or extend verifyOnboardingPayment) that on paid clears trialing, sets status: 'active', sets renewal_date to now + 1 year, and converts any trial-granted seats to paid (mirroring granted_*_seats conversion). QuickBooks remains the only paid system.
- Expired-trial "no paid actions" gate: rather than re-architecting getCustomerAccess (which is binary portal entry), keep expired trial users in the portal but add a lightweight client/server check on write actions (create inspection, create LOTO procedure, invite user) — e.g. a shared requireActiveSubscription helper that the relevant backend functions call. The trial-expired user can still view their data and log in, satisfying the requirement, while being blocked from new paid actions. This keeps the existing gate untouched.
- Dashboard/query audit: search every Subscription.filter, status label, and renewal-date usage and ensure trialing/trial_expired are either explicitly included or excluded as appropriate. Concretely: syncSubscriptions (exclude trial), getCustomerBilling (label "Trial"), SubscriptionsList/AdminEditPlanDialog (show trial banner), Analytics (exclude or segment trial). Add 'triailing'/'trial_expired' to the planLabels/serviceTypeLabels maps.
- Existing customers untouched: admins and active/past_due subs never set trialing, so getCustomerAccess reaches none of the new branches for them. No migration of existing records is required.
- Schema note to resolve: renewal_date is required on the Subscription schema. For trials, set renewal_date to trial_ends_at + a grace, or relax the constraint — decide before implementation.
Summary
The architecture supports a trial cleanly because access is already centralized in getCustomerAccess and payment is already centralized in QuickBooks via createQuickBooksInvoice/verifyInvoicePaid. The two real design decisions are (a) keeping the trial on its own flag (trialing + dates) so it never collides with onboarding_pending/renewal_pending_payment, and (b) adding a separate "no paid actions" layer for expired trials, since the current gate is all-or-nothing portal entry. No existing paying customer or admin-created org is affected. Nothing has been changed — this is analysis only.