From 611e1cc97e8fffa06b3b26fe11b50533902e1225 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 08:44:11 +0100 Subject: [PATCH 01/25] Add additional plan for extra storage --- saas/.kamal/secrets.beta | 3 ++- saas/.kamal/secrets.production | 3 ++- saas/.kamal/secrets.staging | 3 ++- saas/app/models/plan.rb | 7 ++++++- saas/exe/stripe-dev | 5 ++++- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/saas/.kamal/secrets.beta b/saas/.kamal/secrets.beta index 0fb3db660..658b75c59 100644 --- a/saas/.kamal/secrets.beta +++ b/saas/.kamal/secrets.beta @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER Beta/SECRET_KEY_BASE Beta/VAPID_PUBLIC_KEY Beta/VAPID_PRIVATE_KEY Beta/ACTIVE_STORAGE_ACCESS_KEY_ID Beta/ACTIVE_STORAGE_SECRET_ACCESS_KEY Beta/QUEENBEE_API_TOKEN Beta/SIGNAL_ID_SECRET Beta/SENTRY_DSN Beta/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Beta/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Beta/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Beta/STRIPE_MONTHLY_V1_PRICE_ID Beta/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Beta/STRIPE_SECRET_KEY Beta/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -22,5 +22,6 @@ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRY ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS) +STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS) STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) diff --git a/saas/.kamal/secrets.production b/saas/.kamal/secrets.production index 672359a13..768abbcd0 100644 --- a/saas/.kamal/secrets.production +++ b/saas/.kamal/secrets.production @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Production/RAILS_MASTER_KEY Production/MYSQL_ALTER_PASSWORD Production/MYSQL_ALTER_USER Production/MYSQL_APP_PASSWORD Production/MYSQL_APP_USER Production/MYSQL_READONLY_PASSWORD Production/MYSQL_READONLY_USER Production/SECRET_KEY_BASE Production/VAPID_PUBLIC_KEY Production/VAPID_PRIVATE_KEY Production/ACTIVE_STORAGE_ACCESS_KEY_ID Production/ACTIVE_STORAGE_SECRET_ACCESS_KEY Production/QUEENBEE_API_TOKEN Production/SIGNAL_ID_SECRET Production/SENTRY_DSN Production/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Production/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Production/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Production/STRIPE_MONTHLY_V1_PRICE_ID Production/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Production/STRIPE_SECRET_KEY Production/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -22,5 +22,6 @@ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRY ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS) +STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS) STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) diff --git a/saas/.kamal/secrets.staging b/saas/.kamal/secrets.staging index 7121f5e3a..e98540135 100644 --- a/saas/.kamal/secrets.staging +++ b/saas/.kamal/secrets.staging @@ -1,4 +1,4 @@ -SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET) +SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Staging/RAILS_MASTER_KEY Staging/MYSQL_ALTER_PASSWORD Staging/MYSQL_ALTER_USER Staging/MYSQL_APP_PASSWORD Staging/MYSQL_APP_USER Staging/MYSQL_READONLY_PASSWORD Staging/MYSQL_READONLY_USER Staging/SECRET_KEY_BASE Staging/VAPID_PUBLIC_KEY Staging/VAPID_PRIVATE_KEY Staging/ACTIVE_STORAGE_ACCESS_KEY_ID Staging/ACTIVE_STORAGE_SECRET_ACCESS_KEY Staging/QUEENBEE_API_TOKEN Staging/SIGNAL_ID_SECRET Staging/SENTRY_DSN Staging/ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY Staging/ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY Staging/ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT Staging/STRIPE_MONTHLY_V1_PRICE_ID Staging/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID Staging/STRIPE_SECRET_KEY Staging/STRIPE_WEBHOOK_SECRET) GITHUB_TOKEN=$(gh config get -h github.com oauth_token) BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS) @@ -22,5 +22,6 @@ ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRY ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY $SECRETS) ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=$(kamal secrets extract ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT $SECRETS) STRIPE_MONTHLY_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_V1_PRICE_ID $SECRETS) +STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID=$(kamal secrets extract STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID $SECRETS) STRIPE_SECRET_KEY=$(kamal secrets extract STRIPE_SECRET_KEY $SECRETS) STRIPE_WEBHOOK_SECRET=$(kamal secrets extract STRIPE_WEBHOOK_SECRET $SECRETS) diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index 7ef7f922e..826601bfd 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -1,7 +1,8 @@ class Plan PLANS = { free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, - monthly_v1: { name: "Unlimited", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] } + monthly_v1: { name: "Unlimited", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] }, + monthly_extra_storage_v1: { name: "Unlimited with extra storage", price: 25, card_limit: Float::INFINITY, storage_limit: 500.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"] } } attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id @@ -19,6 +20,10 @@ class Plan @paid ||= find(:monthly_v1) end + def paid_with_extra_storage + @paid_with_extra_storage ||= find(:monthly_extra_storage_v1) + end + def find(key) @all_by_key ||= all.index_by(&:key).with_indifferent_access @all_by_key[key] diff --git a/saas/exe/stripe-dev b/saas/exe/stripe-dev index 2033385ff..5f4820cc7 100755 --- a/saas/exe/stripe-dev +++ b/saas/exe/stripe-dev @@ -30,13 +30,15 @@ secrets_escaped = `kamal secrets fetch \ --account basecamp \ --from "Deploy/Fizzy" \ "Development/STRIPE_SECRET_KEY" \ - "Development/STRIPE_MONTHLY_V1_PRICE_ID" 2>/dev/null` + "Development/STRIPE_MONTHLY_V1_PRICE_ID" \ + "Development/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID" 2>/dev/null` secrets_json = secrets_escaped.gsub(/\\(.)/, '\1') secrets = JSON.parse(secrets_json) stripe_secret_key = secrets["Deploy/Fizzy/Development/STRIPE_SECRET_KEY"] stripe_price_id = secrets["Deploy/Fizzy/Development/STRIPE_MONTHLY_V1_PRICE_ID"] +stripe_extra_storage_price_id = secrets["Deploy/Fizzy/Development/STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"] # Clear previous log file File.write(LOG_FILE, "") @@ -72,6 +74,7 @@ end # Output export statements puts %Q(export STRIPE_SECRET_KEY="#{stripe_secret_key}") puts %Q(export STRIPE_MONTHLY_V1_PRICE_ID="#{stripe_price_id}") +puts %Q(export STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID="#{stripe_extra_storage_price_id}") puts %Q(export STRIPE_WEBHOOK_SECRET="#{webhook_secret}") if webhook_secret # Informational message to stderr (won't be eval'd) From a1a67c65f5ece494a906741f4ae27aee43c5dcf4 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 08:45:11 +0100 Subject: [PATCH 02/25] Extract partials --- saas/app/helpers/subscriptions_helper.rb | 4 ++-- .../account/settings/_free_plan.html.erb | 12 +++++++++++ .../account/settings/_paid_plan.html.erb | 6 ++++++ .../settings/_subscription_panel.html.erb | 21 ++----------------- 4 files changed, 22 insertions(+), 21 deletions(-) create mode 100644 saas/app/views/account/settings/_free_plan.html.erb create mode 100644 saas/app/views/account/settings/_paid_plan.html.erb diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb index a1c4c07d9..89d14b74b 100644 --- a/saas/app/helpers/subscriptions_helper.rb +++ b/saas/app/helpers/subscriptions_helper.rb @@ -1,6 +1,6 @@ module SubscriptionsHelper - def plan_storage_limit(plan) - number_to_human_size(plan.storage_limit).delete(" ") + def storage_to_human_size(bytes) + number_to_human_size(bytes).delete(" ") end def subscription_period_end_action(subscription) diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb new file mode 100644 index 000000000..093aa960b --- /dev/null +++ b/saas/app/views/account/settings/_free_plan.html.erb @@ -0,0 +1,12 @@ +<% if Current.account.exceeding_card_limit? %> +

You've used up your <%= Plan.free.card_limit %> free cards

+<% else %> +

You've used <%= Current.account.billed_cards_count %> free cards out of <%= Plan.free.card_limit %>

+<% end %> + +

If you'd like to keep using Fizzy past <%= Plan.free.card_limit %> cards, it's only $<%= Plan.paid.price %>/month for unlimited cards + unlimited users. You'll also get <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

+ +<%= button_to "Upgrade to #{Plan.paid.name} for $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> + +

Cancel anytime, no contracts, take your data with you whenever.

+ diff --git a/saas/app/views/account/settings/_paid_plan.html.erb b/saas/app/views/account/settings/_paid_plan.html.erb new file mode 100644 index 000000000..c49af1016 --- /dev/null +++ b/saas/app/views/account/settings/_paid_plan.html.erb @@ -0,0 +1,6 @@ +

Thank you for buying Fizzy

+ +<% if Current.account.subscription %> + <%= render "account/settings/subscription", subscription: Current.account.subscription %> + +<% end %> diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb index 44949e022..5a5f0eace 100644 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ b/saas/app/views/account/settings/_subscription_panel.html.erb @@ -3,26 +3,9 @@

Subscription

<% if Current.account.plan.free? %> - <% if Current.account.exceeding_card_limit? %> -

You’ve used up your <%= Plan.free.card_limit %> free cards

- <% else %> -

You’ve used <%= Current.account.billed_cards_count %> free cards out of <%= Plan.free.card_limit %>

- <% end %> - -

If you’d like to keep using Fizzy past <%= Plan.free.card_limit %> cards, it’s only $<%= Plan.paid.price %>/month for unlimited cards + unlimited users. You'll also get <%= plan_storage_limit(Plan.paid) %> of storage.

- - <%= button_to "Upgrade to #{Plan.paid.name} for $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> - -

Cancel anytime, no contracts, take your data with you whenever.

- + <%= render "account/settings/free_plan" %> <% else %> -

Thank you for buying Fizzy

- - <% if Current.account.subscription %> - <%= render "account/settings/subscription", subscription: Current.account.subscription %> - - <% end %> + <%= render "account/settings/paid_plan" %> <% end %> - <% end %> From 8205823588e4ee099df7681349949ee8fc7ccbef Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 08:46:27 +0100 Subject: [PATCH 03/25] Add button to buy extra-storage subscriptions --- .../account/subscriptions_controller.rb | 10 +++++++--- .../app/views/account/settings/_free_plan.html.erb | 1 + .../accounts/subscriptions_controller_test.rb | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb index 8ddfb4b7a..bbc4ec47a 100644 --- a/saas/app/controllers/account/subscriptions_controller.rb +++ b/saas/app/controllers/account/subscriptions_controller.rb @@ -9,10 +9,10 @@ class Account::SubscriptionsController < ApplicationController session = Stripe::Checkout::Session.create \ customer: find_or_create_stripe_customer, mode: "subscription", - line_items: [ { price: Plan.paid.stripe_price_id, quantity: 1 } ], + line_items: [ { price: plan_param.stripe_price_id, quantity: 1 } ], success_url: account_subscription_url + "?session_id={CHECKOUT_SESSION_ID}", cancel_url: account_subscription_url, - metadata: { account_id: Current.account.id, plan_key: Plan.paid.key }, + metadata: { account_id: Current.account.id, plan_key: plan_param.key }, automatic_tax: { enabled: true }, tax_id_collection: { enabled: true }, billing_address_collection: "required", @@ -22,6 +22,10 @@ class Account::SubscriptionsController < ApplicationController end private + def plan_param + @plan_param ||= Plan[params[:plan_key]] || Plan.paid + end + def set_stripe_session @stripe_session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id] end @@ -36,7 +40,7 @@ class Account::SubscriptionsController < ApplicationController def create_stripe_customer Stripe::Customer.create(email: Current.user.identity.email_address, name: Current.account.name, metadata: { account_id: Current.account.id }).tap do |customer| - Current.account.create_subscription!(stripe_customer_id: customer.id, plan_key: Plan.paid.key, status: "incomplete") + Current.account.create_subscription!(stripe_customer_id: customer.id, plan_key: plan_param.key, status: "incomplete") end end end diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb index 093aa960b..4785de354 100644 --- a/saas/app/views/account/settings/_free_plan.html.erb +++ b/saas/app/views/account/settings/_free_plan.html.erb @@ -7,6 +7,7 @@

