1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-05 05:25:24 +02:00

Subscription tests and domain (#2209)

* Save work

* Subscriptions and trials domain

* Store family ID on customer

* Remove indirection of stripe calls

* Test simplifications

* Update brakeman

* Fix stripe tests in CI

* Update billing page to show subscription details

* Remove legacy columns

* Complete billing settings page

* Fix hardcoded plan name

* Handle subscriptions for self hosting mode

* Lint fixes
This commit is contained in:
Zach Gollwitzer 2025-05-06 14:05:21 -04:00 committed by GitHub
parent 8c10e87387
commit 5da4bb6dc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1041 additions and 233 deletions

View file

@ -7,7 +7,6 @@ class AccountableSparklinesController < ApplicationController
.where(accountable_type: @accountable.name)
.balance_series(
currency: family.currency,
timezone: family.timezone,
favorable_direction: @accountable.favorable_direction
)
end

View file

@ -3,24 +3,19 @@ module Onboardable
included do
before_action :require_onboarding_and_upgrade
helper_method :subscription_pending?
end
private
# A subscription goes into "pending" mode immediately after checkout, but before webhooks are processed.
def subscription_pending?
subscribed_at = Current.session.subscribed_at
subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago
end
# First, we require onboarding, then once that's complete, we require an upgrade for non-subscribed users.
def require_onboarding_and_upgrade
return unless Current.user
return unless redirectable_path?(request.path)
if !Current.user.onboarded?
if Current.user.needs_onboarding?
redirect_to onboarding_path
elsif !Current.family.subscribed? && !Current.family.trialing? && !self_hosted?
elsif Current.family.needs_subscription?
redirect_to trial_onboarding_path
elsif Current.family.upgrade_required?
redirect_to upgrade_subscription_path
end
end

View file

@ -2,6 +2,6 @@ class Settings::BillingsController < ApplicationController
layout "settings"
def show
@user = Current.user
@family = Current.family
end
end

View file

@ -4,49 +4,40 @@ class SubscriptionsController < ApplicationController
# Upgrade page for unsubscribed users
def upgrade
render layout: "onboardings"
end
def start_trial
unless Current.family.trialing?
ActiveRecord::Base.transaction do
Current.user.update!(onboarded_at: Time.current)
Current.family.update!(trial_started_at: Time.current)
end
if Current.family.subscription&.active?
redirect_to root_path, notice: "You are already subscribed."
else
@plan = params[:plan] || "annual"
render layout: "onboardings"
end
redirect_to root_path, notice: "Welcome to Maybe!"
end
def new
price_map = {
monthly: ENV["STRIPE_MONTHLY_PRICE_ID"],
annual: ENV["STRIPE_ANNUAL_PRICE_ID"]
}
price_id = price_map[(params[:plan] || :monthly).to_sym]
unless Current.family.existing_customer?
customer = stripe.create_customer(
email: Current.family.primary_user.email,
metadata: { family_id: Current.family.id }
)
Current.family.update(stripe_customer_id: customer.id)
end
checkout_session_url = stripe.get_checkout_session_url(
price_id: price_id,
customer_id: Current.family.stripe_customer_id,
checkout_session = stripe.create_checkout_session(
plan: params[:plan],
family_id: Current.family.id,
family_email: Current.family.billing_email,
success_url: success_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url: upgrade_subscription_url(plan: params[:plan])
cancel_url: upgrade_subscription_url
)
redirect_to checkout_session_url, allow_other_host: true, status: :see_other
Current.family.update!(stripe_customer_id: checkout_session.customer_id)
redirect_to checkout_session.url, allow_other_host: true, status: :see_other
end
# Only used for managing our "offline" trials. Paid subscriptions are handled in success callback of checkout session
def create
if Current.family.can_start_trial?
Current.family.start_trial_subscription!
redirect_to root_path, notice: "Welcome to Maybe!"
else
redirect_to root_path, alert: "You have already started or completed a trial. Please upgrade to continue."
end
end
def show
portal_session_url = stripe.get_billing_portal_session_url(
portal_session_url = stripe.create_billing_portal_session_url(
customer_id: Current.family.stripe_customer_id,
return_url: settings_billing_url
)
@ -54,12 +45,16 @@ class SubscriptionsController < ApplicationController
redirect_to portal_session_url, allow_other_host: true, status: :see_other
end
# Stripe redirects here after a successful checkout session and passes the session ID in the URL
def success
checkout_session = stripe.retrieve_checkout_session(params[:session_id])
Current.session.update(subscribed_at: Time.at(checkout_session.created))
redirect_to root_path, notice: "You have successfully subscribed to Maybe+."
rescue Stripe::InvalidRequestError
redirect_to settings_billing_path, alert: "Something went wrong processing your subscription. Please contact us to get this fixed."
checkout_result = stripe.get_checkout_result(params[:session_id])
if checkout_result.success?
Current.family.start_subscription!(checkout_result.subscription_id)
redirect_to root_path, notice: "Welcome to Maybe! Your subscription has been created."
else
redirect_to root_path, alert: "Something went wrong processing your subscription. Please contact us to get this fixed."
end
end
private

View file

@ -44,9 +44,11 @@ class WebhooksController < ApplicationController
head :ok
rescue JSON::ParserError => error
Sentry.capture_exception(error)
Rails.logger.error "JSON parser error: #{error.message}"
head :bad_request
rescue Stripe::SignatureVerificationError => error
Sentry.capture_exception(error)
Rails.logger.error "Stripe signature verification error: #{error.message}"
head :bad_request
end
end

View file

@ -2,7 +2,7 @@ module Account::Chartable
extend ActiveSupport::Concern
class_methods do
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance, interval: nil, timezone: nil)
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance, interval: nil)
raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym)
series_interval = interval || period.interval
@ -132,8 +132,7 @@ module Account::Chartable
period: period,
view: view,
interval: interval,
favorable_direction: favorable_direction,
timezone: family.timezone
favorable_direction: favorable_direction
)
end

