- <% 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) %> of 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 <%= format_currency(Plan.paid.price) %>/month for unlimited cards, unlimited users, and <%= storage_to_human_size(Plan.paid.storage_limit) %> 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 <%= number_with_delimiter(Plan.free.card_limit) %> cards, unlimited users, and <%= storage_to_human_size(Plan.free.storage_limit) %> of storage.
- <% 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 <%= format_currency(Plan.paid_with_extra_storage.price - Plan.paid.price) %>/month more.
-
-
- <%= 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 txt-medium", form: { data: { turbo: false } } %>
-<% end %>
-
-<% if Current.account.subscription %>
- <%= render "account/settings/subscription", subscription: Current.account.subscription %>
-<% end %>
diff --git a/saas/app/views/account/settings/_subscription.html.erb b/saas/app/views/account/settings/_subscription.html.erb
deleted file mode 100644
index 4eca0ad2d..000000000
--- a/saas/app/views/account/settings/_subscription.html.erb
+++ /dev/null
@@ -1,20 +0,0 @@
-<% if subscription.current_period_end %>
-
- <%= form.label :bytes_used, class: "txt-subtle" do %>
- Override bytes used <%= "(Exceeds plan limit)" if @account.exceeding_storage_limit? %>
- <% end %>
-
- <% if Current.user.admin? %>
- <%= 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_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 %>
-
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
deleted file mode 100644
index caa7bd958..000000000
--- a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_card_limit.html.erb
+++ /dev/null
@@ -1 +0,0 @@
-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
deleted file mode 100644
index ab1a3ca34..000000000
--- a/saas/app/views/cards/container/footer/saas/notices/_admin_nearing_storage_limit.html.erb
+++ /dev/null
@@ -1 +0,0 @@
-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
deleted file mode 100644
index 8ab80e162..000000000
--- a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_card_limit.html.erb
+++ /dev/null
@@ -1 +0,0 @@
-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
deleted file mode 100644
index 25428980a..000000000
--- a/saas/app/views/cards/container/footer/saas/notices/_user_nearing_storage_limit.html.erb
+++ /dev/null
@@ -1 +0,0 @@
-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.
diff --git a/saas/config/routes.rb b/saas/config/routes.rb
index 581a2ae02..3eafab92f 100644
--- a/saas/config/routes.rb
+++ b/saas/config/routes.rb
@@ -8,10 +8,5 @@ Fizzy::Saas::Engine.routes.draw do
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/saas/db/migrate/20260317000000_drop_billing_tables.rb b/saas/db/migrate/20260317000000_drop_billing_tables.rb
new file mode 100644
index 000000000..2ac9a808d
--- /dev/null
+++ b/saas/db/migrate/20260317000000_drop_billing_tables.rb
@@ -0,0 +1,7 @@
+class DropBillingTables < ActiveRecord::Migration[8.2]
+ def change
+ drop_table :account_subscriptions
+ drop_table :account_overridden_limits
+ drop_table :account_billing_waivers
+ end
+end
diff --git a/saas/db/saas_schema.rb b/saas/db/saas_schema.rb
index 298cc033a..5ae786764 100644
--- a/saas/db/saas_schema.rb
+++ b/saas/db/saas_schema.rb
@@ -10,39 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.2].define(version: 2026_01_26_230838) 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.bigint "bytes_used"
- 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
-
+ActiveRecord::Schema[8.2].define(version: 2026_03_17_000000) do
create_table "action_push_native_devices", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "name"
diff --git a/saas/exe/stripe-dev b/saas/exe/stripe-dev
deleted file mode 100755
index d3e63a754..000000000
--- a/saas/exe/stripe-dev
+++ /dev/null
@@ -1,83 +0,0 @@
-#!/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 23QPQDKZC5BKBIIG7UGT5GR5RM \
- --from "Deploy/Fizzy" \
- "Development/STRIPE_SECRET_KEY" \
- "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, "")
-
-# 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_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)
-warn ""
-warn "Stripe CLI listening (PID: #{pid})"
-warn "Logs: #{LOG_FILE}"
diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb
index 1ba45df42..dea321cfc 100644
--- a/saas/lib/fizzy/saas/engine.rb
+++ b/saas/lib/fizzy/saas/engine.rb
@@ -16,10 +16,6 @@ module Fizzy
connects_to database: { writing: :saas, reading: :saas }
end
- initializer "fizzy_saas.content_security_policy", before: :load_config_initializers do |app|
- app.config.x.content_security_policy.form_action = "https://checkout.stripe.com https://billing.stripe.com"
- end
-
initializer "fizzy_saas.assets" do |app|
app.config.assets.paths << root.join("app/assets/stylesheets")
end
@@ -33,25 +29,6 @@ module Fizzy
app.paths["config/push"].unshift(root.join("config/push.yml").to_s)
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 do
- scope module: :subscriptions do
- resource :upgrade, only: :create
- resource :downgrade, only: :create
- end
- end
- 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"
@@ -98,10 +75,6 @@ module Fizzy
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|
@@ -160,14 +133,10 @@ module Fizzy
end
config.to_prepare do
- ::Account.include Account::Billing, Account::Limited
- ::User.include User::NotifiesAccountOfEmailChange
::Identity.include Authorization::Identity, Identity::Devices
::Session.include Session::Devices
::Signup.prepend Signup
ApplicationController.include Authorization::Controller
- CardsController.include(Card::LimitedCreation)
- Cards::PublishesController.include(Card::LimitedPublishing)
Notification.register_push_target(:native)
diff --git a/saas/lib/tasks/fizzy/usage_report.rake b/saas/lib/tasks/fizzy/usage_report.rake
deleted file mode 100644
index d8b3ba108..000000000
--- a/saas/lib/tasks/fizzy/usage_report.rake
+++ /dev/null
@@ -1,37 +0,0 @@
-require "csv"
-
-namespace :saas do
- desc "Generate a CSV usage report for all active accounts"
- task usage_report: :environment do
- output_path = Rails.root.join("tmp/usage_report.csv")
-
- CSV.open(output_path, "w") do |csv|
- csv << [ "Queenbee ID", "Account Name", "Sign Up Date", "Paid Date", "Comped", "Card Count", "Storage Used (Bytes)", "Last Active" ]
-
- Account.active.includes(:storage_total).in_batches do |batch|
- batch_ids = batch.pluck(:id)
- paid_dates = Account::Subscription.paid.where(account_id: batch_ids)
- .group(:account_id).minimum(:created_at)
- comped_account_ids = Account::BillingWaiver.where(account_id: batch_ids)
- .pluck(:account_id).to_set
- last_active_dates = Card.where(account_id: batch_ids)
- .group(:account_id).maximum(:last_active_at)
-
- batch.each do |account|
- csv << [
- account.external_account_id,
- account.name,
- account.created_at.to_date,
- paid_dates[account.id]&.to_date,
- comped_account_ids.include?(account.id),
- account.cards_count,
- account.bytes_used,
- last_active_dates[account.id]&.to_date
- ]
- end
- end
- end
-
- puts "Report written to #{output_path}"
- end
-end
diff --git a/saas/test/controllers/accounts/billing_portals_controller_test.rb b/saas/test/controllers/accounts/billing_portals_controller_test.rb
deleted file mode 100644
index 00f16e4df..000000000
--- a/saas/test/controllers/accounts/billing_portals_controller_test.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-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/saas/test/controllers/accounts/subscriptions/card_creation_test.rb b/saas/test/controllers/accounts/subscriptions/card_creation_test.rb
deleted file mode 100644
index 1ddc4bc1c..000000000
--- a/saas/test/controllers/accounts/subscriptions/card_creation_test.rb
+++ /dev/null
@@ -1,73 +0,0 @@
-require "test_helper"
-
-class Account::Subscriptions::CardCreationTest < ActionDispatch::IntegrationTest
- setup do
- sign_in_as :mike
- end
-
- # Nearing limits - shown in card creation footer
-
- test "admin sees nearing card limit notice" do
- accounts(:initech).update_column(:cards_count, 950)
-
- get card_draft_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
- Account.any_instance.stubs(:bytes_used).returns(600.megabytes)
-
- get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
-
- assert_response :success
- assert_match /upgrade to get more/i, response.body
- end
-
- # Exceeding limits - shown instead of create buttons
-
- test "admin sees exceeding card limit notice" do
- accounts(:initech).update_column(:cards_count, 1001)
-
- get card_draft_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
- Account.any_instance.stubs(:bytes_used).returns(1.1.gigabytes)
-
- get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
-
- assert_response :success
- assert_match /you’ve run out of.*free storage/i, response.body
- end
-
- # Paid accounts under limits - no notices
-
- test "paid account under limits sees no notices" do
- logout_and_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
- accounts(:initech).comp
-
- get card_draft_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/downgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb
deleted file mode 100644
index e4680a1f8..000000000
--- a/saas/test/controllers/accounts/subscriptions/downgrades_controller_test.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-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: 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") ]))
- 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
-
- 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/settings_test.rb b/saas/test/controllers/accounts/subscriptions/settings_test.rb
deleted file mode 100644
index 63feac2c5..000000000
--- a/saas/test/controllers/accounts/subscriptions/settings_test.rb
+++ /dev/null
@@ -1,62 +0,0 @@
-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_match /You’ve used.*3.*free cards out of 1,000/i, response.body
- 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
- 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
diff --git a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb b/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb
deleted file mode 100644
index 65e268af1..000000000
--- a/saas/test/controllers/accounts/subscriptions/upgrades_controller_test.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-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: Plan.paid)
- 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
-
- 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
diff --git a/saas/test/controllers/accounts/subscriptions_controller_test.rb b/saas/test/controllers/accounts/subscriptions_controller_test.rb
deleted file mode 100644
index 9e086df39..000000000
--- a/saas/test/controllers/accounts/subscriptions_controller_test.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-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
-
- 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
diff --git a/saas/test/controllers/admin/accounts_controller_test.rb b/saas/test/controllers/admin/accounts_controller_test.rb
deleted file mode 100644
index 3849c07a5..000000000
--- a/saas/test/controllers/admin/accounts_controller_test.rb
+++ /dev/null
@@ -1,54 +0,0 @@
-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: { 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/saas/test/controllers/admin/billing_waivers_controller_test.rb b/saas/test/controllers/admin/billing_waivers_controller_test.rb
deleted file mode 100644
index 8f7d14199..000000000
--- a/saas/test/controllers/admin/billing_waivers_controller_test.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-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/saas/test/controllers/admin/overridden_limits_controller_test.rb b/saas/test/controllers/admin/overridden_limits_controller_test.rb
deleted file mode 100644
index b660e08b0..000000000
--- a/saas/test/controllers/admin/overridden_limits_controller_test.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-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/card/limited_creation_test.rb b/saas/test/controllers/card/limited_creation_test.rb
deleted file mode 100644
index 4105091be..000000000
--- a/saas/test/controllers/card/limited_creation_test.rb
+++ /dev/null
@@ -1,55 +0,0 @@
-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
-
- 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
deleted file mode 100644
index 90e4e5696..000000000
--- a/saas/test/controllers/card/limited_publishing_test.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-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
-
- 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
diff --git a/saas/test/controllers/stripe/webhooks_controller_test.rb b/saas/test/controllers/stripe/webhooks_controller_test.rb
deleted file mode 100644
index 92f6b7508..000000000
--- a/saas/test/controllers/stripe/webhooks_controller_test.rb
+++ /dev/null
@@ -1,100 +0,0 @@
-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/saas/test/fixtures/account_subscriptions.yml b/saas/test/fixtures/account_subscriptions.yml
deleted file mode 100644
index a18ff961b..000000000
--- a/saas/test/fixtures/account_subscriptions.yml
+++ /dev/null
@@ -1,11 +0,0 @@
-_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/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb
deleted file mode 100644
index 0e70d37a5..000000000
--- a/saas/test/models/account/billing_test.rb
+++ /dev/null
@@ -1,93 +0,0 @@
-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
-
- test "cancel callback pauses subscription" do
- account = accounts(:"37s")
- user = users(:david)
-
- subscription = mock("subscription")
- subscription.expects(:pause).once
- account.stubs(:subscription).returns(subscription)
-
- account.cancel(initiated_by: user)
- end
-
- test "reactivate callback resumes subscription" do
- account = accounts(:"37s")
- user = users(:david)
-
- # First cancel with a subscription mock
- subscription_for_cancel = mock("subscription")
- subscription_for_cancel.expects(:pause).once
- account.stubs(:subscription).returns(subscription_for_cancel)
-
- account.cancel(initiated_by: user)
-
- # Now stub for reactivation
- subscription_for_reactivate = mock("subscription")
- subscription_for_reactivate.expects(:resume).once
- account.stubs(:subscription).returns(subscription_for_reactivate)
-
- account.reactivate
- end
-
- test "incinerate callback cancels subscription before destroying account" do
- account = accounts(:"37s")
-
- subscription = mock("subscription")
- subscription.expects(:cancel).once
- account.stubs(:subscription).returns(subscription)
-
- account.incinerate
- end
-
- test "owner_email_changed enqueues sync job when subscription exists" do
- account = accounts(:"37s")
- account.create_subscription!(
- stripe_customer_id: "cus_test",
- plan_key: "monthly_v1",
- status: "active"
- )
-
- assert_enqueued_with(job: Account::SyncStripeCustomerEmailJob, args: [ account.subscription ]) do
- account.owner_email_changed
- end
- end
-
- test "owner_email_changed does nothing without subscription" do
- account = accounts(:initech)
- account.subscription&.destroy
-
- assert_no_enqueued_jobs do
- account.owner_email_changed
- end
- end
-end
diff --git a/saas/test/models/account/limited_test.rb b/saas/test/models/account/limited_test.rb
deleted file mode 100644
index fff154451..000000000
--- a/saas/test/models/account/limited_test.rb
+++ /dev/null
@@ -1,122 +0,0 @@
-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 at exactly the threshold (remaining = 100)
- accounts(:initech).update_column(:cards_count, 900)
- assert accounts(:initech).nearing_plan_cards_limit?
-
- # Free plan over the threshold
- 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 at exactly the limit
- accounts(:initech).update_column(:cards_count, 1000)
- assert 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
-
- test "detect nearing storage limit" do
- # Paid plans have large storage limits
- accounts(:"37s").stubs(:bytes_used).returns(4.gigabytes)
- assert_not accounts(:"37s").nearing_plan_storage_limit?
-
- # Free plan not near limit
- accounts(:initech).stubs(:bytes_used).returns(400.megabytes)
- assert_not accounts(:initech).nearing_plan_storage_limit?
-
- # Free plan near limit
- 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).stubs(:bytes_used).returns(900.megabytes)
- assert_not accounts(:initech).exceeding_storage_limit?
-
- # Free plan over limit
- 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.stubs(:bytes_used).returns(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
diff --git a/saas/test/models/account/subscription_test.rb b/saas/test/models/account/subscription_test.rb
deleted file mode 100644
index f9f16f2bb..000000000
--- a/saas/test/models/account/subscription_test.rb
+++ /dev/null
@@ -1,245 +0,0 @@
-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
-
- test "paid scope includes active paid subscriptions" do
- subscription = Account::Subscription.create!(
- account: accounts(:initech), plan_key: "monthly_v1", status: "active",
- stripe_customer_id: "cus_paid"
- )
-
- assert_includes Account::Subscription.paid, subscription
- end
-
- test "paid scope includes trialing and past_due subscriptions" do
- trialing = Account::Subscription.create!(
- account: accounts(:initech), plan_key: "monthly_v1", status: "trialing",
- stripe_customer_id: "cus_trialing"
- )
- past_due = Account::Subscription.create!(
- account: accounts(:acme), plan_key: "monthly_extra_storage_v1", status: "past_due",
- stripe_customer_id: "cus_past_due"
- )
-
- assert_includes Account::Subscription.paid, trialing
- assert_includes Account::Subscription.paid, past_due
- end
-
- test "paid scope excludes free plan subscriptions" do
- subscription = Account::Subscription.create!(
- account: accounts(:initech), plan_key: "free_v1", status: "active",
- stripe_customer_id: "cus_free"
- )
-
- assert_not_includes Account::Subscription.paid, subscription
- end
-
- test "paid scope excludes canceled subscriptions" do
- subscription = Account::Subscription.create!(
- account: accounts(:initech), plan_key: "monthly_v1", status: "canceled",
- stripe_customer_id: "cus_canceled"
- )
-
- assert_not_includes Account::Subscription.paid, subscription
- end
-
- test "paid scope excludes comped accounts" do
- subscription = Account::Subscription.create!(
- account: accounts(:initech), plan_key: "monthly_v1", status: "active",
- stripe_customer_id: "cus_comped"
- )
- Account::BillingWaiver.create!(account: accounts(:initech))
-
- assert_not_includes Account::Subscription.paid, subscription
- end
-
- test "pause pauses Stripe subscription with void behavior" do
- subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
-
- Stripe::Subscription.expects(:update).with(
- "sub_123",
- pause_collection: { behavior: "void" }
- ).returns(true)
-
- subscription.pause
- end
-
- test "pause does nothing when no stripe_subscription_id" do
- subscription = Account::Subscription.new(stripe_subscription_id: nil)
-
- Stripe::Subscription.expects(:update).never
-
- subscription.pause
- end
-
- test "pause raises on Stripe errors" do
- subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
-
- Stripe::Subscription.stubs(:update).raises(
- Stripe::APIConnectionError.new("Network error")
- )
-
- assert_raises Stripe::APIConnectionError do
- subscription.pause
- end
- end
-
- test "resume resumes Stripe subscription" do
- subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
-
- Stripe::Subscription.expects(:update).with(
- "sub_123",
- pause_collection: ""
- ).returns(true)
-
- subscription.resume
- end
-
- test "resume does nothing when no stripe_subscription_id" do
- subscription = Account::Subscription.new(stripe_subscription_id: nil)
-
- Stripe::Subscription.expects(:update).never
-
- subscription.resume
- end
-
- test "resume raises on Stripe errors" do
- subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
-
- Stripe::Subscription.stubs(:update).raises(
- Stripe::AuthenticationError.new("Invalid API key")
- )
-
- assert_raises Stripe::AuthenticationError do
- subscription.resume
- end
- end
-
- test "cancel cancels Stripe subscription" do
- subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
-
- Stripe::Subscription.expects(:cancel).with("sub_123").returns(true)
-
- subscription.cancel
- end
-
- test "cancel does nothing when no stripe_subscription_id" do
- subscription = Account::Subscription.new(stripe_subscription_id: nil)
-
- Stripe::Subscription.expects(:cancel).never
-
- subscription.cancel
- end
-
- test "cancel treats 404 as success" do
- subscription = Account::Subscription.new(stripe_subscription_id: "sub_deleted")
-
- Stripe::Subscription.stubs(:cancel).raises(
- Stripe::InvalidRequestError.new("No such subscription", {})
- )
-
- assert_nothing_raised do
- subscription.cancel
- end
- end
-
- test "cancel raises on other Stripe errors" do
- subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
-
- Stripe::Subscription.stubs(:cancel).raises(
- Stripe::RateLimitError.new("Rate limit exceeded")
- )
-
- assert_raises Stripe::RateLimitError do
- subscription.cancel
- end
- end
-
- test "sync_customer_email_to_stripe updates Stripe customer with owner email" do
- account = accounts(:"37s")
- owner = account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
- subscription = account.create_subscription!(
- stripe_customer_id: "cus_test",
- plan_key: "monthly_v1",
- status: "active"
- )
-
- Stripe::Customer.expects(:update).with("cus_test", email: owner.identity.email_address).once
-
- subscription.sync_customer_email_to_stripe
- end
-
- test "sync_customer_email_to_stripe does nothing without stripe_customer_id" do
- account = accounts(:"37s")
- subscription = account.build_subscription(
- stripe_customer_id: nil,
- plan_key: "free_v1",
- status: "active"
- )
-
- Stripe::Customer.expects(:update).never
-
- subscription.sync_customer_email_to_stripe
- end
-
- test "sync_customer_email_to_stripe does nothing without owner" do
- account = accounts(:"37s")
- account.users.update_all(role: :member)
- subscription = account.create_subscription!(
- stripe_customer_id: "cus_test",
- plan_key: "monthly_v1",
- status: "active"
- )
-
- Stripe::Customer.expects(:update).never
-
- subscription.sync_customer_email_to_stripe
- end
-
- test "sync_customer_email_to_stripe does nothing when owner has no identity" do
- account = accounts(:"37s")
- owner = account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
- owner.update_column(:identity_id, nil)
- subscription = account.create_subscription!(
- stripe_customer_id: "cus_test",
- plan_key: "monthly_v1",
- status: "active"
- )
-
- Stripe::Customer.expects(:update).never
-
- subscription.sync_customer_email_to_stripe
- end
-
- test "sync_customer_email_to_stripe treats deleted customer as success" do
- account = accounts(:"37s")
- account.users.find_by(role: :owner) || account.users.first.tap { |u| u.update!(role: :owner) }
- subscription = account.create_subscription!(
- stripe_customer_id: "cus_deleted",
- plan_key: "monthly_v1",
- status: "active"
- )
-
- Stripe::Customer.stubs(:update).raises(
- Stripe::InvalidRequestError.new("No such customer", {})
- )
-
- assert_nothing_raised do
- subscription.sync_customer_email_to_stripe
- end
- end
-end
diff --git a/saas/test/models/plan_test.rb b/saas/test/models/plan_test.rb
deleted file mode 100644
index 54ed279d6..000000000
--- a/saas/test/models/plan_test.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-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
-
- 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
diff --git a/saas/test/models/user/notifies_account_of_email_change_test.rb b/saas/test/models/user/notifies_account_of_email_change_test.rb
deleted file mode 100644
index 442a89fff..000000000
--- a/saas/test/models/user/notifies_account_of_email_change_test.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-require "test_helper"
-
-class User::NotifiesAccountOfEmailChangeTest < ActiveSupport::TestCase
- setup do
- @account = accounts(:"37s")
- @owner = @account.users.find_by(role: :owner) || @account.users.first.tap { |u| u.update!(role: :owner) }
- @member = @account.users.where.not(id: @owner.id).first || @account.users.create!(
- name: "Member",
- identity: Identity.create!(email_address: "member@example.com"),
- role: :member
- )
- end
-
- test "notifies account when owner changes email" do
- @account.expects(:owner_email_changed).once
-
- new_identity = Identity.create!(email_address: "new-owner@example.com")
- @owner.update!(identity: new_identity)
- end
-
- test "does not notify account when non-owner changes email" do
- @account.expects(:owner_email_changed).never
-
- new_identity = Identity.create!(email_address: "new-member@example.com")
- @member.update!(identity: new_identity)
- end
-
- test "does not notify account when owner is deactivated" do
- @account.expects(:owner_email_changed).never
-
- @owner.update!(identity: nil)
- end
-
- test "does not notify account when identity unchanged" do
- @account.expects(:owner_email_changed).never
-
- @owner.update!(name: "New Name")
- end
-
- test "notifies account when user becomes owner" do
- @account.expects(:owner_email_changed).once
-
- @member.update!(role: :owner)
- end
-
- test "does not notify account when owner becomes member" do
- @account.expects(:owner_email_changed).never
-
- @owner.update!(role: :member)
- end
-end
From 0b29a67e17e1b8438ae1ae2e37e7f3365c7954f5 Mon Sep 17 00:00:00 2001
From: Adrien Maston
Date: Tue, 17 Mar 2026 11:09:37 +0100
Subject: [PATCH 061/107] Change date and closedBy properties for a more
generic description
---
.../controllers/bridge/stamp_controller.js | 10 +++++++++-
.../initializers/bridge/bridge_element.js | 16 ----------------
app/views/cards/display/common/_stamp.html.erb | 6 ++----
3 files changed, 11 insertions(+), 21 deletions(-)
diff --git a/app/javascript/controllers/bridge/stamp_controller.js b/app/javascript/controllers/bridge/stamp_controller.js
index 6c04d3140..2be17dc35 100644
--- a/app/javascript/controllers/bridge/stamp_controller.js
+++ b/app/javascript/controllers/bridge/stamp_controller.js
@@ -14,6 +14,14 @@ export default class extends BridgeComponent {
}
notifyBridgeOfConnect() {
- this.send("connect", this.bridgeElement.getStamp())
+ this.send("connect", this.#data)
+ }
+
+ get #data() {
+ const bridgeElement = this.bridgeElement
+ return {
+ title: bridgeElement.title,
+ description: bridgeElement.bridgeAttribute("description") ?? null
+ }
}
}
diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js
index b118c9265..76f0db8a7 100644
--- a/app/javascript/initializers/bridge/bridge_element.js
+++ b/app/javascript/initializers/bridge/bridge_element.js
@@ -9,14 +9,6 @@ BridgeElement.prototype.getButton = function() {
}
}
-BridgeElement.prototype.getStamp = function() {
- return {
- title: this.title,
- date: this.getDate(),
- closedBy: this.getClosedBy()
- }
-}
-
BridgeElement.prototype.getIcon = function() {
const url = this.bridgeAttribute("icon-url")
@@ -27,14 +19,6 @@ BridgeElement.prototype.getIcon = function() {
return null
}
-BridgeElement.prototype.getDate = function() {
- return this.bridgeAttribute("date") ?? null
-}
-
-BridgeElement.prototype.getClosedBy = function() {
- return this.bridgeAttribute("closed-by") ?? null
-}
-
BridgeElement.prototype.getDisplayTitle = function() {
return !!this.bridgeAttribute("display-title")
}
diff --git a/app/views/cards/display/common/_stamp.html.erb b/app/views/cards/display/common/_stamp.html.erb
index 75c11ad4a..3416fff59 100644
--- a/app/views/cards/display/common/_stamp.html.erb
+++ b/app/views/cards/display/common/_stamp.html.erb
@@ -2,8 +2,7 @@
<%= tag.div class: token_list("card__closed", "card__closed--system": card.postponed_by&.system?), data: {
controller: "bridge--stamp",
bridge_title: "Not Now",
- bridge_date: card.postponed_at.strftime("%b %d, %Y"),
- bridge_closed_by: card.postponed_by&.familiar_name
+ bridge_description: card.postponed_at.strftime("%b %d, %Y")
} do %>
Not Now<%= card.postponed_at.strftime("%b %d, %Y") %>
@@ -15,8 +14,7 @@
<%= tag.div class: "card__closed", data: {
controller: "bridge--stamp",
bridge_title: "Done",
- bridge_date: card.closed_at.strftime("%b %d, %Y"),
- bridge_closed_by: card.closed_by.familiar_name
+ bridge_description: card.closed_at.strftime("%b %d, %Y")
} do %>
Done<%= card.closed_at.strftime("%b %d, %Y") %>
From 2d1e2019460a6a3c52f995ea0d9c38dc52dec9b4 Mon Sep 17 00:00:00 2001
From: Adrien Maston
Date: Tue, 17 Mar 2026 11:57:59 +0100
Subject: [PATCH 062/107] Add scope selector value
---
app/javascript/controllers/bridge/stamp_controller.js | 11 +++++------
app/views/cards/display/common/_stamp.html.erb | 2 ++
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/app/javascript/controllers/bridge/stamp_controller.js b/app/javascript/controllers/bridge/stamp_controller.js
index 2be17dc35..735e4c830 100644
--- a/app/javascript/controllers/bridge/stamp_controller.js
+++ b/app/javascript/controllers/bridge/stamp_controller.js
@@ -2,10 +2,13 @@ import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
export default class extends BridgeComponent {
static component = "stamp"
+ static values = { scopeSelector: { type: String, default: "body" } }
connect() {
- super.connect()
- this.notifyBridgeOfConnect()
+ if (this.element.closest(this.scopeSelectorValue)) {
+ super.connect()
+ this.send("connect", this.#data)
+ }
}
disconnect() {
@@ -13,10 +16,6 @@ export default class extends BridgeComponent {
this.send("disconnect")
}
- notifyBridgeOfConnect() {
- this.send("connect", this.#data)
- }
-
get #data() {
const bridgeElement = this.bridgeElement
return {
diff --git a/app/views/cards/display/common/_stamp.html.erb b/app/views/cards/display/common/_stamp.html.erb
index 3416fff59..86190e0f6 100644
--- a/app/views/cards/display/common/_stamp.html.erb
+++ b/app/views/cards/display/common/_stamp.html.erb
@@ -1,6 +1,7 @@
<% if card.postponed? %>
<%= tag.div class: token_list("card__closed", "card__closed--system": card.postponed_by&.system?), data: {
controller: "bridge--stamp",
+ bridge__stamp_scope_selector_value: ".card-perma",
bridge_title: "Not Now",
bridge_description: card.postponed_at.strftime("%b %d, %Y")
} do %>
@@ -13,6 +14,7 @@
<% if card.closed? %>
<%= tag.div class: "card__closed", data: {
controller: "bridge--stamp",
+ bridge__stamp_scope_selector_value: ".card-perma",
bridge_title: "Done",
bridge_description: card.closed_at.strftime("%b %d, %Y")
} do %>
From a761d809aef1763fa9e595f5cdd23fa7d6f1aae9 Mon Sep 17 00:00:00 2001
From: Jorge Manrubia
Date: Tue, 17 Mar 2026 12:10:24 +0100
Subject: [PATCH 063/107] Add 1GB storage limit with self-host notice for SaaS
Limit free storage to 1GB on the SaaS version. When exceeded, card
publishing, comment creation, and JSON card creation are blocked,
and the card footer shows a "self-host Fizzy for unlimited storage"
notice instead of the create buttons. A nearing-limit warning
appears when usage exceeds 500MB.
Uses the same SaaS engine patterns as the removed billing system:
model concern on Account, controller concerns included via
config.to_prepare, view partials in saas/ with Fizzy.saas? guards
in the main app.
---
app/views/cards/_messages.html.erb | 6 ++-
.../cards/container/footer/_create.html.erb | 2 +
app/views/cards/drafts/_container.html.erb | 6 ++-
.../concerns/card/storage_limited.rb | 8 ++++
.../card/storage_limited/commenting.rb | 9 ++++
.../concerns/card/storage_limited/creation.rb | 9 ++++
.../card/storage_limited/publishing.rb | 9 ++++
saas/app/models/account/storage_limited.rb | 14 ++++++
.../views/cards/comments/saas/_new.html.erb | 5 ++
.../saas/_storage_limit_exceeded.html.erb | 4 ++
.../container/footer/saas/_create.html.erb | 5 ++
.../saas/_storage_limit_exceeded.html.erb | 8 ++++
.../saas/_storage_limit_notice.html.erb | 5 ++
saas/lib/fizzy/saas/engine.rb | 4 ++
.../card/storage_limited/commenting_test.rb | 29 ++++++++++++
.../card/storage_limited/creation_test.rb | 47 +++++++++++++++++++
.../card/storage_limited/publishing_test.rb | 23 +++++++++
.../controllers/card/storage_limited_test.rb | 27 +++++++++++
.../comment/storage_limited_test.rb | 25 ++++++++++
.../models/account/storage_limited_test.rb | 39 +++++++++++++++
20 files changed, 282 insertions(+), 2 deletions(-)
create mode 100644 saas/app/controllers/concerns/card/storage_limited.rb
create mode 100644 saas/app/controllers/concerns/card/storage_limited/commenting.rb
create mode 100644 saas/app/controllers/concerns/card/storage_limited/creation.rb
create mode 100644 saas/app/controllers/concerns/card/storage_limited/publishing.rb
create mode 100644 saas/app/models/account/storage_limited.rb
create mode 100644 saas/app/views/cards/comments/saas/_new.html.erb
create mode 100644 saas/app/views/cards/comments/saas/_storage_limit_exceeded.html.erb
create mode 100644 saas/app/views/cards/container/footer/saas/_create.html.erb
create mode 100644 saas/app/views/cards/container/footer/saas/_storage_limit_exceeded.html.erb
create mode 100644 saas/app/views/cards/container/footer/saas/_storage_limit_notice.html.erb
create mode 100644 saas/test/controllers/card/storage_limited/commenting_test.rb
create mode 100644 saas/test/controllers/card/storage_limited/creation_test.rb
create mode 100644 saas/test/controllers/card/storage_limited/publishing_test.rb
create mode 100644 saas/test/controllers/card/storage_limited_test.rb
create mode 100644 saas/test/controllers/comment/storage_limited_test.rb
create mode 100644 saas/test/models/account/storage_limited_test.rb
diff --git a/app/views/cards/_messages.html.erb b/app/views/cards/_messages.html.erb
index a2784e72a..c51ab6d8b 100644
--- a/app/views/cards/_messages.html.erb
+++ b/app/views/cards/_messages.html.erb
@@ -1,7 +1,11 @@
<%= messages_tag(card) do %>
<% if card.published? %>
<%= render partial: "cards/comments/comment", collection: card.comments.preloaded.chronologically, cached: true %>
- <%= render "cards/comments/new", card: card %>
+ <% if Fizzy.saas? %>
+ <%= render "cards/comments/saas/new", card: card %>
+ <% else %>
+ <%= render "cards/comments/new", card: card %>
+ <% end %>
<%= render "cards/comments/watchers", card: card %>
<% end %>
diff --git a/app/views/cards/container/footer/_create.html.erb b/app/views/cards/container/footer/_create.html.erb
index 648beffd1..4e5a83e20 100644
--- a/app/views/cards/container/footer/_create.html.erb
+++ b/app/views/cards/container/footer/_create.html.erb
@@ -13,5 +13,7 @@
Create and add another
<% end %>
+
+ <%= render "cards/container/footer/saas/storage_limit_notice" if Fizzy.saas? %>
diff --git a/app/views/cards/drafts/_container.html.erb b/app/views/cards/drafts/_container.html.erb
index 9ffc04711..fa7134b55 100644
--- a/app/views/cards/drafts/_container.html.erb
+++ b/app/views/cards/drafts/_container.html.erb
@@ -26,5 +26,9 @@
<% end %>
- <%= render "cards/container/footer/create", card: card %>
+ <% if Fizzy.saas? %>
+ <%= render "cards/container/footer/saas/create", card: card %>
+ <% else %>
+ <%= render "cards/container/footer/create", card: card %>
+ <% end %>
diff --git a/saas/app/controllers/concerns/card/storage_limited.rb b/saas/app/controllers/concerns/card/storage_limited.rb
new file mode 100644
index 000000000..05357f43f
--- /dev/null
+++ b/saas/app/controllers/concerns/card/storage_limited.rb
@@ -0,0 +1,8 @@
+module Card::StorageLimited
+ extend ActiveSupport::Concern
+
+ private
+ def ensure_within_storage_limit
+ head :forbidden if Current.account.exceeding_storage_limit?
+ end
+end
diff --git a/saas/app/controllers/concerns/card/storage_limited/commenting.rb b/saas/app/controllers/concerns/card/storage_limited/commenting.rb
new file mode 100644
index 000000000..a4b7e3307
--- /dev/null
+++ b/saas/app/controllers/concerns/card/storage_limited/commenting.rb
@@ -0,0 +1,9 @@
+module Card::StorageLimited::Commenting
+ extend ActiveSupport::Concern
+
+ included do
+ include Card::StorageLimited
+
+ before_action :ensure_within_storage_limit, only: :create
+ end
+end
diff --git a/saas/app/controllers/concerns/card/storage_limited/creation.rb b/saas/app/controllers/concerns/card/storage_limited/creation.rb
new file mode 100644
index 000000000..9d894779d
--- /dev/null
+++ b/saas/app/controllers/concerns/card/storage_limited/creation.rb
@@ -0,0 +1,9 @@
+module Card::StorageLimited::Creation
+ extend ActiveSupport::Concern
+
+ included do
+ include Card::StorageLimited
+
+ before_action :ensure_within_storage_limit, only: :create, if: -> { request.format.json? }
+ end
+end
diff --git a/saas/app/controllers/concerns/card/storage_limited/publishing.rb b/saas/app/controllers/concerns/card/storage_limited/publishing.rb
new file mode 100644
index 000000000..abf7731a4
--- /dev/null
+++ b/saas/app/controllers/concerns/card/storage_limited/publishing.rb
@@ -0,0 +1,9 @@
+module Card::StorageLimited::Publishing
+ extend ActiveSupport::Concern
+
+ included do
+ include Card::StorageLimited
+
+ before_action :ensure_within_storage_limit, only: :create
+ end
+end
diff --git a/saas/app/models/account/storage_limited.rb b/saas/app/models/account/storage_limited.rb
new file mode 100644
index 000000000..dbab32b5d
--- /dev/null
+++ b/saas/app/models/account/storage_limited.rb
@@ -0,0 +1,14 @@
+module Account::StorageLimited
+ extend ActiveSupport::Concern
+
+ STORAGE_LIMIT = 1.gigabyte
+ NEAR_STORAGE_LIMIT_THRESHOLD = 500.megabytes
+
+ def exceeding_storage_limit?
+ bytes_used > STORAGE_LIMIT
+ end
+
+ def nearing_storage_limit?
+ !exceeding_storage_limit? && bytes_used > STORAGE_LIMIT - NEAR_STORAGE_LIMIT_THRESHOLD
+ end
+end
diff --git a/saas/app/views/cards/comments/saas/_new.html.erb b/saas/app/views/cards/comments/saas/_new.html.erb
new file mode 100644
index 000000000..74cb6dd43
--- /dev/null
+++ b/saas/app/views/cards/comments/saas/_new.html.erb
@@ -0,0 +1,5 @@
+<% if Current.account.exceeding_storage_limit? %>
+ <%= render "cards/comments/saas/storage_limit_exceeded" %>
+<% else %>
+ <%= render "cards/comments/new", card: card %>
+<% end %>
diff --git a/saas/app/views/cards/comments/saas/_storage_limit_exceeded.html.erb b/saas/app/views/cards/comments/saas/_storage_limit_exceeded.html.erb
new file mode 100644
index 000000000..6be5fb587
--- /dev/null
+++ b/saas/app/views/cards/comments/saas/_storage_limit_exceeded.html.erb
@@ -0,0 +1,4 @@
+
+ This account has used all <%= number_to_human_size(Account::StorageLimited::STORAGE_LIMIT) %> of free storage.
+ <%= link_to "Self-host Fizzy", "https://github.com/basecamp/fizzy", target: "_blank" %> for unlimited storage.
+
diff --git a/saas/app/views/cards/container/footer/saas/_create.html.erb b/saas/app/views/cards/container/footer/saas/_create.html.erb
new file mode 100644
index 000000000..e5544e576
--- /dev/null
+++ b/saas/app/views/cards/container/footer/saas/_create.html.erb
@@ -0,0 +1,5 @@
+<% if Current.account.exceeding_storage_limit? %>
+ <%= render "cards/container/footer/saas/storage_limit_exceeded" %>
+<% else %>
+ <%= render "cards/container/footer/create", card: card %>
+<% end %>
diff --git a/saas/app/views/cards/container/footer/saas/_storage_limit_exceeded.html.erb b/saas/app/views/cards/container/footer/saas/_storage_limit_exceeded.html.erb
new file mode 100644
index 000000000..6fb95be11
--- /dev/null
+++ b/saas/app/views/cards/container/footer/saas/_storage_limit_exceeded.html.erb
@@ -0,0 +1,8 @@
+
+
+ This account has used all <%= number_to_human_size(Account::StorageLimited::STORAGE_LIMIT) %> of free storage.
+
diff --git a/saas/app/views/cards/container/footer/saas/_storage_limit_notice.html.erb b/saas/app/views/cards/container/footer/saas/_storage_limit_notice.html.erb
new file mode 100644
index 000000000..942041fb4
--- /dev/null
+++ b/saas/app/views/cards/container/footer/saas/_storage_limit_notice.html.erb
@@ -0,0 +1,5 @@
+<% if Current.account.nearing_storage_limit? %>
+
+ This account has used <%= number_to_human_size(Current.account.bytes_used) %> of <%= number_to_human_size(Account::StorageLimited::STORAGE_LIMIT) %> storage.
+
+<% end %>
diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb
index dea321cfc..673be1147 100644
--- a/saas/lib/fizzy/saas/engine.rb
+++ b/saas/lib/fizzy/saas/engine.rb
@@ -133,10 +133,14 @@ module Fizzy
end
config.to_prepare do
+ ::Account.include Account::StorageLimited
::Identity.include Authorization::Identity, Identity::Devices
::Session.include Session::Devices
::Signup.prepend Signup
ApplicationController.include Authorization::Controller
+ CardsController.include(Card::StorageLimited::Creation)
+ Cards::CommentsController.include(Card::StorageLimited::Commenting)
+ Cards::PublishesController.include(Card::StorageLimited::Publishing)
Notification.register_push_target(:native)
diff --git a/saas/test/controllers/card/storage_limited/commenting_test.rb b/saas/test/controllers/card/storage_limited/commenting_test.rb
new file mode 100644
index 000000000..e32015130
--- /dev/null
+++ b/saas/test/controllers/card/storage_limited/commenting_test.rb
@@ -0,0 +1,29 @@
+require "test_helper"
+
+class Card::StorageLimited::CommentingTest < ActionDispatch::IntegrationTest
+ test "cannot create comments when storage limit exceeded" do
+ sign_in_as :david
+
+ Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)
+
+ assert_no_difference -> { Comment.count } do
+ post card_comments_path(cards(:logo), script_name: accounts(:"37s").slug),
+ params: { comment: { body: "Blocked comment" } },
+ as: :turbo_stream
+ end
+
+ assert_response :forbidden
+ end
+
+ test "can create comments when under storage limit" do
+ sign_in_as :david
+
+ assert_difference -> { Comment.count } do
+ post card_comments_path(cards(:logo), script_name: accounts(:"37s").slug),
+ params: { comment: { body: "Allowed comment" } },
+ as: :turbo_stream
+ end
+
+ assert_response :success
+ end
+end
diff --git a/saas/test/controllers/card/storage_limited/creation_test.rb b/saas/test/controllers/card/storage_limited/creation_test.rb
new file mode 100644
index 000000000..ab04774fe
--- /dev/null
+++ b/saas/test/controllers/card/storage_limited/creation_test.rb
@@ -0,0 +1,47 @@
+require "test_helper"
+
+class Card::StorageLimited::CreationTest < ActionDispatch::IntegrationTest
+ test "cannot create cards via JSON when storage limit exceeded" do
+ sign_in_as :mike
+
+ Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)
+
+ assert_no_difference -> { Card.count } do
+ post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug),
+ params: { card: { title: "Blocked card" } },
+ as: :json
+ end
+
+ assert_response :forbidden
+ end
+
+ test "can create cards via HTML when storage limit exceeded since they become drafts" do
+ sign_in_as :mike
+
+ Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)
+ accounts(:initech).update_column(:cards_count, 100)
+ 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 "can create cards via JSON when under storage limit" do
+ sign_in_as :mike
+
+ Account.any_instance.stubs(:bytes_used).returns(500.megabytes)
+ accounts(:initech).update_column(:cards_count, 100)
+
+ assert_difference -> { Card.count } do
+ post board_cards_path(boards(:miltons_wish_list), script_name: accounts(:initech).slug),
+ params: { card: { title: "Allowed card" } },
+ as: :json
+ end
+
+ assert_response :created
+ end
+end
diff --git a/saas/test/controllers/card/storage_limited/publishing_test.rb b/saas/test/controllers/card/storage_limited/publishing_test.rb
new file mode 100644
index 000000000..94e9b8e4f
--- /dev/null
+++ b/saas/test/controllers/card/storage_limited/publishing_test.rb
@@ -0,0 +1,23 @@
+require "test_helper"
+
+class Card::StorageLimited::PublishingTest < ActionDispatch::IntegrationTest
+ test "cannot publish cards when storage limit exceeded" do
+ sign_in_as :mike
+
+ Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)
+
+ post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
+
+ assert_response :forbidden
+ assert cards(:unfinished_thoughts).reload.drafted?
+ end
+
+ test "can publish cards when under storage limit" do
+ sign_in_as :mike
+
+ post card_publish_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
+
+ assert_response :redirect
+ assert cards(:unfinished_thoughts).reload.published?
+ end
+end
diff --git a/saas/test/controllers/card/storage_limited_test.rb b/saas/test/controllers/card/storage_limited_test.rb
new file mode 100644
index 000000000..69790c363
--- /dev/null
+++ b/saas/test/controllers/card/storage_limited_test.rb
@@ -0,0 +1,27 @@
+require "test_helper"
+
+class Card::StorageLimitedTest < ActionDispatch::IntegrationTest
+ test "draft card shows storage limit notice instead of create buttons when limit exceeded" do
+ sign_in_as :mike
+
+ Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)
+
+ get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
+
+ assert_response :success
+ assert_select ".card-perma__notch" do
+ assert_select "strong", text: /used all/
+ assert_select "a[href='https://github.com/basecamp/fizzy']", text: "Self-host Fizzy"
+ end
+ assert_select ".card-perma__notch-new-card-buttons", count: 0
+ end
+
+ test "draft card shows create buttons when under storage limit" do
+ sign_in_as :mike
+
+ get card_draft_path(cards(:unfinished_thoughts), script_name: accounts(:initech).slug)
+
+ assert_response :success
+ assert_select ".card-perma__notch-new-card-buttons"
+ end
+end
diff --git a/saas/test/controllers/comment/storage_limited_test.rb b/saas/test/controllers/comment/storage_limited_test.rb
new file mode 100644
index 000000000..4137971b1
--- /dev/null
+++ b/saas/test/controllers/comment/storage_limited_test.rb
@@ -0,0 +1,25 @@
+require "test_helper"
+
+class Comment::StorageLimitedTest < ActionDispatch::IntegrationTest
+ test "published card shows storage limit notice instead of comment form when limit exceeded" do
+ sign_in_as :david
+
+ Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)
+
+ get card_path(cards(:logo), script_name: accounts(:"37s").slug)
+
+ assert_response :success
+ assert_select "strong", text: /used all/
+ assert_select "a[href='https://github.com/basecamp/fizzy']", text: "Self-host Fizzy"
+ assert_select "##{dom_id(cards(:logo), :new_comment)}", count: 0
+ end
+
+ test "published card shows comment form when under storage limit" do
+ sign_in_as :david
+
+ get card_path(cards(:logo), script_name: accounts(:"37s").slug)
+
+ assert_response :success
+ assert_select "##{dom_id(cards(:logo), :new_comment)}"
+ end
+end
diff --git a/saas/test/models/account/storage_limited_test.rb b/saas/test/models/account/storage_limited_test.rb
new file mode 100644
index 000000000..f587d9ef1
--- /dev/null
+++ b/saas/test/models/account/storage_limited_test.rb
@@ -0,0 +1,39 @@
+require "test_helper"
+
+class Account::StorageLimitedTest < ActiveSupport::TestCase
+ test "exceeding storage limit when bytes used exceeds 1 GB" do
+ Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)
+
+ assert accounts(:initech).exceeding_storage_limit?
+ end
+
+ test "not exceeding storage limit when bytes used equals 1 GB" do
+ Account.any_instance.stubs(:bytes_used).returns(1.gigabyte)
+
+ assert_not accounts(:initech).exceeding_storage_limit?
+ end
+
+ test "not exceeding storage limit when under 1 GB" do
+ Account.any_instance.stubs(:bytes_used).returns(500.megabytes)
+
+ assert_not accounts(:initech).exceeding_storage_limit?
+ end
+
+ test "nearing storage limit when within 500 MB of the limit" do
+ Account.any_instance.stubs(:bytes_used).returns(600.megabytes)
+
+ assert accounts(:initech).nearing_storage_limit?
+ end
+
+ test "not nearing storage limit when well under the threshold" do
+ Account.any_instance.stubs(:bytes_used).returns(400.megabytes)
+
+ assert_not accounts(:initech).nearing_storage_limit?
+ end
+
+ test "not nearing storage limit when already exceeding it" do
+ Account.any_instance.stubs(:bytes_used).returns(1.gigabyte + 1)
+
+ assert_not accounts(:initech).nearing_storage_limit?
+ end
+end
From 00b588fcef0370ff01f9e2d80e53d251cb4a30cd Mon Sep 17 00:00:00 2001
From: Jay Ohms
Date: Tue, 17 Mar 2026 07:43:13 -0400
Subject: [PATCH 064/107] Don't display the "Mark done" bridge component button
when the card has been marked as "Not Now"
---
.../cards/container/_closure_buttons.html.erb | 20 ++++++++++++++++---
1 file changed, 17 insertions(+), 3 deletions(-)
diff --git a/app/views/cards/container/_closure_buttons.html.erb b/app/views/cards/container/_closure_buttons.html.erb
index 27db6590e..99fcad627 100644
--- a/app/views/cards/container/_closure_buttons.html.erb
+++ b/app/views/cards/container/_closure_buttons.html.erb
@@ -1,13 +1,27 @@
<%= link_to edit_card_path(card), class: "btn btn--circle-mobile borderless",
- data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Edit", turbo_frame: dom_id(card, :edit) } do %>
+ data: {
+ controller: "hotkey",
+ action: "keydown.e@document->hotkey#click",
+ bridge__overflow_menu_target: "item",
+ bridge_title: "Edit",
+ turbo_frame: dom_id(card, :edit)
+ } do %>
<%= icon_tag "pencil", class: "icon--mobile-only" %>
Edite
<% end %>
- <%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless",
- data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" },
+ <%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless hide-on-native",
+ data: {
+ controller: "hotkey",
+ form_target: "submit",
+ bridge__buttons_target: ("button" unless card.postponed?),
+ bridge_title: "Mark done",
+ bridge_display_as_primary_action: true,
+ bridge_display_title: true, bridge_icon_url: bridge_icon("check"),
+ action: "keydown.d@document->hotkey#click"
+ },
form: { data: { controller: "form" } } do %>
<%= icon_tag "check", class: "icon--mobile-only" %>
Mark as Done
From 5c1f3a1f4f375eaf4104cc47935dbb578c29f68f Mon Sep 17 00:00:00 2001
From: Jay Ohms
Date: Tue, 17 Mar 2026 08:12:57 -0400
Subject: [PATCH 065/107] Refactor stamp component and observe element changes
so subsequent "connect" messages will be sent
---
.../controllers/bridge/stamp_controller.js | 34 ++++++++++++++-----
1 file changed, 26 insertions(+), 8 deletions(-)
diff --git a/app/javascript/controllers/bridge/stamp_controller.js b/app/javascript/controllers/bridge/stamp_controller.js
index 735e4c830..747f5d48d 100644
--- a/app/javascript/controllers/bridge/stamp_controller.js
+++ b/app/javascript/controllers/bridge/stamp_controller.js
@@ -5,22 +5,40 @@ export default class extends BridgeComponent {
static values = { scopeSelector: { type: String, default: "body" } }
connect() {
+ super.connect()
+
if (this.element.closest(this.scopeSelectorValue)) {
- super.connect()
- this.send("connect", this.#data)
+ this.notifyBridgeOfConnect()
+ this.#observeStamp()
}
}
disconnect() {
super.disconnect()
+ this.notifyBridgeOfDisconnect()
+ this.stampObserver?.disconnect()
+ }
+
+ notifyBridgeOfConnect() {
+ const bridgeElement = this.bridgeElement
+
+ this.send("connect", {
+ title: bridgeElement.title,
+ description: bridgeElement.bridgeAttribute("description")
+ })
+ }
+
+ notifyBridgeOfDisconnect() {
this.send("disconnect")
}
- get #data() {
- const bridgeElement = this.bridgeElement
- return {
- title: bridgeElement.title,
- description: bridgeElement.bridgeAttribute("description") ?? null
- }
+ #observeStamp() {
+ this.stampObserver = new MutationObserver(() => {
+ this.notifyBridgeOfConnect()
+ })
+
+ this.stampObserver.observe(this.element, {
+ attributes: true
+ })
}
}
From 09e4bb13d3c8057dcb52469da0bf5f9a26b9a48f Mon Sep 17 00:00:00 2001
From: Jay Ohms
Date: Tue, 17 Mar 2026 09:59:45 -0400
Subject: [PATCH 066/107] Preset the data-bridge-platform and
data-bridge-components on the body element from the user-agent. This prevents
css flashes before the native bridge is registered in the app.
---
app/models/application_platform.rb | 20 ++++++++++++++++++++
app/views/layouts/application.html.erb | 5 ++++-
2 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/app/models/application_platform.rb b/app/models/application_platform.rb
index 9b2695cd5..388c183eb 100644
--- a/app/models/application_platform.rb
+++ b/app/models/application_platform.rb
@@ -43,6 +43,17 @@ class ApplicationPlatform < PlatformAgent
operating_system == "Windows"
end
+ def bridge_name
+ case
+ when native? && android? then :android
+ when native? && ios? then :ios
+ end
+ end
+
+ def bridge_components
+ extract_list_from_native_user_agent("bridge-components")
+ end
+
def type
if native? && android?
"native android"
@@ -67,4 +78,13 @@ class ApplicationPlatform < PlatformAgent
os =~ /Linux/ ? "Linux" : os
end
end
+
+ private
+ def extract_list_from_native_user_agent(prefix)
+ if native?
+ user_agent.to_s.match(/#{prefix}: \[(.+)\]/) { |matches| matches[1] }.to_s
+ else
+ ""
+ end
+ end
end
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 475a7a79e..b83d199d2 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -5,7 +5,10 @@
+ data-platform="<%= platform.type %>"
+ data-bridge-platform="<%= platform.bridge_name %>"
+ data-bridge-components="<%= platform.bridge_components %>"
+ data-bridge--title-title-value="<%= @page_title %>">
Skip to main content
From 139f4cb8694e890094758337c9c842a1c0b7e0e1 Mon Sep 17 00:00:00 2001
From: Jay Ohms
Date: Tue, 17 Mar 2026 10:12:56 -0400
Subject: [PATCH 067/107] Add test coverage for native bridge user agents
---
test/models/application_platform_test.rb | 41 ++++++++++++++++++++++++
1 file changed, 41 insertions(+)
create mode 100644 test/models/application_platform_test.rb
diff --git a/test/models/application_platform_test.rb b/test/models/application_platform_test.rb
new file mode 100644
index 000000000..597fd714b
--- /dev/null
+++ b/test/models/application_platform_test.rb
@@ -0,0 +1,41 @@
+require "test_helper"
+
+class ApplicationPlatformTest < ActiveSupport::TestCase
+ NATIVE_ANDROID_UA = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Hotwire Native Android/1.0 bridge-components: [buttons overflow-menu form]"
+ NATIVE_IOS_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Hotwire Native iOS/1.0 bridge-components: [title]"
+ MOBILE_WEB_IOS_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
+ DESKTOP_WEB_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ NATIVE_WITHOUT_COMPONENTS_UA = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Hotwire Native Android/1.0"
+
+ test "bridge_name returns android for native android user agents" do
+ assert_equal :android, platform_for(NATIVE_ANDROID_UA).bridge_name
+ end
+
+ test "bridge_name returns ios for native ios user agents" do
+ assert_equal :ios, platform_for(NATIVE_IOS_UA).bridge_name
+ end
+
+ test "bridge_name is nil for non-native user agents" do
+ assert_nil platform_for(MOBILE_WEB_IOS_UA).bridge_name
+ assert_nil platform_for(DESKTOP_WEB_UA).bridge_name
+ end
+
+ test "bridge_components returns extracted components for native user agents" do
+ assert_equal "buttons overflow-menu form", platform_for(NATIVE_ANDROID_UA).bridge_components
+ assert_equal "title", platform_for(NATIVE_IOS_UA).bridge_components
+ end
+
+ test "bridge_components is blank for non-native user agents" do
+ assert_equal "", platform_for(MOBILE_WEB_IOS_UA).bridge_components
+ assert_equal "", platform_for(DESKTOP_WEB_UA).bridge_components
+ end
+
+ test "bridge_components is blank when native user agent does not include bridge-components metadata" do
+ assert_equal "", platform_for(NATIVE_WITHOUT_COMPONENTS_UA).bridge_components
+ end
+
+ private
+ def platform_for(user_agent)
+ ApplicationPlatform.new(user_agent)
+ end
+end
From 29a29094bd36040178a72022deca0c346c020186 Mon Sep 17 00:00:00 2001
From: Mike Dalessio
Date: Mon, 16 Mar 2026 12:41:02 -0400
Subject: [PATCH 068/107] Configure Lexxy to add extra spacing between block
elements
Updating Lexxy to v0.8.5 for the insert-markdown event.
---
Gemfile | 2 +-
Gemfile.lock | 11 +++--------
Gemfile.saas.lock | 11 +++--------
app/javascript/initializers/index.js | 1 +
app/javascript/initializers/lexxy_markdown_paste.js | 3 +++
5 files changed, 11 insertions(+), 17 deletions(-)
create mode 100644 app/javascript/initializers/lexxy_markdown_paste.js
diff --git a/Gemfile b/Gemfile
index da66e1473..597899b7d 100644
--- a/Gemfile
+++ b/Gemfile
@@ -27,7 +27,7 @@ gem "geared_pagination", "~> 1.2"
gem "rqrcode"
gem "rouge"
gem "jbuilder"
-gem "lexxy", bc: "lexxy"
+gem "lexxy", "0.8.5.beta"
gem "image_processing", "~> 1.14"
gem "platform_agent"
gem "aws-sdk-s3", require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 8db6817dc..ae84f65f6 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,10 +1,3 @@
-GIT
- remote: https://github.com/basecamp/lexxy
- revision: b7f0f1429df5fa425eac043191ae2b65edee7488
- specs:
- lexxy (0.7.6.beta)
- rails (>= 8.0.2)
-
GIT
remote: https://github.com/basecamp/useragent
revision: 433ca320a42db1266c4b89df74d0abdb9a880c5e
@@ -261,6 +254,8 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
+ lexxy (0.8.5.beta)
+ rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.0)
@@ -523,7 +518,7 @@ DEPENDENCIES
jbuilder
kamal
letter_opener
- lexxy!
+ lexxy (= 0.8.5.beta)
mission_control-jobs
mittens
mocha
diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock
index 6f9b0d088..176a5218c 100644
--- a/Gemfile.saas.lock
+++ b/Gemfile.saas.lock
@@ -21,13 +21,6 @@ GIT
rails (>= 7.0)
rainbow
-GIT
- remote: https://github.com/basecamp/lexxy
- revision: b7f0f1429df5fa425eac043191ae2b65edee7488
- specs:
- lexxy (0.7.6.beta)
- rails (>= 8.0.2)
-
GIT
remote: https://github.com/basecamp/queenbee-plugin
revision: 14312a940471e20617b38cdec7c092a01567d18b
@@ -374,6 +367,8 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
+ lexxy (0.8.5.beta)
+ rails (>= 8.0.2)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.0)
@@ -729,7 +724,7 @@ DEPENDENCIES
jbuilder
kamal
letter_opener
- lexxy!
+ lexxy (= 0.8.5.beta)
mission_control-jobs
mittens
mocha
diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js
index 1d16ef205..d2c20451e 100644
--- a/app/javascript/initializers/index.js
+++ b/app/javascript/initializers/index.js
@@ -1,3 +1,4 @@
import "initializers/current"
import "initializers/bridge/bridge_element"
import "initializers/offline"
+import "initializers/lexxy_markdown_paste"
diff --git a/app/javascript/initializers/lexxy_markdown_paste.js b/app/javascript/initializers/lexxy_markdown_paste.js
new file mode 100644
index 000000000..cf7600306
--- /dev/null
+++ b/app/javascript/initializers/lexxy_markdown_paste.js
@@ -0,0 +1,3 @@
+document.addEventListener("lexxy:insert-markdown", (event) => {
+ event.detail.addBlockSpacing()
+})
From fc18ba8e07de3e23e451be7a0607d77e484fbb13 Mon Sep 17 00:00:00 2001
From: Mike Dalessio
Date: Mon, 16 Mar 2026 13:39:12 -0400
Subject: [PATCH 069/107] Add system tests for markdown paste in Lexxy editor
Test that pasting markdown with block separators produces extra spacing
between blocks, and that single line breaks are preserved.
---
test/system/smoke_test.rb | 40 +++++++++++++++++++++++++++++++++++++++
1 file changed, 40 insertions(+)
diff --git a/test/system/smoke_test.rb b/test/system/smoke_test.rb
index 31e8f723f..5af13f890 100644
--- a/test/system/smoke_test.rb
+++ b/test/system/smoke_test.rb
@@ -76,6 +76,38 @@ class SmokeTest < ApplicationSystemTestCase
assert_no_selector "div##{dom_id(notification)}"
end
+ test "markdown paste adds block spacing" do
+ sign_in_as(users(:david))
+
+ visit card_url(cards(:layout))
+ find("lexxy-editor").click
+ paste_markdown("Hello\n\nWorld")
+
+ within("lexxy-editor") do
+ assert_selector "p", text: "Hello"
+ assert_selector "p br", visible: :all
+ assert_selector "p", text: "World"
+ end
+ end
+
+ test "markdown paste preserves line breaks" do
+ sign_in_as(users(:david))
+
+ visit card_url(cards(:layout))
+ find("lexxy-editor").click
+ paste_markdown("Hello\nWorld")
+
+ inner_html = find("lexxy-editor p", text: "Hello").native.property("innerHTML")
+ children = Nokogiri::HTML5.fragment(inner_html).children
+ assert_pattern do
+ children => [
+ { name: "span", inner_html: "Hello" },
+ { name: "br" },
+ { name: "span", inner_html: "World" }
+ ]
+ end
+ end
+
test "dragging card to a new column" do
sign_in_as(users(:david))
@@ -105,4 +137,12 @@ class SmokeTest < ApplicationSystemTestCase
editor_element.set with
page.execute_script("arguments[0].value = '#{with}'", editor_element)
end
+
+ def paste_markdown(markdown)
+ page.execute_script(<<~JS, markdown)
+ const dt = new DataTransfer();
+ dt.setData("text/plain", arguments[0]);
+ document.activeElement.dispatchEvent(new ClipboardEvent("paste", { clipboardData: dt, bubbles: true }));
+ JS
+ end
end
From 24446afba7477cdd9e7a632e130ceee8a22d85fe Mon Sep 17 00:00:00 2001
From: Zoltan Hosszu
Date: Tue, 17 Mar 2026 10:39:40 +0100
Subject: [PATCH 070/107] Image gallery size fixes
---
app/assets/stylesheets/attachments.css | 5 ++---
app/assets/stylesheets/lexxy.css | 14 ++++++++++++++
2 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/app/assets/stylesheets/attachments.css b/app/assets/stylesheets/attachments.css
index 85dc7652d..8e3808ec5 100644
--- a/app/assets/stylesheets/attachments.css
+++ b/app/assets/stylesheets/attachments.css
@@ -3,8 +3,8 @@
block-size: auto;
display: block;
inline-size: fit-content;
- position: relative;
max-inline-size: 100%;
+ position: relative;
progress {
inline-size: 100%;
@@ -35,8 +35,7 @@
@supports (field-sizing: content) {
field-sizing: content;
- inline-size: auto;
- min-inline-size: 20ch;
+ inline-size: 100%;
}
}
}
diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css
index 8fb7658ee..aaf634710 100644
--- a/app/assets/stylesheets/lexxy.css
+++ b/app/assets/stylesheets/lexxy.css
@@ -188,4 +188,18 @@
.attachment {
margin-inline: 0;
}
+
+ .attachment-gallery {
+ .attachment {
+ display: inline-block;
+ inline-size: calc(33.333% - 0.8ch);
+ }
+
+ &.attachment-gallery--2,
+ &.attachment-gallery--4 {
+ .attachment {
+ inline-size: calc(50% - 0.8ch);
+ }
+ }
+ }
}
From ea871757f210bbb1a35e0abb5afb76db3c6046f8 Mon Sep 17 00:00:00 2001
From: Zoltan Hosszu
Date: Tue, 17 Mar 2026 10:39:48 +0100
Subject: [PATCH 071/107] Mention delete button fix
---
app/assets/stylesheets/lexxy.css | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css
index aaf634710..1ae710da9 100644
--- a/app/assets/stylesheets/lexxy.css
+++ b/app/assets/stylesheets/lexxy.css
@@ -202,4 +202,15 @@
}
}
}
+
+ action-text-attachment[content-type^='application/vnd.actiontext'] {
+ lexxy-node-delete-button {
+ inset-inline-start: -0.25ch;
+
+ .lexxy-floating-controls__group {
+ background-color: oklch(var(--lch-blue-dark));
+ border-radius: 50%;
+ }
+ }
+ }
}
From 1f24ed662b60a576d09171ac3ebcabaa0a65149a Mon Sep 17 00:00:00 2001
From: Zoltan Hosszu
Date: Tue, 17 Mar 2026 10:45:52 +0100
Subject: [PATCH 072/107] Increase max width for mention prompts
---
app/assets/stylesheets/lexxy.css | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css
index 1ae710da9..053f626a9 100644
--- a/app/assets/stylesheets/lexxy.css
+++ b/app/assets/stylesheets/lexxy.css
@@ -120,6 +120,10 @@
margin-block: 1em;
}
+ .lexxy-prompt-menu {
+ max-inline-size: min(35ch, calc(100% - var(--lexxy-prompt-offset-x)));
+ }
+
/* Content
/* ------------------------------------------------------------------------ */
From 8920946cb2b885d0a19bdfb3e10f24222de30550 Mon Sep 17 00:00:00 2001
From: Jay Ohms
Date: Tue, 17 Mar 2026 10:45:37 -0400
Subject: [PATCH 073/107] Update regex and add test coverage for empty/multiple
lists
---
app/models/application_platform.rb | 2 +-
test/models/application_platform_test.rb | 10 ++++++++++
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/app/models/application_platform.rb b/app/models/application_platform.rb
index 388c183eb..6494e37c5 100644
--- a/app/models/application_platform.rb
+++ b/app/models/application_platform.rb
@@ -82,7 +82,7 @@ class ApplicationPlatform < PlatformAgent
private
def extract_list_from_native_user_agent(prefix)
if native?
- user_agent.to_s.match(/#{prefix}: \[(.+)\]/) { |matches| matches[1] }.to_s
+ user_agent.to_s.match(/#{Regexp.escape(prefix)}: \[(.*?)\]/) { |matches| matches[1] }.to_s
else
""
end
diff --git a/test/models/application_platform_test.rb b/test/models/application_platform_test.rb
index 597fd714b..560eb320e 100644
--- a/test/models/application_platform_test.rb
+++ b/test/models/application_platform_test.rb
@@ -6,6 +6,8 @@ class ApplicationPlatformTest < ActiveSupport::TestCase
MOBILE_WEB_IOS_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
DESKTOP_WEB_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
NATIVE_WITHOUT_COMPONENTS_UA = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Hotwire Native Android/1.0"
+ NATIVE_WITH_EMPTY_COMPONENTS_UA = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Hotwire Native Android/1.0 bridge-components: []"
+ NATIVE_WITH_MULTIPLE_LISTS_UA = "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Hotwire Native Android/1.0 bridge-components: [buttons] metadata: [ignored]"
test "bridge_name returns android for native android user agents" do
assert_equal :android, platform_for(NATIVE_ANDROID_UA).bridge_name
@@ -34,6 +36,14 @@ class ApplicationPlatformTest < ActiveSupport::TestCase
assert_equal "", platform_for(NATIVE_WITHOUT_COMPONENTS_UA).bridge_components
end
+ test "bridge_components supports empty lists" do
+ assert_equal "", platform_for(NATIVE_WITH_EMPTY_COMPONENTS_UA).bridge_components
+ end
+
+ test "bridge_components only matches through the first closing bracket" do
+ assert_equal "buttons", platform_for(NATIVE_WITH_MULTIPLE_LISTS_UA).bridge_components
+ end
+
private
def platform_for(user_agent)
ApplicationPlatform.new(user_agent)
From 4d2866dec6a33e8883984ecc6a7b1fe385c70ab6 Mon Sep 17 00:00:00 2001
From: Jay Ohms
Date: Tue, 17 Mar 2026 11:04:39 -0400
Subject: [PATCH 074/107] Update additional tests to ensure that the body
element contains the correct bridge attributes
---
test/controllers/concerns/set_platform_test.rb | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/test/controllers/concerns/set_platform_test.rb b/test/controllers/concerns/set_platform_test.rb
index 26ce9d66a..eb3dc73ec 100644
--- a/test/controllers/concerns/set_platform_test.rb
+++ b/test/controllers/concerns/set_platform_test.rb
@@ -2,13 +2,13 @@ require "test_helper"
class SetPlatformTest < ActionDispatch::IntegrationTest
DESKTOP_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
- NATIVE_IOS_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Hotwire Native iOS/1.0"
+ NATIVE_IOS_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Hotwire Native iOS/1.0 bridge-components: [buttons overflow-menu]"
test "uses the request user agent by default" do
sign_in_as :david
get board_path(boards(:writebook)), headers: { "User-Agent" => DESKTOP_UA }
- assert_match 'data-platform="desktop web"', response.body
+ assert_select "body[data-platform='desktop web'][data-bridge-platform=''][data-bridge-components='']"
end
test "prefers x_user_agent cookie over request user agent" do
@@ -16,6 +16,6 @@ class SetPlatformTest < ActionDispatch::IntegrationTest
cookies[:x_user_agent] = NATIVE_IOS_UA
get board_path(boards(:writebook)), headers: { "User-Agent" => DESKTOP_UA }
- assert_match 'data-platform="native ios"', response.body
+ assert_select "body[data-platform='native ios'][data-bridge-platform='ios'][data-bridge-components='buttons overflow-menu']"
end
end
From b5b3d1cd37a1abe850af1890619e94539624a588 Mon Sep 17 00:00:00 2001
From: Andy Smith
Date: Tue, 17 Mar 2026 11:33:49 -0500
Subject: [PATCH 075/107] Style messages for account limits
---
app/assets/stylesheets/card-perma.css | 8 ++
app/assets/stylesheets/comments.css | 120 ++++++++++--------
.../saas/_storage_limit_exceeded.html.erb | 14 +-
.../saas/_storage_limit_exceeded.html.erb | 8 +-
.../saas/_storage_limit_notice.html.erb | 5 +-
5 files changed, 96 insertions(+), 59 deletions(-)
diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css
index 3dc16d5a2..e204547b4 100644
--- a/app/assets/stylesheets/card-perma.css
+++ b/app/assets/stylesheets/card-perma.css
@@ -511,4 +511,12 @@
}
}
}
+
+ .card-perma__account-limit-message {
+ background-color: var(--color-canvas);
+ border: 2px solid var(--color-container);
+ border-radius: 4px;
+ margin-block-start: calc(var(--padding-block) / -2);
+ padding: 1ch 2ch;
+ }
}
diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css
index 2369d62b3..21578572b 100644
--- a/app/assets/stylesheets/comments.css
+++ b/app/assets/stylesheets/comments.css
@@ -1,4 +1,7 @@
@layer components {
+ /* List
+ /* ------------------------------------------------------------------------ */
+
.comments {
--avatar-size: 2.33em;
--comment-padding-block: var(--block-space-half);
@@ -21,10 +24,8 @@
}
}
- .comments__subscribers {
- max-inline-size: var(--comment-max);
- padding-inline: calc(var(--comment-padding-block) + var(--inline-space-double));
- }
+ /* Comment
+ /* ------------------------------------------------------------------------ */
.comment {
/* Distinguish from the .comment class used for code formatting without extra specificity */
@@ -35,52 +36,6 @@
position: relative;
}
- .comment-by-system & {
- --comment-padding-block: var(--block-space-half);
-
- text-align: center;
-
- &::before {
- /* Make up space for lack of avatar */
- content: "";
- display: flex;
- inline-size: calc(var(--comment-padding-inline) * 0.9);
- }
-
- .comment__avatar {
- display: none;
- }
-
- .comment__author {
- a { margin: 0 auto; }
- h3 { margin-inline: auto; }
- strong { display: none; }
- }
-
- .comment__body {
- padding: 0;
- text-align: center;
- }
-
- .comment__content {
- --stripe-color: var(--color-ink-lightest);
-
- background-image: repeating-linear-gradient(
- 45deg in srgb,
- var(--color-canvas) 0 1px,
- var(--stripe-color) 1px 10px);
- padding-inline: var(--comment-padding-inline);
-
- .comments--system-expanded .comment-by-system & {
- --stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas));
- }
- }
-
- .reactions {
- display: none !important;
- }
- }
-
.reactions {
margin-block-start: var(--block-space-half);
margin-inline: calc(var(--column-gap) / -1);
@@ -195,7 +150,12 @@
}
}
+ /* System comment
+ /* ------------------------------------------------------------------------ */
+
.comment-by-system {
+ --stripe-color: var(--color-ink-lightest);
+
display: none;
transition: var(--dialog-duration) allow-discrete;
transition-property: display;
@@ -203,6 +163,50 @@
.comments--system-expanded & {
display: contents;
}
+
+ .comment {
+ --comment-padding-block: var(--block-space-half);
+
+ text-align: center;
+
+ &::before {
+ /* Make up space for lack of avatar */
+ content: "";
+ display: flex;
+ inline-size: calc(var(--comment-padding-inline) * 0.9);
+ }
+ }
+
+ .comment__avatar {
+ display: none;
+ }
+
+ .comment__author {
+ a { margin: 0 auto; }
+ h3 { margin-inline: auto; }
+ strong { display: none; }
+ }
+
+ .comment__body {
+ padding: 0;
+ text-align: center;
+ }
+
+ .comment__content {
+ background-image: repeating-linear-gradient(
+ 45deg in srgb,
+ var(--color-canvas) 0 1px,
+ var(--stripe-color) 1px 10px);
+ padding-inline: var(--comment-padding-inline);
+
+ .comments--system-expanded .comment-by-system & {
+ --stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas));
+ }
+ }
+
+ .reactions {
+ display: none !important;
+ }
}
/* Show the last system comment */
@@ -220,4 +224,20 @@
display: none;
}
}
+
+ .comment-by-system--account-limit {
+ --stripe-color: oklch(var(--lch-blue-lightest));
+
+ .comment__content {
+ padding: 3ch;
+ }
+ }
+
+ /* Subscribers
+ /* ------------------------------------------------------------------------ */
+
+ .comments__subscribers {
+ max-inline-size: var(--comment-max);
+ padding-inline: calc(var(--comment-padding-block) + var(--inline-space-double));
+ }
}
diff --git a/saas/app/views/cards/comments/saas/_storage_limit_exceeded.html.erb b/saas/app/views/cards/comments/saas/_storage_limit_exceeded.html.erb
index 6be5fb587..82ab7f25e 100644
--- a/saas/app/views/cards/comments/saas/_storage_limit_exceeded.html.erb
+++ b/saas/app/views/cards/comments/saas/_storage_limit_exceeded.html.erb
@@ -1,4 +1,12 @@
-
- This account has used all <%= number_to_human_size(Account::StorageLimited::STORAGE_LIMIT) %> of free storage.
- <%= link_to "Self-host Fizzy", "https://github.com/basecamp/fizzy", target: "_blank" %> for unlimited storage.
+
+
+
+
+
+ This account has used all the included free storage (<%= number_to_human_size(Account::StorageLimited::STORAGE_LIMIT) %>).
+
diff --git a/saas/app/views/cards/container/footer/saas/_storage_limit_notice.html.erb b/saas/app/views/cards/container/footer/saas/_storage_limit_notice.html.erb
index 942041fb4..41bb987f0 100644
--- a/saas/app/views/cards/container/footer/saas/_storage_limit_notice.html.erb
+++ b/saas/app/views/cards/container/footer/saas/_storage_limit_notice.html.erb
@@ -1,5 +1,6 @@
<% if Current.account.nearing_storage_limit? %>
-
- This account has used <%= number_to_human_size(Current.account.bytes_used) %> of <%= number_to_human_size(Account::StorageLimited::STORAGE_LIMIT) %> storage.
+
+ This account has used <%= number_to_human_size(Current.account.bytes_used) %> of <%= number_to_human_size(Account::StorageLimited::STORAGE_LIMIT) %> storage.
+
- This account has used <%= number_to_human_size(Current.account.bytes_used) %> of <%= number_to_human_size(Account::StorageLimited::STORAGE_LIMIT) %> storage.
-