If you'd like to keep using Fizzy past <%= Plan.free.card_limit %> cards, it's only $<%= Plan.paid.price %>/month for unlimited cards + unlimited users. You'll also get <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

<%= button_to "Upgrade to #{Plan.paid.name} for $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> +<%= button_to "Upgrade to #{Plan.paid_with_extra_storage.name} for $#{ Plan.paid_with_extra_storage.price }/month", account_subscription_path(plan_key: Plan.paid_with_extra_storage.key), class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>

Cancel anytime, no contracts, take your data with you whenever.

diff --git a/saas/test/controllers/accounts/subscriptions_controller_test.rb b/saas/test/controllers/accounts/subscriptions_controller_test.rb index d98ac1d91..9e086df39 100644 --- a/saas/test/controllers/accounts/subscriptions_controller_test.rb +++ b/saas/test/controllers/accounts/subscriptions_controller_test.rb @@ -43,4 +43,18 @@ class Account::SubscriptionsControllerTest < ActionDispatch::IntegrationTest post account_subscription_path assert_response :forbidden end + + test "create with custom plan_key redirects to stripe checkout" do + customer = OpenStruct.new(id: "cus_test_37signals") + session = OpenStruct.new(url: "https://checkout.stripe.com/session123") + + Stripe::Customer.stubs(:retrieve).returns(customer) + Stripe::Checkout::Session.stubs(:create).with do |params| + params[:metadata][:plan_key] == :monthly_extra_storage_v1 + end.returns(session) + + post account_subscription_path(plan_key: :monthly_extra_storage_v1) + + assert_redirected_to "https://checkout.stripe.com/session123" + end end From 823cf9fbb9d212ffab150b1ad7e3f0944f525907 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 08:48:39 +0100 Subject: [PATCH 04/25] Add some methods to deal with storage limits --- .../controllers/admin/accounts_controller.rb | 6 +-- saas/app/models/account/limited.rb | 29 ++++++++++++- saas/app/views/admin/accounts/edit.html.erb | 17 +++++--- ...bytes_used_to_account_overridden_limits.rb | 5 +++ saas/db/saas_schema.rb | 3 +- saas/test/models/account/limited_test.rb | 41 +++++++++++++++++++ 6 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb diff --git a/saas/app/controllers/admin/accounts_controller.rb b/saas/app/controllers/admin/accounts_controller.rb index ba6200d1b..26a2b9c82 100644 --- a/saas/app/controllers/admin/accounts_controller.rb +++ b/saas/app/controllers/admin/accounts_controller.rb @@ -12,7 +12,7 @@ class Admin::AccountsController < AdminController end def update - @account.override_limits(card_count: overridden_card_count_param) + @account.override_limits(**overridden_limits_params.to_h.symbolize_keys) redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account limits updated" end @@ -21,7 +21,7 @@ class Admin::AccountsController < AdminController @account = Account.find_by!(external_account_id: params[:id]) end - def overridden_card_count_param - params[:account][:overridden_card_count].to_i + def overridden_limits_params + params.expect(account: [ :card_count, :bytes_used ]) end end diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb index f7eb3cbc1..b185815e4 100644 --- a/saas/app/models/account/limited.rb +++ b/saas/app/models/account/limited.rb @@ -6,15 +6,20 @@ module Account::Limited end NEAR_CARD_LIMIT_THRESHOLD = 100 + NEAR_STORAGE_LIMIT_THRESHOLD = 500.megabytes - def override_limits(card_count:) - (overridden_limits || build_overridden_limits).update!(card_count:) + def override_limits(card_count: nil, bytes_used: nil) + (overridden_limits || build_overridden_limits).update!(card_count:, bytes_used:) end def billed_cards_count overridden_limits&.card_count || cards_count end + def billed_bytes_used + overridden_limits&.bytes_used || bytes_used + end + def nearing_plan_cards_limit? plan.limit_cards? && remaining_cards_count < NEAR_CARD_LIMIT_THRESHOLD end @@ -23,6 +28,22 @@ module Account::Limited plan.limit_cards? && billed_cards_count > plan.card_limit end + def nearing_plan_storage_limit? + remaining_storage < NEAR_STORAGE_LIMIT_THRESHOLD + end + + def exceeding_storage_limit? + billed_bytes_used > plan.storage_limit + end + + def nearing_limits? + nearing_plan_cards_limit? || nearing_plan_storage_limit? + end + + def exceeding_limits? + exceeding_card_limit? || exceeding_storage_limit? + end + def reset_overridden_limits overridden_limits&.destroy reload_overridden_limits @@ -32,4 +53,8 @@ module Account::Limited def remaining_cards_count plan.card_limit - billed_cards_count end + + def remaining_storage + plan.storage_limit - billed_bytes_used + end end diff --git a/saas/app/views/admin/accounts/edit.html.erb b/saas/app/views/admin/accounts/edit.html.erb index 49c99773d..950ecc761 100644 --- a/saas/app/views/admin/accounts/edit.html.erb +++ b/saas/app/views/admin/accounts/edit.html.erb @@ -11,14 +11,21 @@
Actual card count:
<%= @account.cards_count %>
+
+
Actual bytes used:
+
<%= storage_to_human_size(@account.bytes_used) %>
+
<%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id), class: "flex flex-column gap" do |form| %> -
- <%= form.label :overridden_card_count, "Override card count", class: "font-weight-bold" %> -
- <%= form.number_field :overridden_card_count, value: @account.overridden_limits&.card_count, class: "input full-width" %> -
+
+ <%= form.label :card_count, "Override card count", class: "font-weight-bold" %> + <%= form.number_field :card_count, value: @account.overridden_limits&.card_count, class: "input" %> +
+ +
+ <%= form.label :bytes_used, "Override bytes used", class: "font-weight-bold" %> + <%= form.number_field :bytes_used, value: @account.overridden_limits&.bytes_used, class: "input" %>
diff --git a/saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb b/saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb new file mode 100644 index 000000000..85adad44a --- /dev/null +++ b/saas/db/migrate/20251216000000_add_bytes_used_to_account_overridden_limits.rb @@ -0,0 +1,5 @@ +class AddBytesUsedToAccountOverriddenLimits < ActiveRecord::Migration[8.2] + def change + add_column :account_overridden_limits, :bytes_used, :bigint + end +end diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb index 59c78f4cb..7e9b80b92 100644 --- a/saas/db/saas_schema.rb +++ b/saas/db/saas_schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_15_170000) do +ActiveRecord::Schema[8.2].define(version: 2025_12_16_000000) do create_table "account_billing_waivers", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "created_at", null: false @@ -20,6 +20,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_15_170000) do create_table "account_overridden_limits", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false + t.bigint "bytes_used" t.integer "card_count" t.datetime "created_at", null: false t.datetime "updated_at", null: false diff --git a/saas/test/models/account/limited_test.rb b/saas/test/models/account/limited_test.rb index 335d6a37c..dafca0c31 100644 --- a/saas/test/models/account/limited_test.rb +++ b/saas/test/models/account/limited_test.rb @@ -73,4 +73,45 @@ class Account::LimitedTest < ActiveSupport::TestCase assert account.exceeding_card_limit? end + + test "detect nearing storage limit" do + # Paid plans have large storage limits + accounts(:"37s").update_column(:bytes_used, 4.gigabytes) + assert_not accounts(:"37s").nearing_plan_storage_limit? + + # Free plan not near limit + accounts(:initech).update_column(:bytes_used, 400.megabytes) + assert_not accounts(:initech).nearing_plan_storage_limit? + + # Free plan near limit + accounts(:initech).update_column(:bytes_used, 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) + assert_not accounts(:initech).exceeding_storage_limit? + + # Free plan over limit + accounts(:initech).update_column(:bytes_used, 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) + + assert account.exceeding_storage_limit? + assert_equal 1.1.gigabytes, account.billed_bytes_used + + account.override_limits bytes_used: 500.megabytes + assert_not account.exceeding_storage_limit? + assert_equal 500.megabytes, account.billed_bytes_used + assert_equal 1.1.gigabytes, account.bytes_used # original unchanged + + account.reset_overridden_limits + assert account.exceeding_storage_limit? + assert_equal 1.1.gigabytes, account.billed_bytes_used + end end From fc2848ea89218a8f235cfaac11c0c27ed6219ebc Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 08:50:36 +0100 Subject: [PATCH 05/25] Initial storage notices --- .../account/subscriptions/_upgrade.html.erb | 11 ++++------- .../_admin_exceeding_card_limit.html.erb | 4 ++++ .../_admin_exceeding_storage_limit.html.erb | 4 ++++ .../_user_exceeding_card_limit.html.erb | 3 +++ .../_user_exceeding_storage_limit.html.erb | 3 +++ .../container/footer/saas/_create.html.erb | 2 +- .../footer/saas/_near_notice.html.erb | 18 +++++++++--------- .../notices/_admin_nearing_card_limit.html.erb | 1 + .../_admin_nearing_storage_limit.html.erb | 1 + .../notices/_user_nearing_card_limit.html.erb | 1 + .../_user_nearing_storage_limit.html.erb | 1 + 11 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb create mode 100644 saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb create mode 100644 saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb create mode 100644 saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb create mode 100644 saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb create mode 100644 saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb create mode 100644 saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb create mode 100644 saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb index d1c06e7f0..449fecf16 100644 --- a/saas/app/views/account/subscriptions/_upgrade.html.erb +++ b/saas/app/views/account/subscriptions/_upgrade.html.erb @@ -1,12 +1,9 @@
<% if Current.user.admin? %> - You’ve used your <%= Plan.free.card_limit %> free cards. - <%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> - Upgrade to Unlimited - <% end %> + <%= render "account/subscriptions/notices/admin_exceeding_card_limit" if Current.account.exceeding_card_limit? %> + <%= render "account/subscriptions/notices/admin_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %> <% else %> -
- This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more. -
+ <%= render "account/subscriptions/notices/user_exceeding_card_limit" if Current.account.exceeding_card_limit? %> + <%= render "account/subscriptions/notices/user_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %> <% end %>
diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb new file mode 100644 index 000000000..e6c714ed4 --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb @@ -0,0 +1,4 @@ +You've used your <%= Plan.free.card_limit %> free cards. +<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> + Upgrade to Unlimited +<% end %> diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb new file mode 100644 index 000000000..9f7c6c370 --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb @@ -0,0 +1,4 @@ +You've used your <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. +<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> + Upgrade for more storage +<% end %> diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb new file mode 100644 index 000000000..f3f65f3be --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb @@ -0,0 +1,3 @@ +
+ This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more. +
diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb new file mode 100644 index 000000000..51d4a5cdd --- /dev/null +++ b/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb @@ -0,0 +1,3 @@ +
+ This account has used <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. Upgrade to get more. +
diff --git a/saas/app/views/cards/container/footer/saas/_create.html.erb b/saas/app/views/cards/container/footer/saas/_create.html.erb index 295c9bd19..8b0edd308 100644 --- a/saas/app/views/cards/container/footer/saas/_create.html.erb +++ b/saas/app/views/cards/container/footer/saas/_create.html.erb @@ -1,4 +1,4 @@ -<% if Current.account.exceeding_card_limit? %> +<% if Current.account.exceeding_limits? %> <%= render "account/subscriptions/upgrade" %> <% else %> <%= render "cards/container/footer/create", card: card %> diff --git a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb index e40aa9fb5..8dc739e67 100644 --- a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb +++ b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb @@ -1,9 +1,9 @@ -<% if Current.account.nearing_plan_cards_limit? %> -
- <% if Current.user.admin? %> - You’ve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. - <% else %> - This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon - <% end %> -
-<% end %> +
+ <% if Current.user.admin? %> + <%= render "cards/container/footer/saas/notices/admin_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %> + <%= render "cards/container/footer/saas/notices/admin_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %> + <% else %> + <%= render "cards/container/footer/saas/notices/user_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %> + <%= render "cards/container/footer/saas/notices/user_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %> + <% end %> +
diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb new file mode 100644 index 000000000..1b303e62b --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb @@ -0,0 +1 @@ +You've used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb new file mode 100644 index 000000000..904d29657 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb @@ -0,0 +1 @@ +You've used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. <%= link_to "Upgrade for more", account_settings_path(anchor: "subscription") %>. diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb new file mode 100644 index 000000000..30f7b5f14 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb @@ -0,0 +1 @@ +This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb new file mode 100644 index 000000000..c9df3e874 --- /dev/null +++ b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb @@ -0,0 +1 @@ +This account has used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. Upgrade soon From 61c2277362a189adb6244b027189ae242f3dffbc Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 08:51:47 +0100 Subject: [PATCH 06/25] Place nearing variant above for consistency --- .../views/cards/container/footer/saas/_near_notice.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb index 8dc739e67..4cf480f3c 100644 --- a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb +++ b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb @@ -1,9 +1,9 @@
<% if Current.user.admin? %> - <%= render "cards/container/footer/saas/notices/admin_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %> <%= render "cards/container/footer/saas/notices/admin_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %> + <%= render "cards/container/footer/saas/notices/admin_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %> <% else %> - <%= render "cards/container/footer/saas/notices/user_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %> <%= render "cards/container/footer/saas/notices/user_nearing_card_limit" if Current.account.nearing_plan_cards_limit? %> + <%= render "cards/container/footer/saas/notices/user_nearing_storage_limit" if Current.account.nearing_plan_storage_limit? %> <% end %>
From 9c8c56cdb83e9b1038b8cac81330926caff6200e Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 08:59:47 +0100 Subject: [PATCH 07/25] Comped plans use the extra storage variant --- saas/app/models/account/billing_waiver.rb | 2 +- saas/app/models/account/subscription.rb | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/saas/app/models/account/billing_waiver.rb b/saas/app/models/account/billing_waiver.rb index c1968522a..e539f32d6 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.key) + @subscription ||= Account::Subscription.new(plan_key: Plan.paid_with_extra_storage) end end diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb index 0eac71b02..dd7833dfc 100644 --- a/saas/app/models/account/subscription.rb +++ b/saas/app/models/account/subscription.rb @@ -11,6 +11,10 @@ class Account::Subscription < SaasRecord @plan ||= Plan.find(plan_key) end + def plan=(plan) + self.plan_key = plan.key + end + def to_be_canceled? active? && cancel_at.present? end From c56dbb5f1e851592ed1247d2588d5f44feed20d7 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 10:02:23 +0100 Subject: [PATCH 08/25] 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 From 68cec744367d9c8582ad55905a0973ee92741f55 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 10:51:51 +0100 Subject: [PATCH 09/25] Extract parent class, validate plans can be downgraded/upgraded --- .../subscriptions/downgrades_controller.rb | 28 ++++------------ .../subscriptions/update_plan_controller.rb | 32 +++++++++++++++++++ .../subscriptions/upgrades_controller.rb | 28 ++++------------ .../downgrades_controller_test.rb | 9 +++++- .../subscriptions/upgrades_controller_test.rb | 9 +++++- 5 files changed, 60 insertions(+), 46 deletions(-) create mode 100644 saas/app/controllers/account/subscriptions/update_plan_controller.rb diff --git a/saas/app/controllers/account/subscriptions/downgrades_controller.rb b/saas/app/controllers/account/subscriptions/downgrades_controller.rb index 565e0d2ac..d06795841 100644 --- a/saas/app/controllers/account/subscriptions/downgrades_controller.rb +++ b/saas/app/controllers/account/subscriptions/downgrades_controller.rb @@ -1,28 +1,12 @@ -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 +class Account::Subscriptions::DowngradesController < Account::Subscriptions::UpdatePlanController + before_action :ensure_downgradeable private - def subscription - @subscription ||= Current.account.subscription + def target_plan + Plan.paid end - def stripe_subscription_item_id - Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id + def ensure_downgradeable + head :bad_request unless subscription.plan == Plan.paid_with_extra_storage end end diff --git a/saas/app/controllers/account/subscriptions/update_plan_controller.rb b/saas/app/controllers/account/subscriptions/update_plan_controller.rb new file mode 100644 index 000000000..c6a665cec --- /dev/null +++ b/saas/app/controllers/account/subscriptions/update_plan_controller.rb @@ -0,0 +1,32 @@ +class Account::Subscriptions::UpdatePlanController < 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: target_plan.stripe_price_id } ] + } + } + ) + + redirect_to portal_session.url, allow_other_host: true + end + + private + def target_plan + raise NotImplementedError + end + + 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 index 97a112329..1de6d8c6c 100644 --- a/saas/app/controllers/account/subscriptions/upgrades_controller.rb +++ b/saas/app/controllers/account/subscriptions/upgrades_controller.rb @@ -1,28 +1,12 @@ -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 +class Account::Subscriptions::UpgradesController < Account::Subscriptions::UpdatePlanController + before_action :ensure_upgradeable private - def subscription - @subscription ||= Current.account.subscription + def target_plan + Plan.paid_with_extra_storage end - def stripe_subscription_item_id - Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id + def ensure_upgradeable + head :bad_request unless subscription.plan == Plan.paid end end diff --git a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb index acfeedad3..e425806c4 100644 --- a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb +++ b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb @@ -6,7 +6,7 @@ class Account::Subscriptions::DowngradesControllerTest < ActionDispatch::Integra sign_in_as :kevin accounts(:"37s").subscription.update!( stripe_subscription_id: "sub_123", - plan_key: Plan.paid_with_extra_storage.key + plan: Plan.paid_with_extra_storage ) end @@ -30,4 +30,11 @@ class Account::Subscriptions::DowngradesControllerTest < ActionDispatch::Integra post account_subscription_downgrade_path assert_response :forbidden end + + test "downgrade requires downgradeable plan" do + accounts(:"37s").subscription.update!(plan: Plan.paid) + + post account_subscription_downgrade_path + assert_response :bad_request + end end diff --git a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb index 049de5efb..87308842c 100644 --- a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb +++ b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb @@ -6,7 +6,7 @@ class Account::Subscriptions::UpgradesControllerTest < ActionDispatch::Integrati sign_in_as :kevin accounts(:"37s").subscription.update!( stripe_subscription_id: "sub_123", - plan_key: Plan.paid.key + plan: Plan.paid ) end @@ -30,4 +30,11 @@ class Account::Subscriptions::UpgradesControllerTest < ActionDispatch::Integrati post account_subscription_upgrade_path assert_response :forbidden end + + test "upgrade requires upgradeable plan" do + accounts(:"37s").subscription.update!(plan: Plan.paid_with_extra_storage) + + post account_subscription_upgrade_path + assert_response :bad_request + end end From 4e6274d59ac0c941c162ef0e035057db08584ff5 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 11:21:53 +0100 Subject: [PATCH 10/25] Consider storage when preventing backend card creation and publishing too --- saas/app/controllers/concerns/card/limited.rb | 8 ++++++++ .../controllers/concerns/card/limited_creation.rb | 9 +++------ .../controllers/concerns/card/limited_publishing.rb | 9 +++------ saas/test/controllers/card/limited_creation_test.rb | 12 ++++++++++++ .../test/controllers/card/limited_publishing_test.rb | 11 +++++++++++ 5 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 saas/app/controllers/concerns/card/limited.rb diff --git a/saas/app/controllers/concerns/card/limited.rb b/saas/app/controllers/concerns/card/limited.rb new file mode 100644 index 000000000..0576c6c4e --- /dev/null +++ b/saas/app/controllers/concerns/card/limited.rb @@ -0,0 +1,8 @@ +module Card::Limited + extend ActiveSupport::Concern + + private + def ensure_under_limits + head :forbidden if Current.account.exceeding_limits? + end +end diff --git a/saas/app/controllers/concerns/card/limited_creation.rb b/saas/app/controllers/concerns/card/limited_creation.rb index b4c773449..0e992008a 100644 --- a/saas/app/controllers/concerns/card/limited_creation.rb +++ b/saas/app/controllers/concerns/card/limited_creation.rb @@ -2,13 +2,10 @@ module Card::LimitedCreation extend ActiveSupport::Concern included do + include Card::Limited + # Only limit API requests. We let you create drafts in the app to actually show the banner, no matter the card count. # We limit card publications separately. See +Card::LimitedPublishing+. - before_action :ensure_can_create_cards, only: %i[ create ], if: -> { request.format.json? } + before_action :ensure_under_limits, only: %i[ create ], if: -> { request.format.json? } end - - private - def ensure_can_create_cards - head :forbidden if Current.account.exceeding_card_limit? - end end diff --git a/saas/app/controllers/concerns/card/limited_publishing.rb b/saas/app/controllers/concerns/card/limited_publishing.rb index b92ab06f3..8b60a4b53 100644 --- a/saas/app/controllers/concerns/card/limited_publishing.rb +++ b/saas/app/controllers/concerns/card/limited_publishing.rb @@ -2,11 +2,8 @@ module Card::LimitedPublishing extend ActiveSupport::Concern included do - before_action :ensure_can_publish_cards, only: %i[ create ] - end + include Card::Limited - private - def ensure_can_publish_cards - head :forbidden if Current.account.exceeding_card_limit? - end + before_action :ensure_under_limits, only: %i[ create ] + end end diff --git a/saas/test/controllers/card/limited_creation_test.rb b/saas/test/controllers/card/limited_creation_test.rb index b3477735c..4105091be 100644 --- a/saas/test/controllers/card/limited_creation_test.rb +++ b/saas/test/controllers/card/limited_creation_test.rb @@ -40,4 +40,16 @@ class Card::LimitedCreationTest < ActionDispatch::IntegrationTest assert_response :redirect assert Card.last.drafted? end + + test "cannot create cards via JSON when storage limit exceeded" do + sign_in_as :mike + + Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes) + + assert_no_difference -> { Card.count } do + post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug, format: :json) + end + + assert_response :forbidden + end end diff --git a/saas/test/controllers/card/limited_publishing_test.rb b/saas/test/controllers/card/limited_publishing_test.rb index 2eb57471e..90e4e5696 100644 --- a/saas/test/controllers/card/limited_publishing_test.rb +++ b/saas/test/controllers/card/limited_publishing_test.rb @@ -11,4 +11,15 @@ class Card::LimitedPublishingTest < ActionDispatch::IntegrationTest assert_response :forbidden assert cards(:unfinished_thoughts).reload.drafted? end + + test "cannot publish cards when storage limit exceeded" do + sign_in_as :mike + + Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes) + + post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :forbidden + assert cards(:unfinished_thoughts).reload.drafted? + end end From 00cab8ad7aa6ee3b736e9dc17346c01934b2438a Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 11:39:01 +0100 Subject: [PATCH 11/25] Add tests for subscription-related messaging --- .../subscriptions/card_creation_test.rb | 79 +++++++++++++++++++ .../accounts/subscriptions/settings_test.rb | 63 +++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 saas/test/controllers/accounts/subscriptions/card_creation_test.rb create mode 100644 saas/test/controllers/accounts/subscriptions/settings_test.rb diff --git a/saas/test/controllers/accounts/subscriptions/card_creation_test.rb b/saas/test/controllers/accounts/subscriptions/card_creation_test.rb new file mode 100644 index 000000000..650ca7a1a --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/card_creation_test.rb @@ -0,0 +1,79 @@ +require "test_helper" + +class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest + # Nearing limits - shown in card creation footer + + test "admin sees nearing card limit notice" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 950) + + get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_match /upgrade to unlimited/i, response.body + end + + test "admin sees nearing storage limit notice" do + sign_in_as :mike + + Account.any_instance.stubs(:bytes_used).returns(600.megabytes) + + get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_match /upgrade for more/i, response.body + end + + # Exceeding limits - shown instead of create buttons + + test "admin sees exceeding card limit notice" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 1001) + + get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_match /you've used your.*free cards/i, response.body + end + + test "admin sees exceeding storage limit notice" do + sign_in_as :mike + + Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes) + + get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_match /you've used your.*free storage/i, response.body + end + + # Paid accounts under limits - no notices + + test "paid account under limits sees no notices" do + sign_in_as :kevin + + accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active) + + get card_path(cards(:layout), script_name: accounts(:"37s").slug) + + assert_response :success + assert_no_match /upgrade/i, response.body + assert_no_match /you've used your/i, response.body + end + + # Comped accounts under limits - no notices + + test "comped account under limits sees no notices" do + sign_in_as :mike + + accounts(:initech).comp + + get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) + + assert_response :success + assert_no_match /upgrade/i, response.body + assert_no_match /you've used your/i, response.body + end +end diff --git a/saas/test/controllers/accounts/subscriptions/settings_test.rb b/saas/test/controllers/accounts/subscriptions/settings_test.rb new file mode 100644 index 000000000..702e1e4fe --- /dev/null +++ b/saas/test/controllers/accounts/subscriptions/settings_test.rb @@ -0,0 +1,63 @@ +require "test_helper" + +class Account::Subscriptions::SettingsTest < ActionDispatch::IntegrationTest + test "free users see current usage" do + sign_in_as :mike + + accounts(:initech).update_column(:cards_count, 3) + + get account_settings_path(script_name: accounts(:initech).slug) + + assert_response :success + assert_select "h3", text: /You've used 3 free cards out of 1000/ + end + + test "paid users see thank you message" do + sign_in_as :kevin + + accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active) + + get account_settings_path(script_name: accounts(:"37s").slug) + + assert_response :success + assert_select "h3", text: "Thank you for buying Fizzy" + end + + test "regular plan users see upgrade option" do + sign_in_as :kevin + + accounts(:"37s").subscription.update!(plan: Plan.paid, status: :active) + + get account_settings_path(script_name: accounts(:"37s").slug) + + assert_response :success + assert_select "button", text: /upgrade/i + assert_select "button", text: /downgrade/i, count: 0 + end + + test "extra storage plan users see downgrade option" do + sign_in_as :kevin + + accounts(:"37s").subscription.update!(plan: Plan.paid_with_extra_storage, status: :active) + + get account_settings_path(script_name: accounts(:"37s").slug) + + assert_response :success + assert_select "button", text: /downgrade/i + assert_select "button", text: /upgrade/i, count: 0 + end + + test "comped accounts see no subscription panel" do + sign_in_as :mike + + accounts(:initech).comp + + get account_settings_path(script_name: accounts(:initech).slug) + + assert_response :success + assert_no_match /thank you for buying/i, response.body + assert_no_match /free cards out of/i, response.body + assert_no_match /upgrade/i, response.body + assert_no_match /downgrade/i, response.body + end +end From 3b99aa5dcf792661d4ac80acb2f4ff4a7f295283 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Wed, 17 Dec 2025 11:53:09 +0100 Subject: [PATCH 12/25] Remove unused method --- saas/app/models/account/limited.rb | 4 ---- 1 file changed, 4 deletions(-) diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb index b185815e4..8e846a117 100644 --- a/saas/app/models/account/limited.rb +++ b/saas/app/models/account/limited.rb @@ -36,10 +36,6 @@ module Account::Limited billed_bytes_used > plan.storage_limit end - def nearing_limits? - nearing_plan_cards_limit? || nearing_plan_storage_limit? - end - def exceeding_limits? exceeding_card_limit? || exceeding_storage_limit? end From b326f78d028fa7de897fc49af3554c64a5d0eaf8 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 18 Dec 2025 00:03:13 +0100 Subject: [PATCH 13/25] More idiomatic --- saas/app/models/plan.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index 826601bfd..6a27be07f 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -50,6 +50,6 @@ class Plan end def limit_cards? - card_limit != Float::INFINITY + !card_limit.infinite? end end From 5f4330cbcc7e402c9c10ecb4070a7b66e2ef8c7f Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 14:02:52 -0600 Subject: [PATCH 14/25] Update free plan partial to include storage limits --- .../views/account/settings/_free_plan.html.erb | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb index 4785de354..548fa9a40 100644 --- a/saas/app/views/account/settings/_free_plan.html.erb +++ b/saas/app/views/account/settings/_free_plan.html.erb @@ -1,13 +1,16 @@ -<% if Current.account.exceeding_card_limit? %> -

