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: true on every Stripe Checkout session — customers can type any active promotion code at checkout.
  • ?promo=CODE URL param → Billing::PromoResolver resolves 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 of next_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 — stubs Stripe::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:

  1. In-app banner in the dashboard layout 7 / 3 / 1 days before trial ends.
  2. Email reminders at day 23 and day 29 of the trial.
  3. 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

  1. 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 %>
    
  2. Add the i18n string in config/locales/en.yml and pt-BR.yml.
  3. 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

  1. Coupons+ New coupon:
    • Type: Percentage discount.
    • Percentage off: 30.
    • Duration: Forever.
    • Apply to: select Specific products → choose only the yearly price.
    • ID: ANNUAL30.
  2. + 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::PromoResolver already handles this.

Tests

  • test/system/subscriptions_test.rb (new) — system test that clicks Subscribe with ?promo=ANNUAL30 and 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:

  1. Read its "Why defer" section — make sure you actually need it now.
  2. Follow the Stripe Dashboard prerequisites if any.
  3. Implement step by step.
  4. Add tests.
  5. Update implementation.md to move the task from "deferred" to "shipped".
  6. Update next_steps.md if any new external setup is required.