Forever Trial (complimentary access) — Design

Date: 2026-05-28 Status: Approved

Problem

The admin wants to let friends use the full app indefinitely without paying, and without faking Stripe data. Today the paywall (ApplicationController#require_subscription) gates access purely on current_user.payment_processor.subscribed?, redirecting everyone else to /subscriptions. There is no way to comp a user.

Goal

A one-click toggle in the admin users list (/admin/users?tab=list, actions column) that grants/revokes a "Forever Trial" — complimentary access that bypasses the paywall. Comped friends can still subscribe and pay later, at which point they become normal paying subscribers.

Approach (chosen: complimentary-access flag)

Add a comp_access:boolean flag on User. The paywall passes when comp_access? || subscribed?. No fake Pay/Stripe subscription is created, so there is no naming conflict on upgrade, no Stripe-sync risk, and no bogus trial-banner countdown.

Rejected alternatives:

  • Fake "forever" Pay subscription (local trialing, far-future trial_ends_at): on upgrade you get two "default" subscriptions (ambiguous Pay#subscription), the fake sub isn't in Stripe (sync/cancel paths can error), and the trial banner counts down ~36,500 days.
  • Real Stripe 100%-off / infinite-trial subscription: needs live Stripe calls, network dependency, and puts friends into Stripe as customers — overkill.

Components

  1. Migrationadd_column :users, :comp_access, :boolean, default: false, null: false. Yields User#comp_access?. The column name reflects what it is (complimentary access); the UI label is "Forever Trial".

  2. PaywallApplicationController#require_subscription: return if current_user.comp_access? || current_user.payment_processor.subscribed?. The comp_access? short-circuit also avoids touching payment_processor for comped users.

  3. Admin toggle — member route POST /admin/users/:id/toggle_comp_accessAdmin::UsersController#toggle_comp_access. Flips the boolean, writes an audit(:"user.comp_access", target:, granted:) record, and renders a turbo-stream replacing the user row + a flash. Rejects admin targets (non-admin only), mirroring the existing impersonate/confirm actions.

  4. Button — in app/views/admin/users/_user_row.html.erb actions column, beside impersonate: a gift-icon button_to to the toggle route, with turbo_confirm and title reflecting the current state ("Grant Forever Trial" / "Revoke Forever Trial"). Rendered only for non-admins. Indigo to distinguish from the other icons.

  5. BadgeSubscriptionHelper#subscription_status_for gains a :forever_trial state. Precedence: a real Pay subscription (on_trial?/subscribed?) wins; else comp_access? → "Forever Trial" (indigo badge); else "Not Subscribed". A comped friend shows Forever Trial; once they pay they show Subscribed.

  6. Auto-clear on paymentBilling::CompAccessRevoker, subscribed to stripe.subscription.created via Pay::Webhooks.delegator in an initializer (mirroring stripe_connect.rb / Affiliate::WebhookHandler). Resolves the Stripe customer → Pay::CustomerUser and sets comp_access = false, so paying establishes a single source of truth.

Testing

  • Paywall (integration): with the paywall enabled, a comp_access user is NOT redirected; a plain non-subscribed user IS.
  • Admin controller: toggle grants then revokes, writes an audit row, refreshes the row; admin target is rejected.
  • Helper: badge shows "Forever Trial" for a comp user, "Subscribed" when also subscribed.
  • Webhook handler: Billing::CompAccessRevoker clears comp_access on a subscription.created event for the matching customer.

Edge cases / notes

  • Admin targets: button hidden + server-side guard.
  • Per the "auto-clear on payment" decision: if a comped friend pays and later cancels, the grant is already gone, so they fall back to the paywall. Acceptable and consistent with that choice.
  • Analytics subscribed_count is left unchanged — comp users are not paying revenue.