You've used up your <%= Plan.free.card_limit %> free cards

+<% if Current.account.exceeding_card_limit? && Current.account.exceeding_storage_limit? %> +

You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards and all <%= storage_to_human_size(Plan.free.storage_limit) %> of free storage

+<% elsif Current.account.exceeding_card_limit? %> +

You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards

+<% elsif Current.account.exceeding_storage_limit? %> +

You’ve used up all <%= storage_to_human_size(Plan.free.storage_limit) %> free storage

<% else %> -

You've used <%= Current.account.billed_cards_count %> free cards out of <%= Plan.free.card_limit %>

+

You’ve used <%= Current.account.billed_cards_count %> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %>

<% end %> -

If you'd like to keep using Fizzy past <%= Plan.free.card_limit %> cards, it's only $<%= Plan.paid.price %>/month for unlimited cards + unlimited users. You'll also get <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

+

To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> it’s only $<%= Plan.paid.price %>/month for unlimited cards, unlimited users, and <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

<%= button_to "Upgrade to #{Plan.paid.name} for $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> -<%= button_to "Upgrade to #{Plan.paid_with_extra_storage.name} for $#{ Plan.paid_with_extra_storage.price }/month", account_subscription_path(plan_key: Plan.paid_with_extra_storage.key), class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>

