Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

17 KiB

PRD — Tier Bug Fixes, Trials, Annual Billing & Payment Enforcement

  • Slug: tier-trials-annual
  • Date: 2026-03-09
  • Status: Draft
  • Depends on: 2026-03-03-tenant-tier-system

Summary

Extends the 2-tier system (Pro/Premium) with: (1) bug fixes and gaps identified in code review, (2) developer documentation for adding new tier-gated features, (3) annual billing at ~17% discount, (4) Stripe-managed trial periods (7-day Pro for new signups, 30-day Premium for paying Pro customers), (5) trial countdown UI in header + account page, (6) payment failure handling with reminders, and (7) a "Request Premium Trial" flow with manual fulfillment via Nine Minds extension.

Pricing

Pro Monthly Pro Annual Premium Monthly Premium Annual
Base fee $89/mo $890/yr (~$74.17/mo) $349/mo $3,490/yr (~$290.83/mo)
Per user $12/user/mo $120/user/yr (~$10/user/mo) $25/user/mo $250/user/yr (~$20.83/user/mo)

Annual = pay for 10 months, get 12 (~17% discount, "2 months free").

Problem

The tier system works but has bugs, no trials, no annual billing, no payment failure handling, and no documentation for extending it.

Goals

  1. Fix all identified bugs and gaps in the tier system
  2. Document how to add new tier-gated features (developer guide)
  3. Add annual billing option with 17% discount
  4. Implement 7-day free Pro trial for new accounts (CC required, auto-charge after)
  5. Implement 30-day free Premium trial for paying Pro customers
  6. Show trial days remaining in header (every page) and account page
  7. Handle payment failures with reminders and visual "shame" indicators
  8. Allow Pro users to request a Premium trial via in-app message form
  9. Enable Nine Minds admins to manually process Premium trial requests

Non-goals

  • Self-service downgrade flow (Premium → Pro)
  • Automated Premium trial approval (intentionally manual)
  • Public pricing page or tier comparison marketing page
  • Refund processing (handled via Stripe dashboard)
  • Free tier or freemium model

Users and Primary Flows

Personas

  1. New signup — Visits website, picks Pro plan, enters CC, starts 7-day trial
  2. Paying Pro customer — Wants to try Premium features, requests 30-day trial
  3. Trialing user (Pro) — Sees countdown banner, decides to continue or cancel before charge
  4. Trialing user (Premium) — Sees countdown with pricing breakdown, auto-charged at trial end unless they cancel back to Pro
  5. Delinquent customer — Card declined, sees payment failure banner on every page
  6. Nine Minds admin — Receives Premium trial requests, processes them via extension

Flow 1: New Pro Trial (7-day)

  1. User signs up on website → Stripe Checkout with trial_period_days: 7
  2. CC captured but not charged
  3. User lands in app → header shows "Trial: 7 days left"
  4. Day 7 → Stripe auto-charges first month (Pro monthly or annual, depending on selection)
  5. 24-48 hour grace period: user can cancel for full refund (manual via support or Stripe portal)
  6. If charge fails → payment failure flow (see Flow 5)

Flow 2a: Premium Trial for Paying Pro Customer (30-day)

  1. Paying Pro customer (NOT on active trial) clicks "Upgrade to Premium" on account page
  2. Instead of immediate upgrade, sees option to "Request 30-day Premium Trial"
  3. Fills in message form (textarea) + "Send Request" button
  4. Email notification sent to Nine Minds team
  5. Admin activates 30-day Premium trial via Nine Minds extension
  6. Customer gets Premium features immediately, header shows "Premium Trial: 30 days left"

Flow 2b: Premium Trial for Pro-Trialing Customer (edge case)

  1. Customer on 7-day Pro trial wants Premium immediately
  2. They request via same form (or contacts support)
  3. Admin processes via Nine Minds extension — single action that:
    • Ends Pro trial immediately
    • Charges first month of Pro (customer is now a paying Pro customer)
    • Converts to Premium subscription with 30-day trial
  4. Customer gets Premium features, header shows "Premium Trial: 30 days left"

Flow 3: Premium Trial Active & Ending

  1. Admin activates trial → Stripe creates Premium subscription with trial_period_days: 30
  2. Customer sees Premium features immediately, header shows "Premium Trial: 30 days left"
  3. Account page shows pricing breakdown: "You'll be charged $X/mo when your trial ends on {date}"
  4. Customer can cancel Premium trial early → reverts to Pro (cancel button on account page)
  5. If customer does nothing → Stripe auto-charges for Premium at trial end, customer becomes paying Premium
  6. If charge fails at trial end → payment failure flow (banner of shame)