View file

@ -33,7 +33,7 @@ class Assistant::Function::GetAccounts < Assistant::Function
def historical_balances(account)
start_date = [ account.start_date, 5.years.ago.to_date ].max
period = Period.custom(start_date: start_date, end_date: Date.current)
balance_series = account.balance_series(period: period, interval: "1 month", timezone: family.timezone)
balance_series = account.balance_series(period: period, interval: "1 month")
to_ai_time_series(balance_series)
end

View file

@ -54,8 +54,7 @@ class Assistant::Function::GetBalanceSheet < Assistant::Function
currency: family.currency,
period: period,
interval: "1 month",
favorable_direction: "up",
timezone: family.timezone
favorable_direction: "up"
)
to_ai_time_series(balance_series)

View file

@ -69,7 +69,7 @@ class BalanceSheet
end
def net_worth_series(period: Period.last_30_days)
active_accounts.balance_series(currency: currency, period: period, favorable_direction: "up", timezone: family.timezone)
active_accounts.balance_series(currency: currency, period: period, favorable_direction: "up")
end
def currency

View file

@ -160,13 +160,14 @@ class Demo::Generator
id: id,
name: family_name,
currency: currency,
stripe_subscription_status: require_onboarding ? nil : "active",
locale: "en",
country: "US",
timezone: "America/New_York",
date_format: "%m-%d-%Y"
)
family.start_subscription!("sub_1234567890")
family.users.create! \
email: user_email,
first_name: "Demo",

View file

