diff --git a/saas/app/controllers/admin/accounts_controller.rb b/saas/app/controllers/admin/accounts_controller.rb
index dda0794e4..ba6200d1b 100644
--- a/saas/app/controllers/admin/accounts_controller.rb
+++ b/saas/app/controllers/admin/accounts_controller.rb
@@ -1,4 +1,6 @@
class Admin::AccountsController < AdminController
+ include Admin::AccountScoped
+
layout "public"
before_action :set_account, only: %i[ edit update ]
@@ -10,8 +12,8 @@ class Admin::AccountsController < AdminController
end
def update
- @account.update!(account_params)
- redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account updated"
+ @account.override_limits(card_count: overridden_card_count_param)
+ redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account limits updated"
end
private
@@ -19,7 +21,7 @@ class Admin::AccountsController < AdminController
@account = Account.find_by!(external_account_id: params[:id])
end
- def account_params
- params.expect(account: [ :cards_count ])
+ def overridden_card_count_param
+ params[:account][:overridden_card_count].to_i
end
end
diff --git a/saas/app/controllers/admin/overridden_limits_controller.rb b/saas/app/controllers/admin/overridden_limits_controller.rb
new file mode 100644
index 000000000..fff8ea3e3
--- /dev/null
+++ b/saas/app/controllers/admin/overridden_limits_controller.rb
@@ -0,0 +1,8 @@
+class Admin::OverriddenLimitsController < AdminController
+ include Admin::AccountScoped
+
+ def destroy
+ @account.reset_overridden_limits
+ redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Limits reset"
+ end
+end
diff --git a/saas/app/controllers/concerns/admin/account_scoped.rb b/saas/app/controllers/concerns/admin/account_scoped.rb
new file mode 100644
index 000000000..314720cd2
--- /dev/null
+++ b/saas/app/controllers/concerns/admin/account_scoped.rb
@@ -0,0 +1,12 @@
+module Admin::AccountScoped
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :set_account
+ end
+
+ private
+ def set_account
+ @account = Account.find_by!(external_account_id: params[:account_id] || params[:id])
+ end
+end
diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb
index 69c033817..2ef42edb7 100644
--- a/saas/app/models/account/billing.rb
+++ b/saas/app/models/account/billing.rb
@@ -5,8 +5,6 @@ module Account::Billing
has_one :subscription, class_name: "Account::Subscription", dependent: :destroy
end
- NEAR_CARD_LIMIT_THRESHOLD = 100
-
def plan
active_subscription&.plan || Plan.free
end
@@ -18,12 +16,4 @@ module Account::Billing
def subscribed?
subscription.present?
end
-
- def nearing_plan_cards_limit?
- plan.limit_cards? && (plan.card_limit - cards_count) < NEAR_CARD_LIMIT_THRESHOLD
- end
-
- def exceeding_card_limit?
- cards_count > plan.card_limit
- end
end
diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb
new file mode 100644
index 000000000..6fed72e9e
--- /dev/null
+++ b/saas/app/models/account/limited.rb
@@ -0,0 +1,34 @@
+module Account::Limited
+ extend ActiveSupport::Concern
+
+ included do
+ has_one :overridden_limits, class_name: "Account::OverriddenLimits", dependent: :destroy
+ end
+
+ NEAR_CARD_LIMIT_THRESHOLD = 100
+
+ def override_limits(card_count:)
+ if overridden_limits
+ overridden_limits.update(card_count: card_count)
+ else
+ create_overridden_limits(card_count: card_count)
+ end
+ end
+
+ def billed_cards_count
+ overridden_limits&.card_count || cards_count
+ end
+
+ def nearing_plan_cards_limit?
+ plan.limit_cards? && (plan.card_limit - billed_cards_count) < NEAR_CARD_LIMIT_THRESHOLD
+ end
+
+ def exceeding_card_limit?
+ plan.limit_cards? && billed_cards_count > plan.card_limit
+ end
+
+ def reset_overridden_limits
+ overridden_limits&.destroy
+ reload_overridden_limits
+ end
+end
diff --git a/saas/app/models/account/overridden_limits.rb b/saas/app/models/account/overridden_limits.rb
new file mode 100644
index 000000000..cdf3c51ec
--- /dev/null
+++ b/saas/app/models/account/overridden_limits.rb
@@ -0,0 +1,4 @@
+# To ease testing of limits
+class Account::OverriddenLimits < SaasRecord
+ belongs_to :account
+end
diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb
index 58fb60891..52479f0b1 100644
--- a/saas/app/views/account/settings/_subscription_panel.html.erb
+++ b/saas/app/views/account/settings/_subscription_panel.html.erb
@@ -6,7 +6,7 @@
<% if Current.account.exceeding_card_limit? %>
You’ve used up your <%= Plan.free.card_limit %> free cards
<% else %>
-
You’ve used <%= Current.account.cards_count %> free cards out of <%= Plan.free.card_limit %>
+
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 <%= number_to_human_size(Plan.paid.formatted_storage_limit) %> of storage.
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 7b34f7404..e40aa9fb5 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.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") %>.
<% else %>
- This account has used <%= Current.account.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
<% end %>
<% end %>
diff --git a/saas/config/routes.rb b/saas/config/routes.rb
index 23ee39507..7f9d36465 100644
--- a/saas/config/routes.rb
+++ b/saas/config/routes.rb
@@ -5,6 +5,8 @@ Fizzy::Saas::Engine.routes.draw do
mount Audits1984::Engine, at: "/console"
get "stats", to: "stats#show"
resource :account_search, only: :create
- resources :accounts
+ resources :accounts do
+ resource :overridden_limits, only: :destroy
+ end
end
end
diff --git a/saas/db/migrate/20251215140000_create_account_overridden_limits.rb b/saas/db/migrate/20251215140000_create_account_overridden_limits.rb
new file mode 100644
index 000000000..7e8d9da42
--- /dev/null
+++ b/saas/db/migrate/20251215140000_create_account_overridden_limits.rb
@@ -0,0 +1,10 @@
+class CreateAccountOverriddenLimits < ActiveRecord::Migration[8.2]
+ def change
+ create_table :account_overridden_limits, id: :uuid do |t|
+ t.references :account, null: false, type: :uuid, index: { unique: true }
+ t.integer :card_count
+
+ t.timestamps
+ end
+ end
+end
diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb
index 190a5e8f7..edfead6ad 100644
--- a/saas/db/saas_schema.rb
+++ b/saas/db/saas_schema.rb
@@ -10,7 +10,15 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.2].define(version: 2025_12_03_144630) do
+ActiveRecord::Schema[8.2].define(version: 2025_12_15_140000) 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.integer "card_count"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_account_overridden_limits_on_account_id", unique: true
+ end
+
create_table "account_subscriptions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.datetime "cancel_at"
diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb
index 512e4884e..532f0a384 100644
--- a/saas/lib/fizzy/saas/engine.rb
+++ b/saas/lib/fizzy/saas/engine.rb
@@ -127,8 +127,8 @@ module Fizzy
end
config.to_prepare do
- ::Account.include(Account::Billing)
- ::Signup.prepend(Fizzy::Saas::Signup)
+ ::Account.include Account::Billing, Account::Limited
+ ::Signup.prepend Fizzy::Saas::Signup
CardsController.include(Card::LimitedCreation)
Cards::PublishesController.include(Card::LimitedPublishing)
diff --git a/saas/lib/tasks/fizzy/saas_tasks.rake b/saas/lib/tasks/fizzy/saas_tasks.rake
index 6b423e3c3..ada09dabd 100644
--- a/saas/lib/tasks/fizzy/saas_tasks.rake
+++ b/saas/lib/tasks/fizzy/saas_tasks.rake
@@ -1,13 +1,6 @@
require "rake/testtask"
namespace :test do
- # task :prepare_saas => :environment do
- # require "rails/test_help"
- #
- # $LOAD_PATH.unshift Fizzy::Saas::Engine.root.join("test").to_s
- # require Fizzy::Saas::Engine.root.join("test/test_helper")
- # end
-
desc "Run tests for fizzy-saas gem"
Rake::TestTask.new(saas: :environment) do |t|
t.libs << "test"
diff --git a/saas/test/controllers/admin/accounts_controller_test.rb b/saas/test/controllers/admin/accounts_controller_test.rb
index a6c815bcf..0b830919a 100644
--- a/saas/test/controllers/admin/accounts_controller_test.rb
+++ b/saas/test/controllers/admin/accounts_controller_test.rb
@@ -1,10 +1,6 @@
require "test_helper"
class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest
- def saas
- Fizzy::Saas::Engine.routes.url_helpers
- end
-
test "staff can access index" do
sign_in_as :david
@@ -34,15 +30,15 @@ class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
- test "staff can update cards_count" do
+ test "staff can override card count" do
sign_in_as :david
untenanted do
- patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { cards_count: 500 } }
+ patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { overridden_card_count: 500 } }
assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id)
end
- assert_equal 500, accounts(:"37s").reload.cards_count
+ assert_equal 500, accounts(:"37s").reload.billed_cards_count
end
test "non-staff cannot access accounts" do
diff --git a/saas/test/controllers/admin/overridden_limits_controller_test.rb b/saas/test/controllers/admin/overridden_limits_controller_test.rb
new file mode 100644
index 000000000..b660e08b0
--- /dev/null
+++ b/saas/test/controllers/admin/overridden_limits_controller_test.rb
@@ -0,0 +1,22 @@
+require "test_helper"
+
+class Admin::OverriddenLimitsControllerTest < ActionDispatch::IntegrationTest
+ test "staff can reset overridden limits" do
+ sign_in_as :david
+ account = accounts(:"37s")
+
+ # First set an override
+ account.override_limits(card_count: 500)
+ assert_equal 500, account.reload.billed_cards_count
+
+ # Then reset it
+ untenanted do
+ delete saas.admin_account_overridden_limits_path(account.external_account_id)
+ assert_redirected_to saas.edit_admin_account_path(account.external_account_id)
+ end
+
+ # Verify override was removed
+ assert_nil account.reload.overridden_limits
+ assert_equal account.cards_count, account.billed_cards_count
+ end
+end
diff --git a/saas/test/controllers/admin/stats_controller_test.rb b/saas/test/controllers/admin/stats_controller_test.rb
index 9fa0c8b3f..d7de964c1 100644
--- a/saas/test/controllers/admin/stats_controller_test.rb
+++ b/saas/test/controllers/admin/stats_controller_test.rb
@@ -1,10 +1,6 @@
require "test_helper"
class Admin::StatsControllerTest < ActionDispatch::IntegrationTest
- def saas
- Fizzy::Saas::Engine.routes.url_helpers
- end
-
test "staff can access stats" do
sign_in_as :david
diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb
index 086f60807..3969f18b4 100644
--- a/saas/test/models/account/billing_test.rb
+++ b/saas/test/models/account/billing_test.rb
@@ -15,35 +15,4 @@ class Account::BillingTest < ActiveSupport::TestCase
account.subscription.update!(status: "active")
assert_equal account.subscription, account.active_subscription
end
-
- test "detect nearing card limit" do
- # Paid plans are never limited
- accounts(:"37s").update_column(:cards_count, 1_000_000)
- assert_not accounts(:"37s").nearing_plan_cards_limit?
-
- # Free plan not near limit
- accounts(:initech).update_column(:cards_count, 899)
- assert_not accounts(:initech).nearing_plan_cards_limit?
-
- # Free plan near limit
- accounts(:initech).update_column(:cards_count, 900)
- assert_not accounts(:initech).nearing_plan_cards_limit?
-
- accounts(:initech).update_column(:cards_count, 901)
- assert accounts(:initech).nearing_plan_cards_limit?
- end
-
- test "detect exceeding card limit" do
- # Paid plans are never limited
- accounts(:"37s").update_column(:cards_count, 1_000_000)
- assert_not accounts(:"37s").exceeding_card_limit?
-
- # Free plan under limit
- accounts(:initech).update_column(:cards_count, 999)
- assert_not accounts(:initech).exceeding_card_limit?
-
- # Free plan over limit
- accounts(:initech).update_column(:cards_count, 1001)
- assert accounts(:initech).exceeding_card_limit?
- end
end
diff --git a/saas/test/models/account/limited_test.rb b/saas/test/models/account/limited_test.rb
new file mode 100644
index 000000000..ad982203c
--- /dev/null
+++ b/saas/test/models/account/limited_test.rb
@@ -0,0 +1,51 @@
+require "test_helper"
+
+class Account::LimitedTest < ActiveSupport::TestCase
+ test "detect nearing card limit" do
+ # Paid plans are never limited
+ accounts(:"37s").update_column(:cards_count, 1_000_000)
+ assert_not accounts(:"37s").nearing_plan_cards_limit?
+
+ # Free plan not near limit
+ accounts(:initech).update_column(:cards_count, 899)
+ assert_not accounts(:initech).nearing_plan_cards_limit?
+
+ # Free plan near limit
+ accounts(:initech).update_column(:cards_count, 900)
+ assert_not accounts(:initech).nearing_plan_cards_limit?
+
+ accounts(:initech).update_column(:cards_count, 901)
+ assert accounts(:initech).nearing_plan_cards_limit?
+ end
+
+ test "detect exceeding card limit" do
+ # Paid plans are never limited
+ accounts(:"37s").update_column(:cards_count, 1_000_000)
+ assert_not accounts(:"37s").exceeding_card_limit?
+
+ # Free plan under limit
+ accounts(:initech).update_column(:cards_count, 999)
+ assert_not accounts(:initech).exceeding_card_limit?
+
+ # Free plan over limit
+ accounts(:initech).update_column(:cards_count, 1001)
+ assert accounts(:initech).exceeding_card_limit?
+ end
+
+ test "override limits" do
+ account = accounts(:initech)
+ account.update_column(:cards_count, 1001)
+
+ assert account.exceeding_card_limit?
+ assert_equal 1001, account.billed_cards_count
+
+ account.override_limits card_count: 500
+ assert_not account.exceeding_card_limit?
+ assert_equal 500, account.billed_cards_count
+ assert_equal 1001, account.cards_count # original unchanged
+
+ account.reset_overridden_limits
+ assert account.exceeding_card_limit?
+ assert_equal 1001, account.billed_cards_count
+ end
+end