Flow 4: Annual Billing

  1. During checkout (new signup) or from account page (existing customer)
  2. Toggle between monthly/annual pricing
  3. Annual shows "2 months free" / "Save 17%"
  4. Stripe handles annual recurring billing

Flow 5: Payment Failure ("Banner of Shame")

  1. Stripe charge fails (card declined, expired, etc.)
  2. Stripe subscription enters past_due status
  3. App detects past_due from subscription data
  4. Header banner: "Payment failed — update your payment method" (persistent, not dismissible)
  5. Stripe retries per its retry schedule (Smart Retries)
  6. Nine Minds team manually monitors delinquent accounts and contacts them
  7. No automated feature lockout — banner of shame is the enforcement for now

UX / UI Notes

Trial Banner (Header)

  • Position: left side of header, next to tenant UUID badge
  • Style: consistent with existing UI (not a disruptive alert bar)
  • Content: "{Plan} Trial: {N} days left" with link to account page
  • Color: neutral/info for >3 days, warning for ≤3 days
  • Always visible during trial, not dismissible

Payment Failure Banner (Header)

  • Position: same area as trial banner (replaces trial banner if both apply)
  • Style: destructive/error variant
  • Content: "Payment failed — Update payment method" with link to billing portal
  • Always visible, not dismissible

Account Page — Trial Section

  • Shows trial status: plan name, days remaining, start/end dates
  • Progress bar showing trial progress
  • CTA varies by state:
    • Pro trial: "You'll be charged $X on {date}" + cancel option
    • Premium trial: "You'll be charged $X/mo for Premium on {date}" + "Cancel & revert to Pro" option
    • Payment failed: "Update payment method" link to billing portal

Premium Trial Request Form

  • Location: Account Management page, in the Plan & Tier section
  • Shown when: user is on Pro (paid or trialing) and clicks "Upgrade to Premium"
  • UI: Simple textarea ("Tell us what you'd like to try with Premium") + "Send Request" button
  • Confirmation: toast "Request sent! We'll be in touch."
  • No dropdown, no complex form — just a message

Nine Minds Extension — Premium Trial Activation

  • Existing Status column enhanced to show: current plan (Pro/Premium), trial status if trialing, subscription status (active/past_due/etc.)
  • "Start Premium Trial" action button per tenant. Behavior depends on tenant state:
    • Paying Pro: creates Premium subscription with 30-day trial directly
    • Pro trial: ends Pro trial → charges first month of Pro → creates Premium subscription with 30-day trial (all in one action)
    • Button disabled/hidden for tenants already on Premium or Premium trial
  • Premium trial request inbox: list of pending requests with tenant name, email, message, date
  • Manual plan overrides (e.g. courtesy access) done directly in DB — no API exposed

Requirements

Bug Fixes

BF1: Fix stale JSDoc in tenantTiers.ts

  • Replace references to 'basic' with 'pro' in docstrings for resolveTier() and ResolvedTier

BF2: Fix buildPhaseItems using wrong price for scheduled reductions

  • buildPhaseItems() in StripeService must resolve price IDs based on the tenant's CURRENT tier, not pick first configured price
  • Fetch tenant's plan, then use matching tier's price IDs

BF3: Add server-side assertTierAccess to invoice template save

  • Verify assertTierAccess(TIER_FEATURES.INVOICE_DESIGNER) is called in saveInvoiceTemplate action
  • If missing, add it (scratchpad says it was applied but analysis found gap — verify and fix)

BF4: Deduplicate JWT plan logic in nextAuthOptions.ts

  • Extract shared plan-fetching logic into a helper function used by both buildAuthOptions() and static options

BF5: Add warning log for unknown Stripe product names

  • In tierFromStripeProduct(), log a warning when product name doesn't match any known mapping
  • Include product name and ID in the log for debugging

BF6: Add loading skeleton to TierGate

  • Replace return null while loading with a skeleton placeholder
  • Prevents empty flash during slow session loads

BF7: Add FK on stripe_base_price_id

  • New migration adding foreign key from stripe_subscriptions.stripe_base_price_id to stripe_prices.stripe_price_id