@ -1,5 +1,5 @@
class Family < ApplicationRecord
include Syncable, AutoTransferMatchable
include Syncable, AutoTransferMatchable, Subscribeable
DATE_FORMATS = [
[ "MM-DD-YYYY", "%m-%d-%Y" ],
@ -68,6 +68,9 @@ class Family < ApplicationRecord
def sync_data(sync, start_date: nil)
update!(last_synced_at: Time.current)
# We don't rely on this value to guard the app, but keep it eventually consistent
sync_trial_status!
Rails.logger.info("Syncing accounts for family #{id}")
accounts.manual.each do |account|
account.sync_later(start_date: start_date, parent_sync: sync)
@ -127,22 +130,6 @@ class Family < ApplicationRecord
).link_token
end
def subscribed?
stripe_subscription_status == "active"
end
def trialing?
!subscribed? && trial_started_at.present? && trial_started_at <= 14.days.from_now
end
def trial_remaining_days
(14 - (Time.current - trial_started_at).to_i / 86400).to_i
end
def existing_customer?
stripe_customer_id.present?
end
def requires_data_provider?
# If family has any trades, they need a provider for historical prices
return true if trades.any?
@ -162,18 +149,10 @@ class Family < ApplicationRecord
requires_data_provider? && Provider::Registry.get_provider(:synth).nil?
end
def primary_user
users.order(:created_at).first
end
def oldest_entry_date
entries.order(:date).first&.date || Date.current
end
def active_accounts_count
accounts.active.count
end
# Cache key that is invalidated when any of the family's entries are updated (which affect rollups and other calculations)
def build_cache_key(key)
[

View file

@ -0,0 +1,81 @@
module Family::Subscribeable
extend ActiveSupport::Concern
included do
has_one :subscription, dependent: :destroy
end
def billing_email
primary_admin = users.admin.order(:created_at).first
unless primary_admin.present?
raise "No primary admin found for family #{id}. This is an invalid data state and should never occur."
end
primary_admin.email
end
def upgrade_required?
return false if self_hoster?
return false if subscription&.active? || subscription&.trialing?
true
end
def can_start_trial?
subscription&.trial_ends_at.blank?
end
def start_trial_subscription!
create_subscription!(
status: "trialing",
trial_ends_at: Subscription.new_trial_ends_at
)
end
def trialing?
subscription&.trialing? && days_left_in_trial.positive?
end
def has_active_subscription?
subscription&.active?
end
def needs_subscription?
subscription.nil? && !self_hoster?
end
def next_billing_date
subscription&.current_period_ends_at
end
def start_subscription!(stripe_subscription_id)
if subscription.present?
subscription.update!(status: "active", stripe_id: stripe_subscription_id)
else
create_subscription!(status: "active", stripe_id: stripe_subscription_id)
end
end
def days_left_in_trial
return -1 unless subscription.present?
((subscription.trial_ends_at - Time.current).to_i / 86400) + 1
end
def percentage_of_trial_remaining
return 0 unless subscription.present?
(days_left_in_trial.to_f / Subscription::TRIAL_DAYS) * 100
end
def percentage_of_trial_completed
return 0 unless subscription.present?
(1 - days_left_in_trial.to_f / Subscription::TRIAL_DAYS) * 100
end
private
def sync_trial_status!
if subscription&.status == "trialing" && days_left_in_trial < 0
subscription.update!(status: "paused")
end
end
end

View file

@ -1,9 +1,8 @@
class Provider::Stripe
Error = Class.new(StandardError)
def initialize(secret_key:, webhook_secret:)
@client = Stripe::StripeClient.new(
secret_key,
stripe_version: "2025-04-30.basil"
)
@client = Stripe::StripeClient.new(secret_key)
@webhook_secret = webhook_secret
end
@ -12,56 +11,77 @@ class Provider::Stripe
case event.type
when /^customer\.subscription\./
SubscriptionEventProcessor.new(event: event, client: client).process
when /^customer\./
CustomerEventProcessor.new(event: event, client: client).process
SubscriptionEventProcessor.new(event).process
else
Rails.logger.info "Unhandled event type: #{event.type}"
Rails.logger.warn "Unhandled event type: #{event.type}"
end
end
def process_webhook_later(webhook_body, sig_header)
thin_event = client.parse_thin_event(webhook_body, sig_header, webhook_secret)
if thin_event.type.start_with?("customer.")
StripeEventHandlerJob.perform_later(thin_event.id)
else
Rails.logger.info "Unhandled event type: #{thin_event.type}"
end
StripeEventHandlerJob.perform_later(thin_event.id)
end
def create_customer(email:, metadata: {})
client.v1.customers.create(
email: email,
metadata: metadata
def create_checkout_session(plan:, family_id:, family_email:, success_url:, cancel_url:)
customer = client.v1.customers.create(
email: family_email,
metadata: {
family_id: family_id
}
)
end
def get_checkout_session_url(price_id:, customer_id: nil, success_url: nil, cancel_url: nil)
client.v1.checkout.sessions.create(
customer: customer_id,
line_items: [ { price: price_id, quantity: 1 } ],
session = client.v1.checkout.sessions.create(
customer: customer.id,
line_items: [ { price: price_id_for(plan), quantity: 1 } ],
mode: "subscription",
allow_promotion_codes: true,
success_url: success_url,
cancel_url: cancel_url
).url
)
NewCheckoutSession.new(url: session.url, customer_id: customer.id)
end
def get_billing_portal_session_url(customer_id:, return_url: nil)
def get_checkout_result(session_id)
session = client.v1.checkout.sessions.retrieve(session_id)
unless session.status == "complete" && session.payment_status == "paid"
raise Error, "Checkout session not complete"
end
CheckoutSessionResult.new(success?: true, subscription_id: session.subscription)
rescue StandardError => e
Sentry.capture_exception(e)
Rails.logger.error "Error fetching checkout result for session #{session_id}: #{e.message}"
CheckoutSessionResult.new(success?: false, subscription_id: nil)
end
def create_billing_portal_session_url(customer_id:, return_url:)
client.v1.billing_portal.sessions.create(
customer: customer_id,
return_url: return_url
).url
end
def retrieve_checkout_session(session_id)
client.v1.checkout.sessions.retrieve(session_id)
def update_customer_metadata(customer_id:, metadata:)
client.v1.customers.update(customer_id, metadata: metadata)
end
private
attr_reader :client, :webhook_secret
NewCheckoutSession = Data.define(:url, :customer_id)
CheckoutSessionResult = Data.define(:success?, :subscription_id)
def price_id_for(plan)
prices = {
monthly: ENV["STRIPE_MONTHLY_PRICE_ID"],
annual: ENV["STRIPE_ANNUAL_PRICE_ID"]
}
prices[plan.to_sym || :monthly]
end
def retrieve_event(event_id)
client.v1.events.retrieve(event_id)
end

View file

@ -1,20 +0,0 @@
class Provider::Stripe::CustomerEventProcessor < Provider::Stripe::EventProcessor
Error = Class.new(StandardError)
def process
raise Error, "Family not found for Stripe customer ID: #{customer_id}" unless family
family.update(
stripe_customer_id: customer_id
)
end
private
def family
Family.find_by(stripe_customer_id: customer_id)
end
def customer_id
event_data.id
end
end

View file

@ -1,7 +1,6 @@
class Provider::Stripe::EventProcessor
def initialize(event:, client:)
def initialize(event)
@event = event
@client = client
end
def process
@ -9,7 +8,7 @@ class Provider::Stripe::EventProcessor
end
private
attr_reader :event, :client
attr_reader :event
def event_data
event.data.object

View file

@ -2,28 +2,28 @@ class Provider::Stripe::SubscriptionEventProcessor < Provider::Stripe::EventProc
Error = Class.new(StandardError)
def process
raise Error, "Family not found for Stripe customer ID: #{customer_id}" unless family
raise Error, "Family not found for Stripe customer ID: #{subscription.customer}" unless family
family.update(
stripe_plan_id: plan_id,
stripe_subscription_status: subscription_status
family.subscription.update(
stripe_id: subscription.id,
status: subscription.status,
interval: subscription_details.plan.interval,
amount: subscription_details.plan.amount / 100.0, # Stripe returns cents, we report dollars
currency: subscription_details.plan.currency.upcase,
current_period_ends_at: Time.at(subscription_details.current_period_end)
)
end
private
def family
Family.find_by(stripe_customer_id: customer_id)
Family.find_by(stripe_customer_id: subscription.customer)
end
def customer_id
event_data.customer
def subscription_details
event_data.items.data.first
end
def plan_id
event_data.plan.id
end
def subscription_status
event_data.status
def subscription
event_data
end
end

View file

@ -0,0 +1,37 @@
class Subscription < ApplicationRecord
TRIAL_DAYS = 14
belongs_to :family
# https://docs.stripe.com/api/subscriptions/object
enum :status, {
incomplete: "incomplete",
incomplete_expired: "incomplete_expired",
trialing: "trialing", # We use this, but "offline" (no through Stripe's interface)
active: "active",
past_due: "past_due",
canceled: "canceled",
unpaid: "unpaid",
paused: "paused"
}
validates :stripe_id, presence: true, if: :active?
validates :trial_ends_at, presence: true, if: :trialing?
class << self
def new_trial_ends_at
TRIAL_DAYS.days.from_now
end
end
def name
case interval
when "month"
"Monthly Plan"
when "year"
"Annual Plan"
else
"Free trial"
end
end
end

View file

@ -156,6 +156,10 @@ class User < ApplicationRecord
onboarded_at.present?
end
def needs_onboarding?
!onboarded?
end
private
def ensure_valid_profile_image
return unless profile_image.attached?

View file

@ -89,7 +89,7 @@
<div class="flex items-start justify-between">
<div>
<p class="text-sm font-medium text-primary">Free trial</p>
<p class="text-sm text-secondary"><%= Current.family.trial_remaining_days %> days remaining</p>
<p class="text-sm text-secondary"><%= Current.family.days_left_in_trial %> days remaining</p>
</div>
<%= render LinkComponent.new(
@ -99,8 +99,8 @@
</div>
<div class="flex items-center gap-0.5 h-1.5">
<div class="h-full bg-warning rounded-full" style="width: <%= 100 - Current.family.trial_remaining_days / 14.0 * 100 %>%"></div>
<div class="h-full bg-surface-inset rounded-full" style="width: <%= Current.family.trial_remaining_days / 14.0 * 100 %>%"></div>
<div class="h-full bg-warning rounded-full" style="width: <%= Current.family.percentage_of_trial_completed %>%"></div>
<div class="h-full bg-surface-inset rounded-full" style="width: <%= Current.family.percentage_of_trial_remaining %>%"></div>
</div>
</div>
<% end %>

View file

@ -22,10 +22,7 @@
<%= form_with model: @user do |form| %>
<%= form.hidden_field :redirect_to, value: self_hosted? ? "home" : "trial" %>
<%= form.hidden_field :set_onboarding_goals_at, value: Time.current %>
<% if self_hosted? %>
<%= form.hidden_field :onboarded_at, value: Time.current %>
<% end %>
<%= form.hidden_field :onboarded_at, value: Time.current %>
<div class="space-y-3">
<% [

View file

@ -29,12 +29,26 @@
</p>
<div class="w-full">
<%= render ButtonComponent.new(
<% if Current.family.can_start_trial? %>
<%= render ButtonComponent.new(
text: "Try Maybe for 14 days",
href: start_trial_subscription_path,
href: subscription_path,
full_width: true,
data: { turbo: false }
) %>
<% elsif Current.family.trialing? %>
<%= render LinkComponent.new(
text: "Continue trial",
href: root_path,
full_width: true,
) %>
<% else %>
<%= render LinkComponent.new(
text: "Upgrade",
href: upgrade_subscription_path,
full_width: true,
) %>
<% end %>
</div>
</div>

View file

@ -26,13 +26,13 @@
size: "sm",
as_button: true,
data: { action: "rule--actions#remove", rule__actions_destroy_param: action.persisted? }) %>
<%# Templates for different input types - these will be cloned and used by the Stimulus controller %>
<template data-rule--actions-target="selectTemplate">
<span class="font-medium uppercase text-xs">to</span>
<%= form.select :value, [], {} %>
</template>
<template data-rule--actions-target="textTemplate">
<span class="font-medium uppercase text-xs">to</span>
<%= form.text_field :value, placeholder: "Enter a value" %>

View file

@ -11,19 +11,21 @@
) %>
<div class="text-sm space-y-1">
<% if subscription_pending? %>
<% if @family.has_active_subscription? %>
<p class="text-primary">
Your subscription is pending. You can still use Maybe+ while we process your subscription.
<span>You are currently subscribed to the <span class="font-medium"><%= @family.subscription.name %></span>.</span>
<% if @family.next_billing_date %>
<span>Your plan renews on <span class="font-medium"><%= @family.next_billing_date.strftime("%B %d, %Y") %></span>.</span>
<% end %>
</p>
<% elsif @user.family.trialing? %>
<% elsif @family.trialing? %>
<p class="text-primary">
You are currently trialing <span class="font-medium">Maybe+</span>
You are currently trialing Maybe
<span class="text-secondary">
(<%= @user.family.trial_remaining_days %> days remaining)
(<%= @family.days_left_in_trial %> days remaining)
</span>
</p>
<% elsif @user.family.subscribed? %>
<p class="text-primary">You are currently subscribed to <span class="font-medium">Maybe+</span></p>
<% else %>
<p class="text-primary">You are currently <span class="font-medium">not subscribed</span></p>
<p class="text-secondary">Once you subscribe to Maybe+, you'll see your billing settings here.</p>
@ -31,7 +33,7 @@
</div>
</div>
<% if @user.family.subscribed? %>
<% if @family.has_active_subscription? %>
<%= render LinkComponent.new(
text: "Manage",
icon: "external-link",
@ -40,7 +42,7 @@
href: subscription_path,
rel: "noopener"
) %>
<% elsif @user.family.trialing? && !subscription_pending? %>
<% else %>
<%= render LinkComponent.new(
text: "Choose plan",
variant: "primary",

View file

@ -18,7 +18,7 @@
<%= image_tag "logo-color.png", class: "w-16 mb-6" %>
<% if Current.family.trialing? %>
<p class="text-xl lg:text-3xl text-primary font-display font-medium">Your trial has <%= Current.family.trial_remaining_days %> days remaining</p>
<p class="text-xl lg:text-3xl text-primary font-display font-medium">Your trial has <%= Current.family.days_left_in_trial %> days remaining</p>
<% else %>
<p class="text-xl lg:text-3xl text-primary font-display font-medium">Your trial is over</p>
<% end %>
@ -33,8 +33,8 @@
<%= form_with url: new_subscription_path, method: :get, class: "max-w-xs", data: { turbo: false } do |form| %>
<div class="space-y-4 mb-6">
<%= render "subscriptions/plan_choice", form: form, plan: "annual", checked: params[:plan] == "annual" || params[:plan].blank? %>
<%= render "subscriptions/plan_choice", form: form, plan: "monthly", checked: params[:plan] == "monthly" %>
<%= render "subscriptions/plan_choice", form: form, plan: "annual", checked: @plan == "annual" %>
<%= render "subscriptions/plan_choice", form: form, plan: "monthly", checked: @plan == "monthly" %>
</div>
<div class="text-center space-y-2">