Cancel anytime, no contracts, take your data with you whenever.

- + From 95bfc40392b0c045c4a1284a6cfc7783065797e6 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 15:38:01 -0600 Subject: [PATCH 15/25] Tweak plan name --- saas/app/models/plan.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index 6a27be07f..a15ecab54 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -2,7 +2,7 @@ class Plan PLANS = { free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, monthly_v1: { name: "Unlimited", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] }, - monthly_extra_storage_v1: { name: "Unlimited with extra storage", price: 25, card_limit: Float::INFINITY, storage_limit: 500.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"] } + monthly_extra_storage_v1: { name: "Unlimited + Extra Storage", price: 25, card_limit: Float::INFINITY, storage_limit: 500.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"] } } attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id From cfd2033d15d40423363a988982349abdcc5f9cdc Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 17:09:39 -0600 Subject: [PATCH 16/25] Design paid plan screen --- saas/app/assets/stylesheets/fizzy/saas.css | 4 +++ saas/app/helpers/subscriptions_helper.rb | 2 +- .../account/settings/_free_plan.html.erb | 24 +++++++++-------- .../account/settings/_paid_plan.html.erb | 27 ++++++++++++++++--- .../account/settings/_subscription.html.erb | 20 +++++++------- 5 files changed, 53 insertions(+), 24 deletions(-) diff --git a/saas/app/assets/stylesheets/fizzy/saas.css b/saas/app/assets/stylesheets/fizzy/saas.css index 539f79b32..7afd852ff 100644 --- a/saas/app/assets/stylesheets/fizzy/saas.css +++ b/saas/app/assets/stylesheets/fizzy/saas.css @@ -31,6 +31,10 @@ } } + .settings-subscription__link { + color: var(--settings-subscription-text-color); + } + .settings-subscription__notch { animation: wiggle 500ms ease; background: var(--settings-subscription-background); diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb index 89d14b74b..48ce0372f 100644 --- a/saas/app/helpers/subscriptions_helper.rb +++ b/saas/app/helpers/subscriptions_helper.rb @@ -9,7 +9,7 @@ module SubscriptionsHelper elsif subscription.canceled? "Your Fizzy subscription ended on" else - "Your next payment of #{ number_to_currency(subscription.next_amount_due) } will be billed on".html_safe + "Your next payment is #{ number_to_currency(subscription.next_amount_due, strip_insignificant_zeros: true) } on".html_safe end end end diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb index 548fa9a40..1f59ff49f 100644 --- a/saas/app/views/account/settings/_free_plan.html.erb +++ b/saas/app/views/account/settings/_free_plan.html.erb @@ -1,16 +1,18 @@ -<% if Current.account.exceeding_card_limit? && Current.account.exceeding_storage_limit? %> -

You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards and all <%= storage_to_human_size(Plan.free.storage_limit) %> of free storage

-<% elsif Current.account.exceeding_card_limit? %> -

You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards

-<% elsif Current.account.exceeding_storage_limit? %> -

You’ve used up all <%= storage_to_human_size(Plan.free.storage_limit) %> free storage

-<% else %> -

You’ve used <%= Current.account.billed_cards_count %> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %>

-<% end %> +

+ <% if Current.account.exceeding_card_limit? && Current.account.exceeding_storage_limit? %> + You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards and all <%= storage_to_human_size(Plan.free.storage_limit) %> of free storage + <% elsif Current.account.exceeding_card_limit? %> + You’ve used up your <%= number_with_delimiter(Plan.free.card_limit) %> free cards + <% elsif Current.account.exceeding_storage_limit? %> + You’ve used up all <%= storage_to_human_size(Plan.free.storage_limit) %> free storage + <% else %> + You’ve used <%= Current.account.billed_cards_count %> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %> + <% end %> +

-

To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> it’s only $<%= Plan.paid.price %>/month for unlimited cards, unlimited users, and <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

+

To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> it’s only <%= number_to_currency(Plan.paid.price, strip_insignificant_zeros: true) %>/month for unlimited cards, unlimited users, and <%= storage_to_human_size(Plan.paid.storage_limit) %> of storage.

-<%= button_to "Upgrade to #{Plan.paid.name} for $#{ Plan.paid.price }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %> +<%= button_to "Upgrade to #{Plan.paid.name} for #{ number_to_currency(Plan.paid.price, strip_insignificant_zeros: true) }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>

Cancel anytime, no contracts, take your data with you whenever.

diff --git a/saas/app/views/account/settings/_paid_plan.html.erb b/saas/app/views/account/settings/_paid_plan.html.erb index c49af1016..c2e09221b 100644 --- a/saas/app/views/account/settings/_paid_plan.html.erb +++ b/saas/app/views/account/settings/_paid_plan.html.erb @@ -1,6 +1,27 @@ -

Thank you for buying Fizzy

+

+ <% if Current.account.exceeding_storage_limit? %> + You’ve run out of storage + <% elsif Current.account.nearing_plan_storage_limit? %> + You’ve used <%= storage_to_human_size(Current.account.billed_bytes_used) %> of storage + <% else %> + Thank you for buying Fizzy + <% end %> +

+ +<% if Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit? %> +

+ The <%= Current.account.plan.name %> plan includes <%= storage_to_human_size(Plan.paid.storage_limit) %>. Upgrade to get <%= storage_to_human_size(Plan.paid_with_extra_storage.storage_limit) %> extra storage for <%= number_to_currency(Plan.paid_with_extra_storage.price - Plan.paid.price, strip_insignificant_zeros: true) %>/month more. +

+<% end %> <% if Current.account.subscription %> <%= render "account/settings/subscription", subscription: Current.account.subscription %> - -<% end %> + +<% end %> \ No newline at end of file diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb index 8a3e922bf..70bd03ab6 100644 --- a/saas/app/views/account/settings/_subscription.html.erb +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -1,15 +1,17 @@ - - <% if subscription.current_period_end %>

<%= 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 } %> - - <% 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 %> +

