Stripe Subscriptions & Connect — Implementation

Scope: Two-phase rollout. Phase 1 (paywall, 30-day trial, coupons) ships first with all revenue routed to the platform. Phase 2 (Connect / split payments to partner affiliates via Pay gem) is already coded and stays inert until an affiliate_id is attached to a checkout. Tests: All passing (0 failures, 0 errors)


Phase 1 — Standard Subscription + Paywall + Trial + Coupons (THIS RELEASE)

All revenue flows to the platform Stripe account. The Connect split path stays dormant.

1.1 Billing config / feature flag

Rails.application.credentials.dig(:billing, :paywall_enabled) — default OFF. When OFF, the app behaves exactly as today; when ON, unsubscribed non-admin users are redirected to /subscriptions for all non-exempt pages.

1.2 app/models/billing/promo_resolver.rb (NEW)

Looks up a Stripe Promotion Code by case-insensitive code string and returns the Stripe promotion_code ID for use in Stripe::Checkout::Session.create(discounts: …).

class Billing::PromoResolver
  def initialize(code)
    @code = code.to_s.strip
  end

  def promotion_code_id
    return if @code.blank?
    return @cached if defined?(@cached)
    list = Stripe::PromotionCode.list(code: @code, active: true, limit: 1)
    @cached = list.data.first&.id
  end
end

1.3 app/controllers/subscriptions_controller.rb (MODIFIED)

  • Adds a 30-day trial via subscription_data: { trial_period_days: 30 } for customers who have never subscribed before.
  • Accepts ?promo=CODE to auto-apply a 2-month free-trial coupon (or any Stripe promotion code).
  • Leaves allow_promotion_codes: true so users can also type a code at Stripe Checkout.
  • Phase 2's affiliate_checkout_options stays wired in and inert when no affiliate is selected.
class SubscriptionsController < ApplicationController
  before_action :require_billing_enabled
  before_action :sync_subscriptions, only: [ :checkout ]

  def index
  end

  def checkout
    return redirect_to subscriptions_url if current_user.payment_processor&.subscribed?

    price = Stripe::Price.retrieve(params[:price_id])
    return redirect_to subscriptions_url if price.nil?

    checkout_options = base_checkout_options(price)
      .merge(trial_options)
      .merge(promo_options)
      .merge(affiliate_checkout_options)

    @checkout_session = current_user.payment_processor.checkout(**checkout_options)
    redirect_to @checkout_session.url, allow_other_host: true, status: :see_other
  end

  def billing_portal
    @portal_session = current_user.payment_processor.billing_portal(return_url: subscriptions_url)
    redirect_to @portal_session.url, allow_other_host: true, status: :see_other
  end

  private

  def base_checkout_options(price)
    {
      mode: "subscription",
      locale: I18n.locale,
      line_items: [ { price: price, quantity: 1 } ],
      allow_promotion_codes: true,
      automatic_tax: { enabled: false },
      tax_id_collection: { enabled: true },
      customer_update: { address: :auto, name: :auto },
      success_url: subscriptions_url,
      cancel_url: subscriptions_url
    }
  end

  def trial_options
    return {} if current_user.payment_processor.subscriptions.any?
    { subscription_data: { trial_period_days: 30 } }
  end

  def promo_options
    promo_id = Billing::PromoResolver.new(params[:promo]).promotion_code_id
    return {} if promo_id.blank?
    { discounts: [ { promotion_code: promo_id } ], allow_promotion_codes: nil }.compact
  end

  def affiliate_checkout_options
    affiliate = find_affiliate_for_checkout
    return {} unless affiliate
    sa = SubscriptionAffiliate.new(affiliate: affiliate, application_fee_percent: 70.0)
    Affiliate::CheckoutSplitter.new(sa).checkout_options
  end

  def find_affiliate_for_checkout
    return unless params[:affiliate_id].present?
    Affiliate.onboarded.find_by(id: params[:affiliate_id])
  end

  def sync_subscriptions
    current_user.set_payment_processor :stripe
    current_user.payment_processor.sync_subscriptions(status: "all") unless Rails.env.test?
  end

  def require_billing_enabled
    redirect_to dashboard_url if Rails.application.credentials.dig(:stripe, :private_key).blank?
  end
end

1.4 app/controllers/application_controller.rb (MODIFIED)

Promotes the existing require_subscription helper into a sitewide gate, controlled by the paywall feature flag:

before_action :require_subscription, if: :paywall_enforced?

private

def paywall_enforced?
  return false unless user_signed_in?
  return false if current_user.admin?
  return false if paywall_exempt_controller?
  Rails.application.credentials.dig(:billing, :paywall_enabled) == true
end

PAYWALL_EXEMPT_CONTROLLERS = %w[
  subscriptions
  static
  finance_settings
  notifications
  rails/health
].freeze

def paywall_exempt_controller?
  return true if controller_path.start_with?("admin/", "users/", "devise/")
  PAYWALL_EXEMPT_CONTROLLERS.include?(controller_path)
end

1.5 Views (MODIFIED)

  • app/views/subscriptions/_pricing.html.erb — Add a "30-day free trial included" badge under the price.
  • app/views/subscriptions/_pricing.html.erb — When the URL contains ?promo=CODE, surface a "Promo applied: CODE" banner.
  • app/views/subscriptions/_pricing.html.erb — Iterate available_plans (locale-filtered) instead of all plans; format prices with BRL conventions when currency is BRL.
  • app/views/subscriptions/_plan.html.erb — Same currency-aware formatting for the current-subscription card.

1.5b Currency-aware plan filtering