BF8: Mark add-ons scaffolding as intentional

  • Add clear JSDoc to addOns.ts explaining it's intentional scaffolding for future use
  • Add // Scaffolding: not yet integrated into access checks comment

Documentation

DOC1: Tier Gating Developer Guide

  • Create docs/tier-gating-guide.md
  • Step-by-step: how to add a new feature to the tier gate
    1. Add feature to TIER_FEATURES enum
    2. Add to TIER_FEATURE_MAP for appropriate tiers
    3. Add to FEATURE_MINIMUM_TIER reverse map
    4. Gate UI with TierGate or ServerTierGate
    5. Gate server actions with assertTierAccess()
    6. Add display name in AccountManagement FEATURE_DISPLAY_NAMES
    7. Test: unit test for feature mapping, integration test for gating
  • Include code examples from existing INVOICE_DESIGNER implementation
  • Document CE bypass behavior

Annual Billing

AB1: Create annual Stripe prices

  • Env vars: STRIPE_PRO_BASE_ANNUAL_PRICE_ID, STRIPE_PRO_USER_ANNUAL_PRICE_ID, STRIPE_PREMIUM_BASE_ANNUAL_PRICE_ID, STRIPE_PREMIUM_USER_ANNUAL_PRICE_ID
  • Stripe prices created with recurring.interval: 'year' and 10-month pricing

AB2: Add billing_interval to subscription tracking

  • Migration: add billing_interval column to stripe_subscriptions ('month' | 'year', default 'month')
  • Update subscription import/create to track interval

AB3: Checkout supports annual option

  • Modify checkout session creation to accept interval parameter
  • Pass annual price IDs when interval === 'year'

AB4: Account page billing interval toggle

  • Show monthly/annual toggle with savings callout
  • Switching interval creates new subscription schedule or updates at period end

AB5: Upgrade flow supports annual billing

  • upgradeTier() accepts interval parameter
  • Uses annual price IDs when interval === 'year'

Trial System

TR1: Add trial fields to JWT/Session

  • Add trial_end, subscription_status to JWT token and Session.user
  • Refresh alongside plan (5-minute throttle)

TR2: Update TierContext with trial state

  • Add to TierContextValue: isTrialing, trialDaysLeft, trialEndDate, subscriptionStatus
  • Compute from session fields
  • Add isPaymentFailed derived from subscriptionStatus === 'past_due'

TR3: Pro trial on new signup (7-day)

  • Modify checkout session creation: add subscription_data.trial_period_days: 7
  • CC captured via Checkout, not charged until trial ends
  • Stripe auto-charges on trial end

TR4: Trial banner in header

  • New TrialBanner component in Header
  • Position: left side, next to tenant badge
  • Shows: "{Plan} Trial: {N} days left"
  • Links to /msp/account
  • Warning color when ≤3 days remaining

TR5: Payment failure banner in header

  • New PaymentFailedBanner component in Header
  • Shows when subscriptionStatus === 'past_due' or 'unpaid'
  • "Payment failed — Update payment method" linking to Stripe billing portal
  • Error/destructive styling, not dismissible

TR6: Trial status on account page

  • New "Trial Status" card in Account Management
  • Shows: plan, days remaining, progress bar, start/end dates
  • CTA varies by state (continue, upgrade, update payment)

TR7: Handle trial end → auto-charge

  • Stripe handles this natively via trial_end on subscription
  • Webhook customer.subscription.updated fires when trial ends and billing starts
  • Ensure handleSubscriptionUpdated correctly processes trial → active transition

TR8: Handle trial end → payment failure

  • If charge fails at trial end, subscription goes to past_due
  • Payment failure banner appears
  • Stripe Smart Retries handle retry logic

TR9: Premium trial (30-day) — manual activation

  • New server action: startPremiumTrialAction(tenantId) (admin-only, master billing tenant)
  • Detects tenant's current state and handles accordingly:
    • Paying Pro: creates Premium subscription with trial_period_days: 30
    • Pro trial: (a) ends Pro trial by removing trial_end on subscription, (b) Stripe charges first month of Pro immediately, (c) then creates Premium subscription with trial_period_days: 30
  • Updates tenants.plan to 'premium'
  • Customer sees Premium features immediately

