mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-02 20:15:22 +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:
parent
8c10e87387
commit
5da4bb6dc3
40 changed files with 1041 additions and 233 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
[
|
||||
|
|
81
app/models/family/subscribeable.rb
Normal file
81
app/models/family/subscribeable.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
37
app/models/subscription.rb
Normal file
37
app/models/subscription.rb
Normal 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
|
|
@ -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?
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue