Stripe Subscriptions & Connect — Plan

Complexity: High Scope: Two phases — (1) standard subscription paywall + 30-day trial + coupons → all revenue goes to the platform; (2) Stripe Connect Express with split payments via Pay gem for partner revenue share.


Decisions Log (auto-iterated; review later)

These are decisions I made while iterating without you in front of the computer. Adjust as needed:

  1. Phase order. Phase 1 (paywall + trial + coupons, single recipient = platform owner) ships first. Phase 2 (Connect / split payments to partners) is layered on top later. The Connect code already exists and only activates when an affiliate_id is attached to a checkout — it stays inert during Phase 1.
  2. Paywall scope. Sitewide via ApplicationController#require_subscription, gated by Rails.application.credentials.dig(:billing, :paywall_enabled) (default OFF so dev/test/existing users are unaffected). Admins are always exempt. Trialing users are considered subscribed (Pay's subscribed? returns true while in trial).
  3. Trial. 30-day free trial for new customers, applied via Stripe Checkout subscription_data: { trial_period_days: 30 }. No code change to the customer flow — Stripe enforces it.
  4. Coupon for 2-month free trial. Implemented with Stripe Promotion Codes (already enabled via allow_promotion_codes: true). For 2-month free trial codes, create a Stripe Coupon with duration: repeating, duration_in_months: 2, percent_off: 100 and attach Promotion Codes (e.g. LAUNCH60). Users either type the code at checkout OR open /subscriptions/checkout?price_id=...&promo=LAUNCH60 to auto-apply. Admin UI for managing codes is in separate-tasks.md (deferred).
  5. Revenue split percentage. Kept at application_fee_percent: 70 (platform 70 / partner 30). Per-subscription override via SubscriptionAffiliate#application_fee_percent.
  6. Existing Connect code stays. No removal. It only activates when find_affiliate_for_checkout returns an affiliate.

Phase 1 — Standard Subscriptions + Paywall + Trial + Coupons

All revenue goes to the platform Stripe account. No Connect split.

External Setup (Stripe Dashboard)

  1. Products & Prices
    • Create one Product (e.g. "Lifehub Pro").
    • Create two Prices: monthly (price_xxx_month) and yearly (price_xxx_year).
    • Copy the price IDs into config/settings.yml under the appropriate environment (production.plans).
  2. Customer Portal
    • Enable the Customer Portal: Dashboard → Settings → Billing → Customer portal.
    • Allow customers to: update payment method, cancel subscription, switch plans, view invoices.
    • Set the return_url to https://applifehub.com/subscriptions.
  3. Webhooks
    • Endpoint: https://applifehub.com/pay/webhooks/stripe (Pay gem default).
    • Subscribe to: checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, customer.subscription.trial_will_end, invoice.payment_succeeded, invoice.payment_failed.
    • Copy the signing secret into Rails credentials (stripe.signing_secret).
  4. Coupons (for the 2-month free trial codes)
    • Dashboard → Products → Coupons → New.
    • Type: Percent off, value: 100%, duration: Repeating, duration in months: 2.
    • Create a Promotion Code for each campaign (LAUNCH60, FRIEND60, …).

Code Changes

  1. SubscriptionsController#checkout — add to the existing checkout options:
    subscription_data: { trial_period_days: 30 },
    discounts: [{ promotion_code: stripe_promo_code_id }]   # only if params[:promo] is present and resolves
    

    Skip the trial for customers who have ever had a paid subscription (Stripe enforces "one trial per customer" automatically when trial_period_days is set, but we also gate based on current_user.payment_processor.subscriptions.exists?).

  2. ApplicationController — promote the existing require_subscription helper into a sitewide before_action:
    before_action :require_subscription, if: :paywall_enabled?, unless: :paywall_exempt?
    

    Exempt actions: anything under SubscriptionsController, Admin::*, Devise, Users::*, FinanceSettingsController, StaticController, NotificationsController, sign-out flows. Admins are exempt.

  3. Paywall feature flagRails.application.credentials.dig(:billing, :paywall_enabled). Default OFF. When OFF, the app behaves exactly as today.

  4. Promo code resolverAffiliate::PromoResolver (rename to Billing::PromoResolver) looks up Stripe::PromotionCode.list(code: params[:promo]) and returns the promotion_code ID for the checkout session.

  5. Tests
    • SubscriptionsControllerTest: checkout creates a session with trial_period_days: 30, with/without ?promo=, paywall-on redirects, paywall-off allows, admin bypass, billing portal redirect.
    • ApplicationControllerTest: paywall gate.
    • Model test for Billing::PromoResolver.

Verification (Phase 1)

  • Sign up new user → checkout → see "30-day free trial" on Stripe Checkout page.
  • Subscribe with a LAUNCH60 promotion code → 100% off for 2 months on the invoice preview.
  • After paywall enabled in credentials: unsubscribed non-admin hitting /dashboard is redirected to /subscriptions.
  • Trial user can access the dashboard.
  • Admin user can access the dashboard regardless.
  • Cancel via billing portal → access continues until current_period_end, then paywall kicks in.

Phase 2 — Stripe Connect Express (Split Payments)

Most of this is already implemented. Listed here for completeness; activate when partner deals are in place.

External Setup

  1. Stripe Dashboard → Connect → Complete platform profile:
    • Business info, branding, description.
    • Platform type: select appropriately for your use case.
  2. Connect → Settings:
    • Account type: Express (recommended — Stripe hosts onboarding).
    • Set branding (logo, colors, accent color).
    • Configure payout schedule.
  3. Webhooks → Add events to existing endpoint:
    • account.updated, account.application.deauthorized.
  4. Test mode: Use test keys first. Create test connected accounts via Stripe CLI or dashboard.
  5. Fee flow:
    • Customer pays $19 → Stripe fee (~$0.85) → Platform gets ~$12.70 (70%) → Affiliate gets ~$5.45 (30%).

Code Changes (already shipped)

  • Affiliate model + Affiliate::StripeOnboarder + Affiliate::CheckoutSplitter + Affiliate::WebhookHandler.
  • SubscriptionAffiliate join table (Pay::Subscription ↔ Affiliate, with per-row application_fee_percent).
  • AffiliatesController (public onboarding) + Admin::AffiliatesController + Admin::SubscriptionAffiliatesController.
  • SubscriptionsController#checkout merges in Affiliate::CheckoutSplitter#checkout_options when params[:affiliate_id] is present.

Verification (Phase 2)

  • Create test affiliate → complete Stripe Express onboarding (test mode).
  • Create subscription with ?affiliate_id= → verify application_fee in Stripe dashboard.
  • Create subscription without affiliate → no split payment (Phase 1 behavior preserved).
  • Check affiliate's connected account shows incoming transfer.
  • Verify 70/30 math in Stripe payment detail.
  • Test webhook: account.updated → affiliate marked as onboarded.
  • Run bin/ci.