+
+ <% 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.
Cancel anytime, no contracts, take your data with you whenever.
+
Right now you’re on the <%= Current.account.plan.name %> plan which includes <%= Plan.free.card_limit %> cards, unlimited users, and <%= plan_storage_limit(Plan.free) %> of storage.
Right now you’re on the <%= Current.account.plan.name %> plan which includes unlimited cards, unlimited users, and <%= plan_storage_limit(Plan.paid) %> of storage.
+ <% end %>
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/account/subscriptions/_upgrade.html.erb b/app/views/account/subscriptions/_upgrade.html.erb
new file mode 100644
index 0000000..d1c06e7
--- /dev/null
+++ b/app/views/account/subscriptions/_upgrade.html.erb
@@ -0,0 +1,12 @@
+
+ <% 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 %>
+ <% else %>
+
+ This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more.
+
+ <% end %>
+
diff --git a/app/views/account/subscriptions/show.html.erb b/app/views/account/subscriptions/show.html.erb
new file mode 100644
index 0000000..8d29a37
--- /dev/null
+++ b/app/views/account/subscriptions/show.html.erb
@@ -0,0 +1,9 @@
+<% @page_title = "Thank you" %>
+
+<% if @stripe_session&.payment_status == "paid" %>
+
+
Thanks for buying Fizzy!
+
Your payment was successful. You’re now on the <%= Current.account.plan.name %> plan.
+
diff --git a/app/views/cards/container/footer/saas/_create.html.erb b/app/views/cards/container/footer/saas/_create.html.erb
new file mode 100644
index 0000000..295c9bd
--- /dev/null
+++ b/app/views/cards/container/footer/saas/_create.html.erb
@@ -0,0 +1,5 @@
+<% if Current.account.exceeding_card_limit? %>
+ <%= render "account/subscriptions/upgrade" %>
+<% else %>
+ <%= render "cards/container/footer/create", card: card %>
+<% end %>
diff --git a/app/views/cards/container/footer/saas/_near_notice.html.erb b/app/views/cards/container/footer/saas/_near_notice.html.erb
new file mode 100644
index 0000000..e40aa9f
--- /dev/null
+++ b/app/views/cards/container/footer/saas/_near_notice.html.erb
@@ -0,0 +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 %>
diff --git a/config/deploy.beta.yml b/config/deploy.beta.yml
index 8744705..4aefe56 100644
--- a/config/deploy.beta.yml
+++ b/config/deploy.beta.yml
@@ -44,6 +44,9 @@ env:
- ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
- ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
- ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
+ - STRIPE_MONTHLY_V1_PRICE_ID
+ - STRIPE_SECRET_KEY
+ - STRIPE_WEBHOOK_SECRET
tags:
sc_chi: {}
df_iad:
diff --git a/config/deploy.production.yml b/config/deploy.production.yml
index d8e4dc4..7055694 100644
--- a/config/deploy.production.yml
+++ b/config/deploy.production.yml
@@ -50,6 +50,9 @@ env:
- ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
- ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
- ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
+ - STRIPE_MONTHLY_V1_PRICE_ID
+ - STRIPE_SECRET_KEY
+ - STRIPE_WEBHOOK_SECRET
tags:
sc_chi:
MYSQL_SOLID_CACHE_HOST: fizzy-solidcache-db-01.sc-chi-int.37signals.com
diff --git a/config/deploy.staging.yml b/config/deploy.staging.yml
index 2abc18e..2c0ae95 100644
--- a/config/deploy.staging.yml
+++ b/config/deploy.staging.yml
@@ -50,6 +50,9 @@ env:
- ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
- ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
- ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
+ - STRIPE_MONTHLY_V1_PRICE_ID
+ - STRIPE_SECRET_KEY
+ - STRIPE_WEBHOOK_SECRET
tags:
sc_chi:
MYSQL_SOLID_CACHE_HOST: fizzy-staging-solidcache-db-01.sc-chi-int.37signals.com
diff --git a/config/routes.rb b/config/routes.rb
index 7c7d4f6..ece9fef 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -3,5 +3,11 @@
namespace :admin do
mount Audits1984::Engine, at: "/console"
+ get "stats", to: "stats#show"
+ resource :account_search, only: :create
+ resources :accounts do
+ resource :overridden_limits, only: :destroy
+ resource :billing_waiver, only: [ :create, :destroy ]
+ end
end
end
diff --git a/db/migrate/20251203144630_create_account_subscriptions.rb b/db/migrate/20251203144630_create_account_subscriptions.rb
new file mode 100644
index 0000000..7da4806
--- /dev/null
+++ b/db/migrate/20251203144630_create_account_subscriptions.rb
@@ -0,0 +1,15 @@
+class CreateAccountSubscriptions < ActiveRecord::Migration[8.2]
+ def change
+ create_table :account_subscriptions, id: :uuid do |t|
+ t.references :account, null: false, type: :uuid, index: true
+ t.string :plan_key
+ t.string :stripe_customer_id, null: false, index: { unique: true }
+ t.string :stripe_subscription_id, index: { unique: true }
+ t.string :status
+ t.datetime :current_period_end
+ t.datetime :cancel_at
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20251215140000_create_account_overridden_limits.rb b/db/migrate/20251215140000_create_account_overridden_limits.rb
new file mode 100644
index 0000000..7e8d9da
--- /dev/null
+++ b/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/db/migrate/20251215160000_create_account_billing_waivers.rb b/db/migrate/20251215160000_create_account_billing_waivers.rb
new file mode 100644
index 0000000..8f3732c
--- /dev/null
+++ b/db/migrate/20251215160000_create_account_billing_waivers.rb
@@ -0,0 +1,9 @@
+class CreateAccountBillingWaivers < ActiveRecord::Migration[8.2]
+ def change
+ create_table :account_billing_waivers, id: :uuid do |t|
+ t.references :account, null: false, type: :uuid, index: { unique: true }
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20251215170000_add_next_amount_due_in_cents_to_account_subscriptions.rb b/db/migrate/20251215170000_add_next_amount_due_in_cents_to_account_subscriptions.rb
new file mode 100644
index 0000000..28838b3
--- /dev/null
+++ b/db/migrate/20251215170000_add_next_amount_due_in_cents_to_account_subscriptions.rb
@@ -0,0 +1,5 @@
+class AddNextAmountDueInCentsToAccountSubscriptions < ActiveRecord::Migration[8.2]
+ def change
+ add_column :account_subscriptions, :next_amount_due_in_cents, :integer
+ end
+end
diff --git a/db/saas_schema.rb b/db/saas_schema.rb
index 19d4689..59c78f4 100644
--- a/db/saas_schema.rb
+++ b/db/saas_schema.rb
@@ -10,7 +10,38 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.2].define(version: 2025_12_02_205753) do
+ActiveRecord::Schema[8.2].define(version: 2025_12_15_170000) 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
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_account_billing_waivers_on_account_id", unique: true
+ end
+
+ 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"
+ t.datetime "created_at", null: false
+ t.datetime "current_period_end"
+ t.integer "next_amount_due_in_cents"
+ t.string "plan_key"
+ t.string "status"
+ t.string "stripe_customer_id", null: false
+ t.string "stripe_subscription_id"
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_account_subscriptions_on_account_id"
+ t.index ["stripe_customer_id"], name: "index_account_subscriptions_on_stripe_customer_id", unique: true
+ t.index ["stripe_subscription_id"], name: "index_account_subscriptions_on_stripe_subscription_id", unique: true
+ end
+
create_table "audits1984_audits", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "auditor_id", null: false
t.datetime "created_at", null: false
diff --git a/exe/stripe-dev b/exe/stripe-dev
new file mode 100755
index 0000000..2033385
--- /dev/null
+++ b/exe/stripe-dev
@@ -0,0 +1,80 @@
+#!/usr/bin/env ruby
+#
+# Fetches Stripe development environment variables from 1Password and starts
+# the Stripe CLI webhook listener.
+#
+# Usage: eval "$(bundle exec stripe-dev)"
+
+require "json"
+require "fileutils"
+
+LOG_FILE = "log/stripe.development.log"
+PID_FILE = "tmp/stripe.tunnel.pid"
+
+# Ensure directories exist
+FileUtils.mkdir_p("log")
+FileUtils.mkdir_p("tmp")
+
+# Kill any existing stripe tunnel process
+if File.exist?(PID_FILE)
+ old_pid = File.read(PID_FILE).strip.to_i
+ if old_pid > 0
+ Process.kill("TERM", old_pid) rescue nil
+ end
+ File.delete(PID_FILE)
+end
+
+# Fetch secrets from 1Password
+secrets_escaped = `kamal secrets fetch \
+ --adapter 1password \
+ --account basecamp \
+ --from "Deploy/Fizzy" \
+ "Development/STRIPE_SECRET_KEY" \
+ "Development/STRIPE_MONTHLY_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"]
+
+# Clear previous log file
+File.write(LOG_FILE, "")
+
+# Start stripe listen in background
+pid = spawn(
+ "stripe", "listen", "--forward-to", "localhost:3006/stripe/webhooks",
+ out: [ LOG_FILE, "a" ],
+ err: [ LOG_FILE, "a" ]
+)
+Process.detach(pid)
+
+# Save PID for cleanup
+File.write(PID_FILE, pid.to_s)
+
+# Wait for the webhook secret to appear in logs
+webhook_secret = nil
+20.times do
+ sleep 0.5
+ if File.exist?(LOG_FILE)
+ content = File.read(LOG_FILE)
+ if match = content.match(/webhook signing secret is (whsec_\w+)/)
+ webhook_secret = match[1]
+ break
+ end
+ end
+end
+
+if webhook_secret.nil?
+ warn "Warning: Could not capture webhook secret from stripe listen"
+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_WEBHOOK_SECRET="#{webhook_secret}") if webhook_secret
+
+# Informational message to stderr (won't be eval'd)
+warn ""
+warn "Stripe CLI listening (PID: #{pid})"
+warn "Logs: #{LOG_FILE}"
diff --git a/fizzy-saas.gemspec b/fizzy-saas.gemspec
index 64f2535..1368ef6 100644
--- a/fizzy-saas.gemspec
+++ b/fizzy-saas.gemspec
@@ -18,9 +18,12 @@ Gem::Specification.new do |spec|
spec.metadata["source_code_uri"] = "https://github.com/basecamp/fizzy-saas"
spec.files = Dir.chdir(File.expand_path(__dir__)) do
- Dir["{app,config,db,lib,test}/**/*", "LICENSE.md", "Rakefile", "README.md"]
+ Dir["{app,config,db,lib,exe}/**/*", "test/fixtures/**/*", "LICENSE.md", "Rakefile", "README.md"]
end
+ spec.bindir = "exe"
+ spec.executables = [ "stripe-dev" ]
+
spec.add_dependency "rails", ">= 8.1.0.beta1"
spec.add_dependency "queenbee"
spec.add_dependency "rails_structured_logging"
diff --git a/lib/fizzy/saas/engine.rb b/lib/fizzy/saas/engine.rb
index ad23bef..532f0a3 100644
--- a/lib/fizzy/saas/engine.rb
+++ b/lib/fizzy/saas/engine.rb
@@ -9,6 +9,28 @@ class Engine < ::Rails::Engine
# moved from config/initializers/queenbee.rb
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"
+ end
+
+ initializer "fizzy_saas.assets" do |app|
+ app.config.assets.paths << root.join("app/assets/stylesheets")
+ end
+
+ initializer "fizzy.saas.routes", after: :add_routing_paths do |app|
+ # Routes that rely on the implicit account tenant should go here instead of in +routes.rb+.
+ app.routes.prepend do
+ namespace :account do
+ resource :billing_portal, only: :show
+ resource :subscription
+ end
+
+ namespace :stripe do
+ resource :webhooks, only: :create
+ end
+ end
+ end
+
initializer "fizzy.saas.mount" do |app|
app.routes.append do
mount Fizzy::Saas::Engine => "/", as: "saas"
@@ -47,6 +69,10 @@ class Engine < ::Rails::Engine
end
end
+ initializer "fizzy_saas.stripe" do
+ Stripe.api_key = ENV["STRIPE_SECRET_KEY"]
+ end
+
initializer "fizzy_saas.sentry" do
if !Rails.env.local? && ENV["SKIP_TELEMETRY"].blank?
Sentry.init do |config|
@@ -101,7 +127,10 @@ class Engine < ::Rails::Engine
end
config.to_prepare do
- ::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)
Queenbee::Subscription.short_names = Subscription::SHORT_NAMES
diff --git a/lib/fizzy/saas/testing.rb b/lib/fizzy/saas/testing.rb
index eeaf249..3b40f02 100644
--- a/lib/fizzy/saas/testing.rb
+++ b/lib/fizzy/saas/testing.rb
@@ -7,3 +7,14 @@ def next_id
super + Random.rand(1000000)
end
end
+
+# Add engine fixtures to the test fixture paths
+module Fizzy::Saas::EngineFixtures
+ def included(base)
+ super
+ engine_fixtures = Fizzy::Saas::Engine.root.join("test", "fixtures").to_s
+ base.fixture_paths << engine_fixtures unless base.fixture_paths.include?(engine_fixtures)
+ end
+end
+
+ActiveRecord::TestFixtures.singleton_class.prepend(Fizzy::Saas::EngineFixtures)
diff --git a/lib/tasks/fizzy/saas_tasks.rake b/lib/tasks/fizzy/saas_tasks.rake
index 6b423e3..ada09da 100644
--- a/lib/tasks/fizzy/saas_tasks.rake
+++ b/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/test/controllers/accounts/billing_portals_controller_test.rb b/test/controllers/accounts/billing_portals_controller_test.rb
new file mode 100644
index 0000000..00f16e4
--- /dev/null
+++ b/test/controllers/accounts/billing_portals_controller_test.rb
@@ -0,0 +1,29 @@
+require "test_helper"
+require "ostruct"
+
+class Account::BillingPortalsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in_as :kevin
+ end
+
+ test "redirects to stripe billing portal" do
+ Current.account.subscription.update!(stripe_customer_id: "cus_test123")
+
+ session = OpenStruct.new(url: "https://billing.stripe.com/session123")
+ Stripe::BillingPortal::Session.expects(:create)
+ .with(customer: "cus_test123", return_url: account_settings_url)
+ .returns(session)
+
+ get account_billing_portal_path
+
+ assert_redirected_to "https://billing.stripe.com/session123"
+ end
+
+ test "requires admin" do
+ logout_and_sign_in_as :david
+
+ get account_billing_portal_path
+
+ assert_response :forbidden
+ end
+end
diff --git a/test/controllers/accounts/subscriptions_controller_test.rb b/test/controllers/accounts/subscriptions_controller_test.rb
new file mode 100644
index 0000000..d98ac1d
--- /dev/null
+++ b/test/controllers/accounts/subscriptions_controller_test.rb
@@ -0,0 +1,46 @@
+require "test_helper"
+require "ostruct"
+
+class Account::SubscriptionsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in_as :kevin
+ end
+
+ test "show" do
+ get account_subscription_path
+ assert_response :success
+ end
+
+ test "show with session_id retrieves stripe session" do
+ Stripe::Checkout::Session.stubs(:retrieve).with("sess_123").returns(OpenStruct.new(id: "sess_123"))
+
+ get account_subscription_path(session_id: "sess_123")
+ assert_response :success
+ end
+
+ test "show requires admin" do
+ logout_and_sign_in_as :david
+
+ get account_subscription_path
+ assert_response :forbidden
+ end
+
+ test "create 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).returns(session)
+
+ post account_subscription_path
+
+ assert_redirected_to "https://checkout.stripe.com/session123"
+ end
+
+ test "create requires admin" do
+ logout_and_sign_in_as :david
+
+ post account_subscription_path
+ assert_response :forbidden
+ end
+end
diff --git a/test/controllers/admin/accounts_controller_test.rb b/test/controllers/admin/accounts_controller_test.rb
new file mode 100644
index 0000000..0b83091
--- /dev/null
+++ b/test/controllers/admin/accounts_controller_test.rb
@@ -0,0 +1,54 @@
+require "test_helper"
+
+class Admin::AccountsControllerTest < ActionDispatch::IntegrationTest
+ test "staff can access index" do
+ sign_in_as :david
+
+ untenanted do
+ get saas.admin_accounts_path
+ end
+
+ assert_response :success
+ end
+
+ test "search account" do
+ sign_in_as :david
+
+ untenanted do
+ post saas.admin_account_search_path, params: { q: accounts(:"37s").external_account_id }
+ assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id)
+ end
+ end
+
+ test "staff can edit account" do
+ sign_in_as :david
+
+ untenanted do
+ get saas.edit_admin_account_path(accounts(:"37s").external_account_id)
+ end
+
+ assert_response :success
+ end
+
+ 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: { overridden_card_count: 500 } }
+ assert_redirected_to saas.edit_admin_account_path(accounts(:"37s").external_account_id)
+ end
+
+ assert_equal 500, accounts(:"37s").reload.billed_cards_count
+ end
+
+ test "non-staff cannot access accounts" do
+ sign_in_as :jz
+
+ untenanted do
+ patch saas.admin_account_path(accounts(:"37s").external_account_id), params: { account: { cards_count: 9999 } }
+ end
+
+ assert_response :forbidden
+ assert_not_equal 9999, accounts(:"37s").reload.cards_count
+ end
+end
diff --git a/test/controllers/admin/billing_waivers_controller_test.rb b/test/controllers/admin/billing_waivers_controller_test.rb
new file mode 100644
index 0000000..8f7d141
--- /dev/null
+++ b/test/controllers/admin/billing_waivers_controller_test.rb
@@ -0,0 +1,32 @@
+require "test_helper"
+
+class Admin::BillingWaiversControllerTest < ActionDispatch::IntegrationTest
+ test "staff can comp an account" do
+ sign_in_as :david
+ account = accounts(:"37s")
+
+ assert_not account.comped?
+
+ untenanted do
+ post saas.admin_account_billing_waiver_path(account.external_account_id)
+ assert_redirected_to saas.edit_admin_account_path(account.external_account_id)
+ end
+
+ assert account.reload.comped?
+ end
+
+ test "staff can uncomp an account" do
+ sign_in_as :david
+ account = accounts(:"37s")
+ account.comp
+
+ assert account.comped?
+
+ untenanted do
+ delete saas.admin_account_billing_waiver_path(account.external_account_id)
+ assert_redirected_to saas.edit_admin_account_path(account.external_account_id)
+ end
+
+ assert_not account.reload.comped?
+ end
+end
diff --git a/test/controllers/admin/overridden_limits_controller_test.rb b/test/controllers/admin/overridden_limits_controller_test.rb
new file mode 100644
index 0000000..b660e08
--- /dev/null
+++ b/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/test/controllers/admin/stats_controller_test.rb b/test/controllers/admin/stats_controller_test.rb
new file mode 100644
index 0000000..d7de964
--- /dev/null
+++ b/test/controllers/admin/stats_controller_test.rb
@@ -0,0 +1,23 @@
+require "test_helper"
+
+class Admin::StatsControllerTest < ActionDispatch::IntegrationTest
+ test "staff can access stats" do
+ sign_in_as :david
+
+ untenanted do
+ get saas.admin_stats_path
+ end
+
+ assert_response :success
+ end
+
+ test "non-staff cannot access stats" do
+ sign_in_as :jz
+
+ untenanted do
+ get saas.admin_stats_path
+ end
+
+ assert_response :forbidden
+ end
+end
diff --git a/test/controllers/card/limited_creation_test.rb b/test/controllers/card/limited_creation_test.rb
new file mode 100644
index 0000000..b347773
--- /dev/null
+++ b/test/controllers/card/limited_creation_test.rb
@@ -0,0 +1,43 @@
+require "test_helper"
+
+class Card::LimitedCreationTest < ActionDispatch::IntegrationTest
+ test "cannot create cards via JSON when card limit exceeded" do
+ sign_in_as :mike
+
+ accounts(:initech).update_column(:cards_count, 1001)
+
+ 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
+
+ test "can create cards via HTML when card limit exceeded but they are drafts" do
+ sign_in_as :mike
+
+ accounts(:initech).update_column(:cards_count, 1001)
+ boards(:miltons_wish_list).cards.drafted.where(creator: users(:mike)).destroy_all
+
+ assert_difference -> { Card.count } do
+ post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug)
+ end
+
+ assert_response :redirect
+ assert Card.last.drafted?
+ end
+
+ test "cannot force published status via HTML when card limit exceeded" do
+ sign_in_as :mike
+
+ accounts(:initech).update_column(:cards_count, 1001)
+ boards(:miltons_wish_list).cards.drafted.where(creator: users(:mike)).destroy_all
+
+ assert_difference -> { Card.count } do
+ post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug), params: { card: { status: "published" } }
+ end
+
+ assert_response :redirect
+ assert Card.last.drafted?
+ end
+end
diff --git a/test/controllers/card/limited_publishing_test.rb b/test/controllers/card/limited_publishing_test.rb
new file mode 100644
index 0000000..2eb5747
--- /dev/null
+++ b/test/controllers/card/limited_publishing_test.rb
@@ -0,0 +1,14 @@
+require "test_helper"
+
+class Card::LimitedPublishingTest < ActionDispatch::IntegrationTest
+ test "cannot publish cards when card limit exceeded" do
+ sign_in_as :mike
+
+ accounts(:initech).update_column(:cards_count, 1001)
+
+ post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
+
+ assert_response :forbidden
+ assert cards(:unfinished_thoughts).reload.drafted?
+ end
+end
diff --git a/test/controllers/stripe/webhooks_controller_test.rb b/test/controllers/stripe/webhooks_controller_test.rb
new file mode 100644
index 0000000..92f6b75
--- /dev/null
+++ b/test/controllers/stripe/webhooks_controller_test.rb
@@ -0,0 +1,100 @@
+require "test_helper"
+require "ostruct"
+
+class Stripe::WebhooksControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @account = Account.create!(name: "Test")
+ @subscription = @account.create_subscription! \
+ plan_key: "monthly_v1",
+ status: "incomplete",
+ stripe_customer_id: "cus_test123"
+ end
+
+ test "invalid signature returns bad request" do
+ Stripe::Webhook.stubs(:construct_event).raises(Stripe::SignatureVerificationError.new("invalid", "sig"))
+
+ post stripe_webhooks_path
+ assert_response :bad_request
+ end
+
+ test "checkout session completed activates subscription" do
+ stripe_sub = OpenStruct.new(id: "sub_123", customer: "cus_test123", status: "active", cancel_at: nil, items: stub_items(1.month.from_now.to_i))
+
+ event = stripe_event("checkout.session.completed",
+ mode: "subscription",
+ customer: "cus_test123",
+ subscription: "sub_123",
+ metadata: { "plan_key" => "monthly_v1" }
+ )
+
+ Stripe::Webhook.stubs(:construct_event).returns(event)
+ Stripe::Subscription.stubs(:retrieve).returns(stripe_sub)
+ Stripe::Invoice.stubs(:create_preview).returns(OpenStruct.new(amount_due: 1999))
+
+ post stripe_webhooks_path
+
+ assert_response :ok
+ @subscription.reload
+ assert_equal "sub_123", @subscription.stripe_subscription_id
+ assert_equal "active", @subscription.status
+ end
+
+ test "subscription updated changes status and syncs next amount due" do
+ @subscription.update!(stripe_subscription_id: "sub_123", status: "active")
+
+ stripe_sub = OpenStruct.new(
+ id: "sub_123",
+ customer: "cus_test123",
+ status: "past_due",
+ cancel_at: nil,
+ items: stub_items(1.month.from_now.to_i)
+ )
+
+ event = stripe_event("customer.subscription.updated", id: "sub_123")
+
+ Stripe::Webhook.stubs(:construct_event).returns(event)
+ Stripe::Subscription.stubs(:retrieve).returns(stripe_sub)
+ Stripe::Invoice.stubs(:create_preview).returns(OpenStruct.new(amount_due: 1999))
+
+ post stripe_webhooks_path
+
+ assert_response :ok
+ @subscription.reload
+ assert_equal "past_due", @subscription.status
+ assert_equal 1999, @subscription.next_amount_due_in_cents
+ end
+
+ test "subscription deleted cancels subscription" do
+ @subscription.update!(stripe_subscription_id: "sub_123", status: "active")
+
+ stripe_sub = OpenStruct.new(
+ id: "sub_123",
+ customer: "cus_test123",
+ status: "canceled",
+ cancel_at: nil,
+ items: stub_items(1.month.from_now.to_i)
+ )
+
+ event = stripe_event("customer.subscription.deleted", id: "sub_123")
+
+ Stripe::Webhook.stubs(:construct_event).returns(event)
+ Stripe::Subscription.stubs(:retrieve).returns(stripe_sub)
+
+ post stripe_webhooks_path
+
+ assert_response :ok
+ @subscription.reload
+ assert_equal "canceled", @subscription.status
+ assert_nil @subscription.stripe_subscription_id
+ assert_nil @subscription.next_amount_due_in_cents
+ end
+
+ private
+ def stripe_event(type, **attributes)
+ OpenStruct.new(type: type, data: OpenStruct.new(object: OpenStruct.new(attributes)))
+ end
+
+ def stub_items(current_period_end)
+ OpenStruct.new(data: [ OpenStruct.new(current_period_end: current_period_end) ])
+ end
+end
diff --git a/test/fixtures/account_subscriptions.yml b/test/fixtures/account_subscriptions.yml
new file mode 100644
index 0000000..a18ff96
--- /dev/null
+++ b/test/fixtures/account_subscriptions.yml
@@ -0,0 +1,11 @@
+_fixture:
+ model_class: Account::Subscription
+
+signals_monthly:
+ id: <%= ActiveRecord::FixtureSet.identify("signals_monthly", :uuid) %>
+ account_id: <%= ActiveRecord::FixtureSet.identify("37s", :uuid) %>
+ plan_key: monthly_v1
+ status: active
+ stripe_customer_id: cus_test_37signals
+ created_at: <%= Time.current %>
+ updated_at: <%= Time.current %>
diff --git a/test/models/account/billing_test.rb b/test/models/account/billing_test.rb
new file mode 100644
index 0000000..7283f47
--- /dev/null
+++ b/test/models/account/billing_test.rb
@@ -0,0 +1,31 @@
+require "test_helper"
+
+class Account::BillingTest < ActiveSupport::TestCase
+ test "plan reflects active subscription" do
+ account = accounts(:initech)
+
+ # No subscription
+ assert_equal Plan.free, account.plan
+
+ # Subscription but it is not active
+ account.create_subscription!(plan_key: "monthly_v1", status: "canceled", stripe_customer_id: "cus_test")
+ assert_equal Plan.free, account.plan
+
+ # Active subscription exists
+ account.subscription.update!(status: "active")
+ assert_equal Plan.paid, account.plan
+ end
+
+ test "comped account" do
+ account = accounts(:"37s")
+
+ assert_not account.comped?
+
+ account.comp
+ assert account.comped?
+
+ # Calling comp again does not create duplicate
+ account.comp
+ assert_equal 1, Account::BillingWaiver.where(account: account).count
+ end
+end
diff --git a/test/models/account/limited_test.rb b/test/models/account/limited_test.rb
new file mode 100644
index 0000000..335d6a3
--- /dev/null
+++ b/test/models/account/limited_test.rb
@@ -0,0 +1,76 @@
+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
+
+ test "comped accounts are never limited" do
+ account = accounts(:initech)
+ account.update_column(:cards_count, 1_000_000)
+
+ assert account.exceeding_card_limit?
+ assert account.nearing_plan_cards_limit?
+
+ account.comp
+
+ assert_not account.exceeding_card_limit?
+ assert_not account.nearing_plan_cards_limit?
+ end
+
+ test "uncomping an account restores limits" do
+ account = accounts(:initech)
+ account.update_column(:cards_count, 1_000_000)
+ account.comp
+
+ assert_not account.exceeding_card_limit?
+
+ account.uncomp
+
+ assert account.exceeding_card_limit?
+ end
+end
diff --git a/test/models/account/subscription_test.rb b/test/models/account/subscription_test.rb
new file mode 100644
index 0000000..99612ec
--- /dev/null
+++ b/test/models/account/subscription_test.rb
@@ -0,0 +1,18 @@
+require "test_helper"
+
+class Account::SubscriptionTest < ActiveSupport::TestCase
+ test "get the account plan" do
+ subscription = Account::Subscription.new(plan_key: "free_v1")
+ assert_equal Plan[:free_v1], subscription.plan
+ end
+
+ test "check if account is active" do
+ subscription = Account::Subscription.new(status: "active")
+ assert subscription.active?
+ end
+
+ test "check if account is paid" do
+ assert Account::Subscription.new(plan_key: "monthly_v1", status: "active").paid?
+ assert_not Account::Subscription.new(plan_key: "free_v1", status: "active").paid?
+ end
+end
diff --git a/test/models/plan_test.rb b/test/models/plan_test.rb
new file mode 100644
index 0000000..6e0d70b
--- /dev/null
+++ b/test/models/plan_test.rb
@@ -0,0 +1,11 @@
+require "test_helper"
+
+class PlanTest < ActiveSupport::TestCase
+ test "free plan is free" do
+ assert Plan[:free_v1].free?
+ end
+
+ test "monthly plan is not free" do
+ assert_not Plan[:monthly_v1].free?
+ end
+end