1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-24 15:49:39 +02:00

New onboarding, trials, Stripe integration (#2185)
Some checks are pending
Publish Docker image / ci (push) Waiting to run
Publish Docker image / Build docker image (push) Blocked by required conditions

* New onboarding, trials, Stripe integration

* Fix tests

* Lint fixes

* Fix subscription endpoints
This commit is contained in:
Zach Gollwitzer 2025-05-01 16:47:14 -04:00 committed by GitHub
parent 79b4a3769b
commit a51c4d2cba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 847 additions and 372 deletions

View file

@ -2,27 +2,10 @@ class ApplicationController < ActionController::Base
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable, Notifiable
include Pagy::Backend
helper_method :require_upgrade?, :subscription_pending?
before_action :detect_os
before_action :set_default_chat
private
def require_upgrade?
return false if self_hosted?
return false unless Current.session
return false if Current.family.subscribed?
return false if subscription_pending? || request.path == settings_billing_path
return false if Current.family.active_accounts_count <= 3
true
end
def subscription_pending?
subscribed_at = Current.session.subscribed_at
subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago
end
def detect_os
user_agent = request.user_agent
@os = case user_agent

View file

@ -2,16 +2,40 @@ module Onboardable
extend ActiveSupport::Concern
included do
before_action :redirect_to_onboarding, if: :needs_onboarding?
before_action :require_onboarding_and_upgrade
helper_method :subscription_pending?
end
private
def redirect_to_onboarding
redirect_to onboarding_path
# 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
def needs_onboarding?
Current.user && Current.user.onboarded_at.blank? &&
!%w[/users /onboarding /sessions].any? { |path| request.path.start_with?(path) }
# 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_at.blank?
redirect_to onboarding_path
elsif !Current.family.subscribed? && !Current.family.trialing?
redirect_to upgrade_subscription_path
end
end
def redirectable_path?(path)
return false if path.starts_with?("/settings")
return false if path.starts_with?("/subscription")
return false if path.starts_with?("/onboarding")
return false if path.starts_with?("/users")
[
new_registration_path,
new_session_path,
new_password_reset_path,
new_email_confirmation_path
].exclude?(path)
end
end

View file

@ -1,14 +1,16 @@
class OnboardingsController < ApplicationController
layout "wizard"
before_action :set_user
before_action :load_invitation
def show
end
def profile
def preferences
end
def preferences
def trial
end
private

View file

@ -1,41 +1,63 @@
class SubscriptionsController < ApplicationController
before_action :redirect_to_root_if_self_hosted
# Disables subscriptions for self hosted instances
guard_feature if: -> { self_hosted? }
# Upgrade page for unsubscribed users
def upgrade
render layout: "onboardings"
end
def start_trial
if Current.family.trial_started_at.present?
redirect_to root_path, alert: "You've already started or completed your trial"
else
Family.transaction do
Current.family.update(trial_started_at: Time.current)
Current.user.update(onboarded_at: Time.current)
end
redirect_to root_path, notice: "Your trial has started"
end
end
def new
if Current.family.stripe_customer_id.blank?
customer = stripe_client.v1.customers.create(
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
session = stripe_client.v1.checkout.sessions.create({
customer: Current.family.stripe_customer_id,
line_items: [ {
price: ENV["STRIPE_PLAN_ID"],
quantity: 1
} ],
mode: "subscription",
allow_promotion_codes: true,
checkout_session_url = stripe.get_checkout_session_url(
price_id: price_id,
customer_id: Current.family.stripe_customer_id,
success_url: success_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url: settings_billing_url
})
cancel_url: upgrade_subscription_url(plan: params[:plan])
)
redirect_to session.url, allow_other_host: true, status: :see_other
redirect_to checkout_session_url, allow_other_host: true, status: :see_other
end
def show
portal_session = stripe_client.v1.billing_portal.sessions.create(
customer: Current.family.stripe_customer_id,
portal_session_url = stripe.get_billing_portal_session_url(
customer_id: Current.family.stripe_customer_id,
return_url: settings_billing_url
)
redirect_to portal_session.url, allow_other_host: true, status: :see_other
redirect_to portal_session_url, allow_other_host: true, status: :see_other
end
def success
checkout_session = stripe_client.v1.checkout.sessions.retrieve(params[:session_id])
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
@ -43,11 +65,7 @@ class SubscriptionsController < ApplicationController
end
private
def stripe_client
@stripe_client ||= Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
end
def redirect_to_root_if_self_hosted
redirect_to root_path, alert: I18n.t("subscriptions.self_hosted_alert") if self_hosted?
def stripe
@stripe ||= Provider::Registry.get_provider(:stripe)
end
end

View file

@ -63,6 +63,10 @@ class UsersController < ApplicationController
redirect_to root_path
when "preferences"
redirect_to settings_preferences_path, notice: notice
when "goals"
redirect_to goals_onboarding_path
when "trial"
redirect_to trial_onboarding_path
else
redirect_to settings_profile_path, notice: notice
end
@ -83,8 +87,10 @@ class UsersController < ApplicationController
def user_params
params.require(:user).permit(
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, :show_ai_sidebar, :ai_enabled, :theme,
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ]
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
:show_sidebar, :default_period, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at,
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ],
goals: []
)
end

View file

@ -33,61 +33,21 @@ class WebhooksController < ApplicationController
end
def stripe
webhook_body = request.body.read
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
stripe_provider = Provider::Registry.get_provider(:stripe)
begin
thin_event = client.parse_thin_event(webhook_body, sig_header, ENV["STRIPE_WEBHOOK_SECRET"])
webhook_body = request.body.read
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
event = client.v1.events.retrieve(thin_event.id)
case event.type
when /^customer\.subscription\./
handle_subscription_event(event)
when "customer.created", "customer.updated", "customer.deleted"
handle_customer_event(event)
else
Rails.logger.info "Unhandled event type: #{event.type}"
end
stripe_provider.process_webhook_later(webhook_body, sig_header)
head :ok
rescue JSON::ParserError => error
Sentry.capture_exception(error)
render json: { error: "Invalid payload" }, status: :bad_request
return
head :bad_request
rescue Stripe::SignatureVerificationError => error
Sentry.capture_exception(error)
render json: { error: "Invalid signature" }, status: :bad_request
return
head :bad_request
end
render json: { received: true }, status: :ok
end
private
def handle_subscription_event(event)
subscription = event.data.object
family = Family.find_by(stripe_customer_id: subscription.customer)
if family
family.update(
stripe_plan_id: subscription.plan.id,
stripe_subscription_status: subscription.status
)
else
Rails.logger.error "Family not found for Stripe customer ID: #{subscription.customer}"
end
end
def handle_customer_event(event)
customer = event.data.object
family = Family.find_by(stripe_customer_id: customer.id)
if family
family.update(stripe_customer_id: customer.id)
else
Rails.logger.error "Family not found for Stripe customer ID: #{customer.id}"
end
end
end