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/admin/accounts/edit.html.erb b/saas/app/views/admin/accounts/edit.html.erb index 4245c8cd5..1a2f90515 100644 --- a/saas/app/views/admin/accounts/edit.html.erb +++ b/saas/app/views/admin/accounts/edit.html.erb @@ -1,14 +1,33 @@ -

Edit Account <%= @account.external_account_id %>

+
+
+

Edit Account <%= @account.external_account_id %>

-

Name: <%= @account.name %>

+
+
+
Name:
+
<%= @account.name %>
+
+
+
Actual card count:
+
<%= @account.cards_count %>
+
+
-<%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id) do |form| %> -

- <%= form.label :cards_count %> - <%= form.number_field :cards_count %> -

+ <%= 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.submit "Update" %> -<% end %> + + <% end %> -

<%= link_to "Back", saas.admin_accounts_path %>

+ <% if @account.overridden_limits %> + <%= button_to "Reset limits", saas.admin_account_overridden_limits_path(@account.external_account_id), method: :delete, class: "btn btn--negative" %> + <% end %> + + <%= link_to "Back to accounts", saas.admin_accounts_path, class: "btn btn--plain txt-link" %> +
+
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