TR10: Premium trial end — auto-charge or cancel

  • Default (customer does nothing): Stripe auto-charges for Premium at trial end → customer becomes paying Premium
  • Customer cancels during trial: "Cancel Premium Trial" button on account page → reverts subscription, sets tenants.plan back to 'pro'
  • Webhook customer.subscription.updated handles both transitions:
    • trialingactive: auto-charge succeeded, customer is now paying Premium
    • trialingcanceled: customer cancelled during trial, revert to Pro
    • trialingpast_due: charge failed, show payment failure banner

TR11: Premium trial request form

  • Textarea in Account Management (Plan & Tier section)
  • Shown when: isPro && !isTrialing and user clicks "Upgrade to Premium"
  • Server action: sendPremiumTrialRequestAction(message)
  • Sends email to Nine Minds support with tenant info + message

TR12: Premium trial request — NineMinds extension

  • Add "Trial Requests" section to TenantManagementView
  • List pending requests with: tenant name, email, message, date
  • "Start Premium Trial" action button per request
  • Action calls API: POST /api/v1/tenant-management/start-premium-trial
  • API endpoint: validates admin, calls startPremiumTrialAction(tenantId)

Payment Enforcement

PE1: Track subscription_status in session

  • Already covered by TR1 (subscription_status in JWT)

PE2: Payment failure detection

  • handleSubscriptionUpdated webhook sets local status to match Stripe
  • Status propagates to JWT on next refresh (≤5 min)

PE3: Visual payment reminder

  • Already covered by TR5 (payment failure banner)

PE4: Grace period documentation

  • Document 24-48 hour cancellation window after first charge post-trial
  • This is handled via Stripe billing portal / support — no custom code needed

Data / API / Integrations

New Database Columns

stripe_subscriptions:

  • billing_interval (text, default 'month') — 'month' | 'year'

No new tables needed. Trial state comes from Stripe subscription fields (trial_end, status).

New JWT/Session Fields

  • trial_end: number | null — Unix timestamp of trial end
  • subscription_status: string | null — 'active' | 'trialing' | 'past_due' | 'unpaid' | 'canceled'

New API Endpoints

  • POST /api/v1/tenant-management/start-premium-trial — Admin-only, starts 30-day Premium trial

  • POST /api/v1/tenant-management/trial-requests — List pending trial requests

  • Server action: sendPremiumTrialRequestAction(message) — Sends email notification

New Env Vars

  • STRIPE_PRO_BASE_ANNUAL_PRICE_ID
  • STRIPE_PRO_USER_ANNUAL_PRICE_ID
  • STRIPE_PREMIUM_BASE_ANNUAL_PRICE_ID
  • STRIPE_PREMIUM_USER_ANNUAL_PRICE_ID
  • TRIAL_REQUEST_EMAIL — Email address for Premium trial requests (default: support@nineminds.com)

Security / Permissions

  • startPremiumTrialAction restricted to master billing tenant only
  • Trial state read from Stripe (authoritative) via webhooks, not client-editable
  • Payment failure banner cannot be dismissed (prevents ignoring payment issues)

Rollout

Phase 1: Bug Fixes & Documentation

  • All BF* and DOC* items — safe to deploy, no behavior change

Phase 2: Annual Billing

  • AB* items — new Stripe prices must be created in Stripe Dashboard first
  • Feature-flagged: annual-billing flag controls visibility of annual toggle

Phase 3: Trial System

  • TR* and PE* items — requires Stripe price configuration
  • Feature-flagged: trial-system flag controls trial creation on checkout
  • Premium trial is manual-only, no flag needed

Resolved Questions

  1. What happens when subscription goes unpaid? → Banner of shame + manual monitoring/outreach by Nine Minds. No automated lockout for now.
  2. Annual mid-year cancellation refund? → Case-by-case manual process via support.
  3. 24-48hr grace period after first charge? → Support policy only. Handled via Stripe portal/support, no custom code.

Acceptance Criteria

  1. All 8 bugs fixed and verified
  2. Developer guide published and covers full tier-gating workflow
  3. Annual billing toggle works on checkout and account page
  4. New signups get 7-day Pro trial with CC capture
  5. Trial countdown visible in header on every page
  6. Payment failure banner visible when subscription is past_due
  7. Pro customers can request Premium trial via in-app form
  8. Nine Minds admins can activate Premium trials from extension
  9. Premium trial auto-charges at end unless customer cancels back to Pro
  10. Customer can cancel Premium trial and revert to Pro from account page