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_idis 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=CODEto auto-apply a 2-month free-trial coupon (or any Stripe promotion code). - Leaves
allow_promotion_codes: trueso users can also type a code at Stripe Checkout. - Phase 2's
affiliate_checkout_optionsstays 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— Iterateavailable_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 ofconfig/settings.ymlplans matching the user's currency. Pt-BR users /BRLsetting → BRL plans only; en users /USDsetting → USD plans only. Guests default byI18n.locale.preferred_currency_for(user)—user.finance_currencyif present, otherwise locale-derived (pt-BR → BRL, anything else → USD).currency_symbol(code)— extended withBRL → 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 aSubscriptionAffiliateassignment 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.rb—belongs_to :user,pay_merchant, scopes (onboarded,pending_onboarding),ready_for_payments?app/models/subscription_affiliate.rb—belongs_to :pay_subscription, class_name: "Pay::Subscription", delegates toAffiliateapp/models/affiliate/stripe_onboarder.rb— Express account creation, hosted onboarding URL, status sync, dashboard login linkapp/models/affiliate/checkout_splitter.rb— Buildsapplication_fee_percent+transfer_datafor the checkout session when the affiliate is readyapp/models/affiliate/webhook_handler.rb—stripe.account.updatedhandler (charges_enabled && details_submitted →onboarding_completed: true)app/models/user.rb—has_one :affiliate, dependent: :destroy
2.3 Controllers
app/controllers/affiliates_controller.rb—show / new / create / onboarding / callback / dashboard(public, authenticated users)app/controllers/admin/affiliates_controller.rb— Full CRUD +sync_statusapp/controllers/admin/subscription_affiliates_controller.rb—create / update / destroyapp/controllers/subscriptions_controller.rb—checkoutmergesAffiliate::CheckoutSplitter#checkout_optionswhenparams[:affiliate_id]resolves
2.4 Views
app/views/admin/affiliates/{index,show,new,edit,_form}.html.erbapp/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.rb—Pay::Webhooks.delegator.subscribe "stripe.account.updated", Affiliate::WebhookHandler.newconfig/initializers/content_security_policy.rb— addshttps://connect.stripe.comtoframe_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.