+ <% if subscription.plan == Plan.paid && (Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit?) %> + <%= button_to "Upgrade to #{Plan.paid_with_extra_storage.name } for #{ number_to_currency(Plan.paid_with_extra_storage.price, strip_insignificant_zeros: true) }/month", account_subscription_upgrade_path, class: "btn settings-subscription__button", form: { data: { turbo: false } } %> + <% elsif subscription.plan == Plan.paid_with_extra_storage && !Current.account.exceeding_storage_limit? %> <%= button_to "Downgrade to Regular", account_subscription_downgrade_path, class: "btn", form: { data: { turbo: false } } %> <% end %> -

+

+ +

+ <%= link_to "Manage your subscription", account_billing_portal_path, class: "btn btn--plain settings-subscription__link txt-link", data: { turbo_prefetch: false } %> +

+ + From b1a65bd698f64763cd00887b7ad1d4a32779e205 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 17:23:56 -0600 Subject: [PATCH 17/25] Consistency --- saas/app/views/account/settings/_subscription.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb index 70bd03ab6..68e3acd65 100644 --- a/saas/app/views/account/settings/_subscription.html.erb +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -1,5 +1,5 @@ <% if subscription.current_period_end %> -

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

+

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

<% end %>

From d8b3485812762cef5df1fe7ed487d45bc4809868 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 17:40:28 -0600 Subject: [PATCH 18/25] Copy edits for limit notices --- .../notices/_admin_exceeding_card_limit.html.erb | 2 +- .../notices/_admin_exceeding_storage_limit.html.erb | 4 ++-- .../notices/_user_exceeding_storage_limit.html.erb | 2 +- .../footer/saas/notices/_admin_nearing_card_limit.html.erb | 2 +- .../footer/saas/notices/_admin_nearing_storage_limit.html.erb | 2 +- .../footer/saas/notices/_user_nearing_card_limit.html.erb | 2 +- .../footer/saas/notices/_user_nearing_storage_limit.html.erb | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb index e6c714ed4..3dc759d9e 100644 --- a/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb +++ b/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb @@ -1,4 +1,4 @@ -You've used your <%= Plan.free.card_limit %> free cards. +You’ve used your <%= Plan.free.card_limit %> free cards. <%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> Upgrade to Unlimited <% end %> diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb index 9f7c6c370..8770c1460 100644 --- a/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb +++ b/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb @@ -1,4 +1,4 @@ -You've used your <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. +You’ve run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>. <%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> - Upgrade for more storage + Upgrade to get more <% end %> diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb index 51d4a5cdd..f230443a1 100644 --- a/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb +++ b/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb @@ -1,3 +1,3 @@

