From c56dbb5f1e851592ed1247d2588d5f44feed20d7 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 10:02:23 +0100 Subject: [PATCH] Add upgrade/downgrade options --- .../subscriptions/downgrades_controller.rb | 28 ++++++++++++++++ .../subscriptions/upgrades_controller.rb | 28 ++++++++++++++++ .../controllers/stripe/webhooks_controller.rb | 8 ++++- saas/app/models/account/billing_waiver.rb | 2 +- .../account/settings/_subscription.html.erb | 10 +++++- saas/lib/fizzy/saas/engine.rb | 9 +++-- .../downgrades_controller_test.rb | 33 +++++++++++++++++++ .../subscriptions/upgrades_controller_test.rb | 33 +++++++++++++++++++ .../admin/accounts_controller_test.rb | 2 +- saas/test/models/account/limited_test.rb | 12 +++---- 10 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 saas/app/controllers/account/subscriptions/downgrades_controller.rb create mode 100644 saas/app/controllers/account/subscriptions/upgrades_controller.rb create mode 100644 saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb create mode 100644 saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb diff --git a/saas/app/controllers/account/subscriptions/downgrades_controller.rb b/saas/app/controllers/account/subscriptions/downgrades_controller.rb new file mode 100644 index 000000000..565e0d2ac --- /dev/null +++ b/saas/app/controllers/account/subscriptions/downgrades_controller.rb @@ -0,0 +1,28 @@ +class Account::Subscriptions::DowngradesController < ApplicationController + before_action :ensure_admin + + def create + portal_session = Stripe::BillingPortal::Session.create( + customer: subscription.stripe_customer_id, + return_url: account_settings_url(anchor: "subscription"), + flow_data: { + type: "subscription_update_confirm", + subscription_update_confirm: { + subscription: subscription.stripe_subscription_id, + items: [ { id: stripe_subscription_item_id, price: Plan.paid.stripe_price_id } ] + } + } + ) + + redirect_to portal_session.url, allow_other_host: true + end + + private + def subscription + @subscription ||= Current.account.subscription + end + + def stripe_subscription_item_id + Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id + end +end diff --git a/saas/app/controllers/account/subscriptions/upgrades_controller.rb b/saas/app/controllers/account/subscriptions/upgrades_controller.rb new file mode 100644 index 000000000..97a112329 --- /dev/null +++ b/saas/app/controllers/account/subscriptions/upgrades_controller.rb @@ -0,0 +1,28 @@ +class Account::Subscriptions::UpgradesController < ApplicationController + before_action :ensure_admin + + def create + portal_session = Stripe::BillingPortal::Session.create( + customer: subscription.stripe_customer_id, + return_url: account_settings_url(anchor: "subscription"), + flow_data: { + type: "subscription_update_confirm", + subscription_update_confirm: { + subscription: subscription.stripe_subscription_id, + items: [ { id: stripe_subscription_item_id, price: Plan.paid_with_extra_storage.stripe_price_id } ] + } + } + ) + + redirect_to portal_session.url, allow_other_host: true + end + + private + def subscription + @subscription ||= Current.account.subscription + end + + def stripe_subscription_item_id + Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id + end +end diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb index bf00fc23f..74627203b 100644 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -49,7 +49,8 @@ class Stripe::WebhooksController < ApplicationController status: stripe_subscription.status, current_period_end: current_period_end_for(stripe_subscription), cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil, - next_amount_due_in_cents: next_amount_due_for(stripe_subscription) + next_amount_due_in_cents: next_amount_due_for(stripe_subscription), + plan_key: plan_key_for(stripe_subscription) } yield subscription_properties if block_given? @@ -76,4 +77,9 @@ class Stripe::WebhooksController < ApplicationController rescue Stripe::InvalidRequestError nil end + + def plan_key_for(stripe_subscription) + price_id = stripe_subscription.items.data.first&.price&.id + Plan.all.find { |plan| plan.stripe_price_id == price_id }&.key + end end diff --git a/saas/app/models/account/billing_waiver.rb b/saas/app/models/account/billing_waiver.rb index e539f32d6..a9ec35eb8 100644 --- a/saas/app/models/account/billing_waiver.rb +++ b/saas/app/models/account/billing_waiver.rb @@ -2,6 +2,6 @@ class Account::BillingWaiver < SaasRecord belongs_to :account def subscription - @subscription ||= Account::Subscription.new(plan_key: Plan.paid_with_extra_storage) + @subscription ||= Account::Subscription.new(plan: Plan.paid_with_extra_storage) end end diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb index 3aef9b853..8a3e922bf 100644 --- a/saas/app/views/account/settings/_subscription.html.erb +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -4,4 +4,12 @@

<%= subscription_period_end_action(subscription) %> <%= subscription.current_period_end.to_date.to_fs(:long) %>