app/helpers/subscription_helper.rb gains:

  • available_plans(user) — returns the subset of config/settings.yml plans matching the user's currency. Pt-BR users / BRL setting → BRL plans only; en users / USD setting → USD plans only. Guests default by I18n.locale.
  • preferred_currency_for(user)user.finance_currency if present, otherwise locale-derived (pt-BR → BRL, anything else → USD).
  • currency_symbol(code) — extended with BRL → R$ and falls back to the upcased code for unknown currencies.

config/settings.yml now lists four plan entries (BRL × {month, year} + USD × {month, year}). Each entry MUST have its own unique Stripe Price ID — a Stripe Price has a fixed currency.

1.6 Translations

config/locales/en.yml — add under subscriptions::

subscriptions:
  trial:
    badge: "30-day free trial"
    headline: "Try Lifehub free for 30 days. Cancel anytime."
  promo:
    applied: "Promo code %{code} will be applied at checkout."
    invalid: "That promo code is invalid or expired."
shared:
  errors:
    not_subscribed: "Your trial or subscription has ended. Subscribe to continue."

1.7 Tests (Phase 1)

File Coverage
test/controllers/subscriptions_controller_test.rb (NEW) checkout adds trial_period_days: 30 for first-time customers; skips trial for returning customers; applies promo via ?promo=; redirects when already subscribed; billing portal redirect.
test/controllers/paywall_test.rb (NEW, integration) Paywall OFF (default) → all pages accessible. Paywall ON + unsubscribed non-admin → redirect to /subscriptions. Paywall ON + admin → allowed. Paywall ON + trialing → allowed. Exempt controllers always accessible.
test/models/billing/promo_resolver_test.rb (NEW) Returns the Stripe promotion_code ID; blank for missing / inactive / blank input. Stubs Stripe::PromotionCode.list.

Stub Stripe::Price.retrieve, Stripe::PromotionCode.list, and Pay::Customer#checkout in tests — no live API calls.


Phase 2 — Stripe Connect Express + Split Payments (ALREADY SHIPPED)

Stays inert until params[:affiliate_id] or a SubscriptionAffiliate assignment is in play. Documented here so future readers know it's part of the same system.

2.1 Database Migration

db/migrate/20260228142725_create_affiliates.rb — already applied. Created affiliates and subscription_affiliates tables.

Schema:

  • affiliates: user_id (unique FK), stripe_account_id (unique, nullable), onboarding_completed (default false), display_name (required)
  • subscription_affiliates: pay_subscription_id (unique FK), affiliate_id (FK), application_fee_percent (decimal 5,2, default 70.0)

2.2 Models

  • app/models/affiliate.rbbelongs_to :user, pay_merchant, scopes (onboarded, pending_onboarding), ready_for_payments?
  • app/models/subscription_affiliate.rbbelongs_to :pay_subscription, class_name: "Pay::Subscription", delegates to Affiliate
  • app/models/affiliate/stripe_onboarder.rb — Express account creation, hosted onboarding URL, status sync, dashboard login link
  • app/models/affiliate/checkout_splitter.rb — Builds application_fee_percent + transfer_data for the checkout session when the affiliate is ready
  • app/models/affiliate/webhook_handler.rbstripe.account.updated handler (charges_enabled && details_submitted → onboarding_completed: true)
  • app/models/user.rbhas_one :affiliate, dependent: :destroy

2.3 Controllers

  • app/controllers/affiliates_controller.rbshow / new / create / onboarding / callback / dashboard (public, authenticated users)
  • app/controllers/admin/affiliates_controller.rb — Full CRUD + sync_status
  • app/controllers/admin/subscription_affiliates_controller.rbcreate / update / destroy
  • app/controllers/subscriptions_controller.rbcheckout merges Affiliate::CheckoutSplitter#checkout_options when params[:affiliate_id] resolves

2.4 Views

  • app/views/admin/affiliates/{index,show,new,edit,_form}.html.erb
  • app/views/affiliates/{show,new}.html.erb

2.5 Routes

# Public
resources :affiliates, only: [:show, :new, :create] do
  member do
    get :onboarding
    get :callback
    get :dashboard
  end
end

# Admin
namespace :admin do
  resources :affiliates do
    member { get :sync_status }
  end
  resources :subscription_affiliates, only: [:create, :update, :destroy]
end

2.6 Initializers

  • config/initializers/stripe_connect.rbPay::Webhooks.delegator.subscribe "stripe.account.updated", Affiliate::WebhookHandler.new
  • config/initializers/content_security_policy.rb — adds https://connect.stripe.com to frame_src

2.7 Translations

Affiliate/admin keys live under en.affiliates.* and en.admin.affiliates.* / en.admin.subscription_affiliates.*.


Summary of Files

Phase 1 — New Files

File Purpose
app/models/billing/promo_resolver.rb Resolve Stripe Promotion Code by string → ID
test/controllers/subscriptions_controller_test.rb Subscription checkout / portal coverage
test/controllers/paywall_test.rb Sitewide paywall gate coverage
test/models/billing/promo_resolver_test.rb Promo resolver unit tests

Phase 1 — Modified Files

File Changes
app/controllers/application_controller.rb Sitewide require_subscription gate behind paywall_enabled flag
app/controllers/subscriptions_controller.rb 30-day trial + ?promo= discount application
app/helpers/subscription_helper.rb available_plans, preferred_currency_for, BRL symbol
app/views/subscriptions/_pricing.html.erb Trial badge + promo banner + locale-filtered plans + BRL formatting
app/views/subscriptions/_plan.html.erb BRL/USD-aware price formatting
config/settings.yml Four plan entries (BRL × {month, year}, USD × {month, year}) with one Stripe Price ID each
config/locales/en.yml Trial/promo strings, not_subscribed updated

Phase 2 — Already Shipped (unchanged in this release)

See sections 2.1 – 2.7 above.