- This account has used <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. Upgrade to get more. + This account has run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade to get more.
diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb index 1b303e62b..caa7bd958 100644 --- a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb +++ b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb @@ -1 +1 @@ -You've used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. +You’ve used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. <%= link_to "Upgrade to unlimited", account_settings_path(anchor: "subscription") %>. diff --git a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb index 904d29657..ab1a3ca34 100644 --- a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb +++ b/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb @@ -1 +1 @@ -You've used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. <%= link_to "Upgrade for more", account_settings_path(anchor: "subscription") %>. +You’ve used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Current.account.plan.storage_limit) %> <%= Current.account.plan.free? ? "free storage" : "storage" %>. <%= link_to "Upgrade to get more", account_settings_path(anchor: "subscription") %>. diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb index 30f7b5f14..8ab80e162 100644 --- a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb +++ b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb @@ -1 +1 @@ -This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon +This account has used <%= Current.account.billed_cards_count %> out of <%= Plan.free.card_limit %> free cards. Upgrade soon. diff --git a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb index c9df3e874..25428980a 100644 --- a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb +++ b/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb @@ -1 +1 @@ -This account has used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Plan.free.storage_limit) %> free storage. Upgrade soon +This account has used <%= storage_to_human_size(Current.account.billed_bytes_used) %> out of <%= storage_to_human_size(Current.account.plan.storage_limit) %> <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade soon. From 8bb59f507b4224751e61f61a2d17cfe721baafea Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Wed, 17 Dec 2025 17:49:39 -0600 Subject: [PATCH 19/25] Cherry-pick changes in b87ae1494a8c002a7f7d54d2c2d25f6a2ce2da8d --- saas/app/assets/stylesheets/fizzy/saas.css | 1 - 1 file changed, 1 deletion(-) diff --git a/saas/app/assets/stylesheets/fizzy/saas.css b/saas/app/assets/stylesheets/fizzy/saas.css index 7afd852ff..326b95b40 100644 --- a/saas/app/assets/stylesheets/fizzy/saas.css +++ b/saas/app/assets/stylesheets/fizzy/saas.css @@ -43,7 +43,6 @@ color: var(--settings-subscription-text-color); margin-inline: auto; padding: 0 0.3em 0 1.2em; - padding: 0.5ch 0.5ch 0.5ch 2ch; transform: rotate(-1deg); @media (max-width: 640px) { From 0f9d1df31b4bfb25aea655f69e735a4948c90b07 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 18 Dec 2025 09:21:19 +0100 Subject: [PATCH 20/25] Format --- .../accounts/subscriptions/downgrades_controller_test.rb | 9 ++------- .../accounts/subscriptions/upgrades_controller_test.rb | 9 ++------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb index e425806c4..e4680a1f8 100644 --- a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb +++ b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb @@ -4,16 +4,11 @@ require "ostruct" class Account::Subscriptions::DowngradesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin - accounts(:"37s").subscription.update!( - stripe_subscription_id: "sub_123", - plan: Plan.paid_with_extra_storage - ) + accounts(:"37s").subscription.update!(stripe_subscription_id: "sub_123", plan: Plan.paid_with_extra_storage) end test "downgrade redirects to stripe billing portal" do - stripe_subscription = OpenStruct.new( - items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]) - ) + 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) diff --git a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb index 87308842c..65e268af1 100644 --- a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb +++ b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb @@ -4,16 +4,11 @@ require "ostruct" class Account::Subscriptions::UpgradesControllerTest < ActionDispatch::IntegrationTest setup do sign_in_as :kevin - accounts(:"37s").subscription.update!( - stripe_subscription_id: "sub_123", - plan: Plan.paid - ) + accounts(:"37s").subscription.update!(stripe_subscription_id: "sub_123", plan: Plan.paid) end test "upgrade redirects to stripe billing portal" do - stripe_subscription = OpenStruct.new( - items: OpenStruct.new(data: [ OpenStruct.new(id: "si_123") ]) - ) + 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) From 7bf701095cebd9c9f01a7b7e7f662bc342bd4c99 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 18 Dec 2025 09:35:00 +0100 Subject: [PATCH 21/25] Fix tests after the UI/copy changes --- .../accounts/subscriptions/card_creation_test.rb | 10 +++++----- .../accounts/subscriptions/settings_test.rb | 3 +-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/saas/test/controllers/accounts/subscriptions/card_creation_test.rb b/saas/test/controllers/accounts/subscriptions/card_creation_test.rb index 650ca7a1a..3f24c82c5 100644 --- a/saas/test/controllers/accounts/subscriptions/card_creation_test.rb +++ b/saas/test/controllers/accounts/subscriptions/card_creation_test.rb @@ -22,7 +22,7 @@ class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) assert_response :success - assert_match /upgrade for more/i, response.body + assert_match /upgrade to get more/i, response.body end # Exceeding limits - shown instead of create buttons @@ -35,7 +35,7 @@ class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) assert_response :success - assert_match /you've used your.*free cards/i, response.body + assert_match /you’ve used your.*free cards/i, response.body end test "admin sees exceeding storage limit notice" do @@ -46,7 +46,7 @@ class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest get card_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug) assert_response :success - assert_match /you've used your.*free storage/i, response.body + assert_match /you’ve run out of.*free storage/i, response.body end # Paid accounts under limits - no notices @@ -60,7 +60,7 @@ class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest assert_response :success assert_no_match /upgrade/i, response.body - assert_no_match /you've used your/i, response.body + assert_no_match /you’ve used your/i, response.body end # Comped accounts under limits - no notices @@ -74,6 +74,6 @@ class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest assert_response :success assert_no_match /upgrade/i, response.body - assert_no_match /you've used your/i, response.body + assert_no_match /you’ve used your/i, response.body end end diff --git a/saas/test/controllers/accounts/subscriptions/settings_test.rb b/saas/test/controllers/accounts/subscriptions/settings_test.rb index 702e1e4fe..63feac2c5 100644 --- a/saas/test/controllers/accounts/subscriptions/settings_test.rb +++ b/saas/test/controllers/accounts/subscriptions/settings_test.rb @@ -9,7 +9,7 @@ class Account::Subscriptions::SettingsTest < ActionDispatch::IntegrationTest get account_settings_path(script_name: accounts(:initech).slug) assert_response :success - assert_select "h3", text: /You've used 3 free cards out of 1000/ + assert_match /You’ve used.*3.*free cards out of 1,000/i, response.body end test "paid users see thank you message" do @@ -44,7 +44,6 @@ class Account::Subscriptions::SettingsTest < ActionDispatch::IntegrationTest assert_response :success assert_select "button", text: /downgrade/i - assert_select "button", text: /upgrade/i, count: 0 end test "comped accounts see no subscription panel" do From d9ce1577c05bf54a03792a2ff37c176e455ad28b Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 18 Dec 2025 09:47:06 +0100 Subject: [PATCH 22/25] Fix upgrade/downgrade buttons - Remove old buttons I had added before the UI work. - Fix: make new upgrade button use the dedicated action. - Add symmetric downgrade button. --- .../account/settings/_paid_plan.html.erb | 10 +--------- .../account/settings/_subscription.html.erb | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/saas/app/views/account/settings/_paid_plan.html.erb b/saas/app/views/account/settings/_paid_plan.html.erb index c2e09221b..426568eae 100644 --- a/saas/app/views/account/settings/_paid_plan.html.erb +++ b/saas/app/views/account/settings/_paid_plan.html.erb @@ -16,12 +16,4 @@ <% if Current.account.subscription %> <%= render "account/settings/subscription", subscription: Current.account.subscription %> - -<% end %> \ No newline at end of file +<% end %> diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb index 68e3acd65..5ea251c8d 100644 --- a/saas/app/views/account/settings/_subscription.html.erb +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -2,16 +2,19 @@

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

