Stripe Subscriptions — Separate / Deferred Tasks
Things explicitly carved out of the Phase 1 release so we ship the paywall + 30-day trial faster. Pick these up after Phase 1 stabilizes.
Each task lists what to do, why it matters, the Stripe Dashboard steps if any, and the code that needs to change.
1. Coupon Admin UI (2-Month Free Trial Codes)
What we have today (Phase 1)
allow_promotion_codes: trueon every Stripe Checkout session — customers can type any active promotion code at checkout.?promo=CODEURL param →Billing::PromoResolverresolves the code to a Stripe promotion_code ID and auto-attaches the discount.- The actual 2-month free-trial coupon and its promotion codes (
LAUNCH60,FRIEND60, etc.) are created manually in the Stripe Dashboard following section 1.8 ofnext_steps.md.
Why defer this
Stripe's Coupons/Promotion Codes UI already does the job — you don't need an in-app UI to ship Phase 1. We defer the in-app version because:
- It's only useful once you're running multiple campaigns and want non-developers to manage codes without Stripe Dashboard access.
- It needs CRUD, sync, and reporting — non-trivial work for a feature you might never use heavily.
When to pick this up
- You start running launch / referral / partner campaigns and find yourself in the Stripe Dashboard daily.
- You want non-admin staff (e.g. a marketing person) to create codes without a Stripe login.
- You want code-redemption stats visible inside Lifehub without exporting from Stripe.
Step-by-step implementation plan
Step 1: Migration
Create db/migrate/<timestamp>_create_billing_promotion_codes.rb:
class CreateBillingPromotionCodes < ActiveRecord::Migration[8.1]
def change
create_table :billing_promotion_codes do |t|
t.string :code, null: false # e.g. "LAUNCH60"
t.string :stripe_coupon_id # e.g. "TWO_MONTHS_FREE"
t.string :stripe_promotion_code_id # e.g. "promo_1QABCxyz"
t.string :description
t.string :campaign_tag # e.g. "launch", "creator-partner"
t.integer :duration_in_months, default: 2, null: false
t.integer :percent_off, default: 100, null: false
t.integer :max_redemptions
t.integer :redeemed_count, default: 0, null: false
t.datetime :expires_at
t.boolean :active, default: true, null: false
t.timestamps
end
add_index :billing_promotion_codes, :code, unique: true
add_index :billing_promotion_codes, :stripe_promotion_code_id, unique: true
add_index :billing_promotion_codes, :campaign_tag
end
end
Run bin/rails db:migrate.
Step 2: Model
app/models/billing/promotion_code.rb:
class Billing::PromotionCode < ApplicationRecord
self.table_name = "billing_promotion_codes"
validates :code, presence: true, uniqueness: { case_sensitive: false }
validates :duration_in_months, numericality: { greater_than: 0, less_than_or_equal_to: 12 }
validates :percent_off, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
scope :active, -> { where(active: true) }
scope :inactive, -> { where(active: false) }
def redemption_rate
return 0 if max_redemptions.blank? || max_redemptions.zero?
(redeemed_count.to_f / max_redemptions * 100).round
end
def share_url(price_id)
Rails.application.routes.url_helpers.subscriptions_checkout_url(
price_id: price_id, promo: code, host: "applifehub.com"
)
end
end
Step 3: Sync wrapper
app/models/billing/promotion_code_syncer.rb — creates the coupon + promotion code in Stripe when a local row is created, and updates redemption counts on demand.
class Billing::PromotionCodeSyncer
def initialize(promotion_code)
@promotion_code = promotion_code
end
def create!
coupon = Stripe::Coupon.create(
duration: "repeating",
duration_in_months: @promotion_code.duration_in_months,
percent_off: @promotion_code.percent_off,
name: "#{@promotion_code.duration_in_months}-month #{@promotion_code.percent_off}% off"
)
promo = Stripe::PromotionCode.create(
coupon: coupon.id,
code: @promotion_code.code,
max_redemptions: @promotion_code.max_redemptions,
expires_at: @promotion_code.expires_at&.to_i
)
@promotion_code.update!(
stripe_coupon_id: coupon.id,
stripe_promotion_code_id: promo.id
)
end
def refresh!
return unless @promotion_code.stripe_promotion_code_id.present?
promo = Stripe::PromotionCode.retrieve(@promotion_code.stripe_promotion_code_id)
@promotion_code.update!(
redeemed_count: promo.times_redeemed,
active: promo.active
)
end
def deactivate!
return unless @promotion_code.stripe_promotion_code_id.present?
Stripe::PromotionCode.update(@promotion_code.stripe_promotion_code_id, active: false)
@promotion_code.update!(active: false)
end
end
Step 4: Update Billing::PromoResolver to prefer local rows
Modify app/models/billing/promo_resolver.rb to check the local table first:
def promotion_code_id
return if @code.blank?
local = Billing::PromotionCode.active.find_by("LOWER(code) = ?", @code.downcase)
return local.stripe_promotion_code_id if local&.stripe_promotion_code_id.present?
# fall back to live Stripe lookup
lookup&.id
end
Step 5: Admin controller + routes
app/controllers/admin/promotion_codes_controller.rb:
class Admin::PromotionCodesController < Admin::BaseController
before_action :set_promotion_code, only: %i[show edit update destroy sync deactivate]
def index
@promotion_codes = Billing::PromotionCode.order(created_at: :desc)
end
def show
end
def new
@promotion_code = Billing::PromotionCode.new
end
def create
@promotion_code = Billing::PromotionCode.new(promotion_code_params)
if @promotion_code.save
Billing::PromotionCodeSyncer.new(@promotion_code).create!
redirect_to admin_promotion_codes_path, notice: t("admin.promotion_codes.created")
else
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @promotion_code.update(promotion_code_params)
redirect_to admin_promotion_codes_path, notice: t("admin.promotion_codes.updated")
else
render :edit, status: :unprocessable_entity
end
end
def sync
Billing::PromotionCodeSyncer.new(@promotion_code).refresh!
redirect_to admin_promotion_code_path(@promotion_code), notice: t("admin.promotion_codes.synced")
end
def deactivate
Billing::PromotionCodeSyncer.new(@promotion_code).deactivate!
redirect_to admin_promotion_codes_path, notice: t("admin.promotion_codes.deactivated")
end
private
def set_promotion_code
@promotion_code = Billing::PromotionCode.find(params[:id])
end
def promotion_code_params
params.expect(promotion_code: [
:code, :description, :campaign_tag,
:duration_in_months, :percent_off, :max_redemptions, :expires_at
])
end
end
config/routes.rb — add inside the namespace :admin do … end block:
resources :promotion_codes do
member do
post :sync
post :deactivate
end
end
Step 6: Admin views
Mirror the existing admin/affiliates views. Minimum needed:
app/views/admin/promotion_codes/index.html.erb— table: code, campaign, redemptions, max, status, "Copy share URL" button (per plan), Edit / Deactivate links.app/views/admin/promotion_codes/new.html.erb+_form.html.erb— fields: code, description, campaign_tag, duration_in_months (default 2), percent_off (default 100), max_redemptions, expires_at.app/views/admin/promotion_codes/show.html.erb— detail + sync button + share URLs.
Step 7: Stripe Dashboard prerequisites
None new — the admin UI creates coupons + promo codes in Stripe via API. You still need:
- Stripe credentials (
stripe.private_key) in place (already done for Phase 1). - Test mode toggled appropriately when testing.
Step 8: Tests
test/models/billing/promotion_code_test.rb— validations, scopes,redemption_rate,share_url.test/models/billing/promotion_code_syncer_test.rb— stubsStripe::Coupon.create,Stripe::PromotionCode.create,Stripe::PromotionCode.retrieve,Stripe::PromotionCode.update. Asserts the local row is updated.test/controllers/admin/promotion_codes_controller_test.rb— admin auth gate, index/show/create/sync/deactivate happy paths.
2. Trial Expiration UX
What it is
Three layers of "your trial is ending" messaging:
- In-app banner in the dashboard layout 7 / 3 / 1 days before trial ends.
- Email reminders at day 23 and day 29 of the trial.
- Soft paywall after trial expires — 7 days of read-only access to financial data before the hard paywall kicks in.
Why defer
The default flow (Pay redirects to /subscriptions when the subscription ends) works. Soft paywall is a conversion-optimization layer, valuable but not essential.
Step-by-step implementation plan
Step 1: Trial banner partial
Create app/views/shared/_trial_banner.html.erb:
<% if current_user.payment_processor&.subscription&.on_trial? %>
<% days_left = ((current_user.payment_processor.subscription.trial_ends_at - Time.current) / 1.day).ceil %>
<% if days_left <= 7 %>
<div class="rounded-lg p-4 mb-4 <%= days_left <= 1 ? 'bg-red-50 dark:bg-red-900/30' : 'bg-amber-50 dark:bg-amber-900/30' %>">
<span class="font-semibold"><%= t("subscriptions.trial.ending_in", count: days_left) %></span>
<%= link_to t("subscriptions.trial.add_payment"), subscriptions_path, class: "underline ml-2" %>
</div>
<% end %>
<% end %>
Include it in app/views/layouts/application.html.erb near the flash.
Step 2: Email reminder hook
Pay already emits customer.subscription.trial_will_end 3 days before trial end. Wire it via the Pay webhook delegator.
config/initializers/pay.rb (or add to the existing stripe_connect.rb):
Pay::Webhooks.delegator.subscribe "stripe.customer.subscription.trial_will_end", Billing::TrialReminderHandler.new
app/models/billing/trial_reminder_handler.rb:
class Billing::TrialReminderHandler
def call(event)
sub = event.data.object
customer = Pay::Customer.find_by(processor: :stripe, processor_id: sub.customer)
return unless customer
BillingMailer.trial_ending(customer.owner).deliver_later
end
end
app/mailers/billing_mailer.rb:
class BillingMailer < ApplicationMailer
def trial_ending(user)
@user = user
@subscriptions_url = subscriptions_url
mail(to: user.email, subject: t("billing_mailer.trial_ending.subject"))
end
end
Step 3: Soft paywall (optional)
In ApplicationController#paywall_enforced?, extend the check:
def paywall_state_for(user)
return :ok if user.payment_processor.subscribed?
last_sub = user.payment_processor&.subscriptions&.order(ends_at: :desc)&.first
return :hard unless last_sub
if last_sub.ends_at && last_sub.ends_at > 7.days.ago
:soft # read-only access
else
:hard # full redirect
end
end
Then in feature controllers, check paywall_state_for(current_user) and:
:soft→ render index actions, but hide "Create / Edit / Delete" buttons.:hard→ existing redirect behavior.
Step 4: Tests
test/models/billing/trial_reminder_handler_test.rb— stubs the event, asserts mail enqueued.test/mailers/billing_mailer_test.rb— renders the email.test/controllers/paywall_test.rb— extend with soft-paywall scenarios.
Stripe Dashboard prerequisites
Make sure customer.subscription.trial_will_end is in the webhook events list (step 1.6.6 of next_steps.md — already included).
3. Self-Serve Plan Switch
What it is
A "Switch to yearly (save 30%)" button on /subscriptions for customers currently on monthly.
Why defer
The Customer Portal already handles plan switching — customers can click "Manage Billing" → switch plans. This task is just about reducing clicks.
Implementation plan
- In
app/views/subscriptions/_plan.html.erb, detect the current plan's interval and show a CTA only when on monthly:<% if plan_for(subscription)[:interval] == "month" %> <% yearly = Rails.application.config_for(:settings)[:plans].find { |p| p[:interval] == "year" } %> <%= button_to t("subscriptions.switch_to_yearly"), subscriptions_billing_portal_path, ... %> <% end %> - Add the i18n string in
config/locales/en.ymlandpt-BR.yml. - Test:
test/controllers/subscriptions_controller_test.rb— render the plan view for a monthly subscriber and assert the button is present.
Stripe Dashboard prerequisites
- The Customer Portal must already allow plan switching (Phase 1 step 1.5 "Subscription updates: toggle ON Allow customers to switch plans"). ✅
4. Annual Discount Coupon (Separate from Free Trial)
What it is
A permanent code (ANNUAL30) that gives 30% off the yearly plan forever. Published on the pricing page as a sticky offer.
Stripe Dashboard steps
- Coupons → + New coupon:
- Type: Percentage discount.
- Percentage off:
30. - Duration: Forever.
- Apply to: select Specific products → choose only the yearly price.
- ID:
ANNUAL30.
- + Create promotion code on the coupon → code =
ANNUAL30, no expiry.
Code changes
- In
app/views/subscriptions/_pricing.html.erb, on the yearly column add a "Use code ANNUAL30 for 30% off" hint with a copy-to-clipboard button. - No model changes needed —
Billing::PromoResolveralready handles this.
Tests
test/system/subscriptions_test.rb(new) — system test that clicks Subscribe with?promo=ANNUAL30and asserts the promo banner.
5. Webhook-Driven Audit Log
What it is
A table that records every Pay webhook event we care about, for debugging "why was this user charged?".
Why defer
Pay already stores raw events in pay_webhooks. Until you actually have a "why was this user charged" mystery, you don't need a separate projection.
Implementation plan
Migration
class CreateBillingEvents < ActiveRecord::Migration[8.1]
def change
create_table :billing_events do |t|
t.references :user, foreign_key: true
t.string :event_type, null: false # e.g. "invoice.payment_succeeded"
t.string :processor_id, null: false # Stripe event ID
t.json :payload
t.datetime :processed_at
t.timestamps
end
add_index :billing_events, :processor_id, unique: true
add_index :billing_events, [ :user_id, :created_at ]
end
end
Subscribe to events via Pay's delegator
In a new initializer:
%w[
stripe.invoice.payment_succeeded
stripe.invoice.payment_failed
stripe.customer.subscription.created
stripe.customer.subscription.updated
stripe.customer.subscription.deleted
].each do |event_name|
Pay::Webhooks.delegator.subscribe event_name, Billing::EventRecorder.new
end
app/models/billing/event_recorder.rb writes a row per event.
Admin view
/admin/billing_events — paginated table, filterable by user + event type.
Stripe Dashboard prerequisites
None — events are already coming through the existing webhook endpoint.
How to pick one of these up
For each task:
- Read its "Why defer" section — make sure you actually need it now.
- Follow the Stripe Dashboard prerequisites if any.
- Implement step by step.
- Add tests.
- Update
implementation.mdto move the task from "deferred" to "shipped". - Update
next_steps.mdif any new external setup is required.