<% end %> -<%= link_to "Manage Billing", account_billing_portal_path, class: "btn", data: { turbo_prefetch: false } %> +
+ <%= link_to "Manage Billing", account_billing_portal_path, class: "btn", data: { turbo_prefetch: false } %> + + <% if subscription.plan == Plan.paid %> + <%= button_to "Upgrade to Extra Storage", account_subscription_upgrade_path, class: "btn", form: { data: { turbo: false } } %> + <% elsif subscription.plan == Plan.paid_with_extra_storage %> + <%= button_to "Downgrade to Regular", account_subscription_downgrade_path, class: "btn", form: { data: { turbo: false } } %> + <% end %> +
diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 532f0a384..757de6beb 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -10,7 +10,7 @@ module Fizzy Queenbee.host_app = Fizzy initializer "fizzy_saas.content_security_policy", before: :load_config_initializers do |app| - app.config.x.content_security_policy.form_action = "https://checkout.stripe.com" + app.config.x.content_security_policy.form_action = "https://checkout.stripe.com https://billing.stripe.com" end initializer "fizzy_saas.assets" do |app| @@ -22,7 +22,12 @@ module Fizzy app.routes.prepend do namespace :account do resource :billing_portal, only: :show - resource :subscription + resource :subscription do + scope module: :subscriptions do + resource :upgrade, only: :create + resource :downgrade, only: :create + end + end end namespace :stripe do diff --git a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb new file mode 100644 index 000000000..acfeedad3 --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" +require "ostruct" + +class Account::Subscriptions::DowngradesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + accounts(:"37s").subscription.update!( + stripe_subscription_id: "sub_123", + plan_key: Plan.paid_with_extra_storage.key + ) + end + + test "downgrade redirects to stripe billing portal" do + stripe_subscription = OpenStruct.new( + items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]) + ) + portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123") + + Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription) + Stripe::BillingPortal::Session.stubs(:create).returns(portal_session) + + post account_subscription_downgrade_path + + assert_redirected_to "https://billing.stripe.com/session/abc123" + end + + test "downgrade requires admin" do + logout_and_sign_in_as :david + + post account_subscription_downgrade_path + assert_response :forbidden + end +end diff --git a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb new file mode 100644 index 000000000..049de5efb --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" +require "ostruct" + +class Account::Subscriptions::UpgradesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + accounts(:"37s").subscription.update!( + stripe_subscription_id: "sub_123", + plan_key: Plan.paid.key + ) + end + + test "upgrade redirects to stripe billing portal" do + stripe_subscription = OpenStruct.new( + items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]) + ) + portal_session = OpenStruct.new(url: "https://billing.stripe.com/session/abc123") + + Stripe::Subscription.stubs(:retrieve).with("sub_123").returns(stripe_subscription) + Stripe::BillingPortal::Session.stubs(:create).returns(portal_session) + + post account_subscription_upgrade_path + + assert_redirected_to "https://billing.stripe.com/session/abc123" + end + + test "upgrade requires admin" do + logout_and_sign_in_as :david + + post account_subscription_upgrade_path + assert_response :forbidden + end +end diff --git a/saas/test/controllers/admin/accounts_controller_test.rb b/saas/test/controllers/admin/accounts_controller_test.rb index 0b830919a..3849c07a5 100644 --- a/saas/test/controllers/admin/accounts_controller_test.rb +++ b/saas/test/controllers/admin/accounts_controller_test.rb @@ -34,7 +34,7 @@ class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest sign_in_as :david untenanted do - patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { overridden_card_count: 500 } } + patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { card_count: 500 } } assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id) end diff --git a/saas/test/models/account/limited_test.rb b/saas/test/models/account/limited_test.rb index dafca0c31..828386023 100644 --- a/saas/test/models/account/limited_test.rb +++ b/saas/test/models/account/limited_test.rb @@ -76,31 +76,31 @@ class Account::LimitedTest < ActiveSupport::TestCase test "detect nearing storage limit" do # Paid plans have large storage limits - accounts(:"37s").update_column(:bytes_used, 4.gigabytes) + accounts(:"37s").stubs(:bytes_used).returns(4.gigabytes) assert_not accounts(:"37s").nearing_plan_storage_limit? # Free plan not near limit - accounts(:initech).update_column(:bytes_used, 400.megabytes) + accounts(:initech).stubs(:bytes_used).returns(400.megabytes) assert_not accounts(:initech).nearing_plan_storage_limit? # Free plan near limit - accounts(:initech).update_column(:bytes_used, 600.megabytes) + accounts(:initech).stubs(:bytes_used).returns(600.megabytes) assert accounts(:initech).nearing_plan_storage_limit? end test "detect exceeding storage limit" do # Free plan under limit - accounts(:initech).update_column(:bytes_used, 900.megabytes) + accounts(:initech).stubs(:bytes_used).returns(900.megabytes) assert_not accounts(:initech).exceeding_storage_limit? # Free plan over limit - accounts(:initech).update_column(:bytes_used, 1.1.gigabytes) + accounts(:initech).stubs(:bytes_used).returns(1.1.gigabytes) assert accounts(:initech).exceeding_storage_limit? end test "override bytes_used limits" do account = accounts(:initech) - account.update_column(:bytes_used, 1.1.gigabytes) + account.stubs(:bytes_used).returns(1.1.gigabytes) assert account.exceeding_storage_limit? assert_equal 1.1.gigabytes, account.billed_bytes_used