<% end %> -

- <% if subscription.plan == Plan.paid && (Current.account.nearing_plan_storage_limit? || Current.account.exceeding_storage_limit?) %> - <%= button_to "Upgrade to #{Plan.paid_with_extra_storage.name } for #{ number_to_currency(Plan.paid_with_extra_storage.price, strip_insignificant_zeros: true) }/month", account_subscription_upgrade_path, class: "btn settings-subscription__button", form: { data: { turbo: false } } %> - <% elsif subscription.plan == Plan.paid_with_extra_storage && !Current.account.exceeding_storage_limit? %> - <%= button_to "Downgrade to Regular", account_subscription_downgrade_path, class: "btn", form: { data: { turbo: false } } %> - <% end %> -

-

<%= link_to "Manage your subscription", account_billing_portal_path, class: "btn btn--plain settings-subscription__link txt-link", data: { turbo_prefetch: false } %>

+ From b44b7ba992a59b7e3f5ec2ddf3f17ecc976eadb8 Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 18 Dec 2025 10:02:06 +0100 Subject: [PATCH 23/25] Add method to locate plans by the stripe price id --- saas/app/controllers/stripe/webhooks_controller.rb | 2 +- saas/app/models/plan.rb | 4 ++++ saas/test/models/plan_test.rb | 7 +++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb index 74627203b..cdfb6bcec 100644 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ b/saas/app/controllers/stripe/webhooks_controller.rb @@ -80,6 +80,6 @@ class Stripe::WebhooksController < ApplicationController 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 + Plan.find_by_price_id(price_id)&.key end end diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb index a15ecab54..e77ea81e8 100644 --- a/saas/app/models/plan.rb +++ b/saas/app/models/plan.rb @@ -29,6 +29,10 @@ class Plan @all_by_key[key] end + def find_by_price_id(price_id) + all.find { |plan| plan.stripe_price_id == price_id } + end + alias [] find end diff --git a/saas/test/models/plan_test.rb b/saas/test/models/plan_test.rb index 6e0d70b5c..54ed279d6 100644 --- a/saas/test/models/plan_test.rb +++ b/saas/test/models/plan_test.rb @@ -8,4 +8,11 @@ class PlanTest < ActiveSupport::TestCase test "monthly plan is not free" do assert_not Plan[:monthly_v1].free? end + + test "find plan by its price id" do + Plan.paid.stubs(:stripe_price_id).returns("price_monthly_v1") + + assert_equal Plan.paid, Plan.find_by_price_id("price_monthly_v1") + assert_nil Plan.find_by_price_id("unknown_price_id") + end end From dd59771c94ee1924def5d8dea8a1cc7245673c10 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Thu, 18 Dec 2025 07:25:20 -0600 Subject: [PATCH 24/25] Use current plan because you might be on the extra storage plan --- saas/app/views/account/settings/_subscription.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb index 5ea251c8d..c3e629607 100644 --- a/saas/app/views/account/settings/_subscription.html.erb +++ b/saas/app/views/account/settings/_subscription.html.erb @@ -7,7 +7,7 @@