diff --git a/app/controllers/accountable_sparklines_controller.rb b/app/controllers/accountable_sparklines_controller.rb index 505700b3..17892479 100644 --- a/app/controllers/accountable_sparklines_controller.rb +++ b/app/controllers/accountable_sparklines_controller.rb @@ -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 diff --git a/app/controllers/concerns/onboardable.rb b/app/controllers/concerns/onboardable.rb index 9e7dd144..9d1f9ef6 100644 --- a/app/controllers/concerns/onboardable.rb +++ b/app/controllers/concerns/onboardable.rb @@ -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 diff --git a/app/controllers/settings/billings_controller.rb b/app/controllers/settings/billings_controller.rb index 7bb8c200..5d69d632 100644 --- a/app/controllers/settings/billings_controller.rb +++ b/app/controllers/settings/billings_controller.rb @@ -2,6 +2,6 @@ class Settings::BillingsController < ApplicationController layout "settings" def show - @user = Current.user + @family = Current.family end end diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb index 45f4548a..541d3dd9 100644 --- a/app/controllers/subscriptions_controller.rb +++ b/app/controllers/subscriptions_controller.rb @@ -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 diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index d138e64f..ff1ae08c 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -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 diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb index 8a34c25d..cf16ac78 100644 --- a/app/models/account/chartable.rb +++ b/app/models/account/chartable.rb @@ -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 diff --git a/app/models/assistant/function/get_accounts.rb b/app/models/assistant/function/get_accounts.rb index 38db5758..b912d81d 100644 --- a/app/models/assistant/function/get_accounts.rb +++ b/app/models/assistant/function/get_accounts.rb @@ -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 diff --git a/app/models/assistant/function/get_balance_sheet.rb b/app/models/assistant/function/get_balance_sheet.rb index 930eda6d..71992831 100644 --- a/app/models/assistant/function/get_balance_sheet.rb +++ b/app/models/assistant/function/get_balance_sheet.rb @@ -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) diff --git a/app/models/balance_sheet.rb b/app/models/balance_sheet.rb index fce184b2..c289f86f 100644 --- a/app/models/balance_sheet.rb +++ b/app/models/balance_sheet.rb @@ -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 diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index a7ce8940..bbcc292c 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -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", diff --git a/app/models/family.rb b/app/models/family.rb index 0adb1521..ed919b11 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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) [ diff --git a/app/models/family/subscribeable.rb b/app/models/family/subscribeable.rb new file mode 100644 index 00000000..1cc3a5f1 --- /dev/null +++ b/app/models/family/subscribeable.rb @@ -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 diff --git a/app/models/provider/stripe.rb b/app/models/provider/stripe.rb index d5f5242c..81fc3f96 100644 --- a/app/models/provider/stripe.rb +++ b/app/models/provider/stripe.rb @@ -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 diff --git a/app/models/provider/stripe/customer_event_processor.rb b/app/models/provider/stripe/customer_event_processor.rb deleted file mode 100644 index 6efce0b2..00000000 --- a/app/models/provider/stripe/customer_event_processor.rb +++ /dev/null @@ -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 diff --git a/app/models/provider/stripe/event_processor.rb b/app/models/provider/stripe/event_processor.rb index 03806d1d..f19c419f 100644 --- a/app/models/provider/stripe/event_processor.rb +++ b/app/models/provider/stripe/event_processor.rb @@ -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 diff --git a/app/models/provider/stripe/subscription_event_processor.rb b/app/models/provider/stripe/subscription_event_processor.rb index 773b91d1..360a7f74 100644 --- a/app/models/provider/stripe/subscription_event_processor.rb +++ b/app/models/provider/stripe/subscription_event_processor.rb @@ -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 diff --git a/app/models/subscription.rb b/app/models/subscription.rb new file mode 100644 index 00000000..5f96361e --- /dev/null +++ b/app/models/subscription.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index d211878a..19a17c87 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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? diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 25432ff0..53e7f666 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -89,7 +89,7 @@

Free trial

-

<%= Current.family.trial_remaining_days %> days remaining

+

<%= Current.family.days_left_in_trial %> days remaining

<%= render LinkComponent.new( @@ -99,8 +99,8 @@
-
-
+
+
<% end %> diff --git a/app/views/onboardings/goals.html.erb b/app/views/onboardings/goals.html.erb index 62385de5..f08f51cb 100644 --- a/app/views/onboardings/goals.html.erb +++ b/app/views/onboardings/goals.html.erb @@ -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 %>
<% [ diff --git a/app/views/onboardings/trial.html.erb b/app/views/onboardings/trial.html.erb index 19c7310f..9aa9e6a7 100644 --- a/app/views/onboardings/trial.html.erb +++ b/app/views/onboardings/trial.html.erb @@ -29,12 +29,26 @@

- <%= 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 %>
diff --git a/app/views/rule/actions/_action.html.erb b/app/views/rule/actions/_action.html.erb index ed126cfd..f81eff1b 100644 --- a/app/views/rule/actions/_action.html.erb +++ b/app/views/rule/actions/_action.html.erb @@ -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 %> - +