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