diff --git a/Gemfile b/Gemfile index 17e7496c0..da66e1473 100644 --- a/Gemfile +++ b/Gemfile @@ -8,7 +8,7 @@ gem "rails", github: "rails/rails", branch: "main" gem "importmap-rails" gem "propshaft" gem "stimulus-rails" -gem "turbo-rails" +gem "turbo-rails", github: "hotwired/turbo-rails", branch: "offline-cache" # Deployment and drivers gem "bootsnap", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 3f8461b39..8db6817dc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -11,6 +11,15 @@ GIT specs: useragent (0.16.11) +GIT + remote: https://github.com/hotwired/turbo-rails.git + revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080 + branch: offline-cache + specs: + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + GIT remote: https://github.com/rails/rails.git revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5 @@ -461,9 +470,6 @@ GEM trilogy (2.10.0) bigdecimal tsort (0.2.0) - turbo-rails (2.0.23) - actionpack (>= 7.1.0) - railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) @@ -539,7 +545,7 @@ DEPENDENCIES stimulus-rails thruster trilogy (~> 2.10) - turbo-rails + turbo-rails! useragent! vcr web-console! diff --git a/Gemfile.saas b/Gemfile.saas index 39aa49a71..384f5b4b7 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -5,7 +5,6 @@ git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } gem "activeresource", require: "active_resource" gem "actionpack-xml_parser" # needed by queenbee for XML request body parsing -gem "stripe", "~> 18.0" gem "queenbee", bc: "queenbee-plugin" gem "fizzy-saas", path: "saas" gem "console1984", bc: "console1984" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 152d1889c..6f9b0d088 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -60,6 +60,15 @@ GIT rails (>= 6.1) yabeda (~> 0.6) +GIT + remote: https://github.com/hotwired/turbo-rails.git + revision: 93ce5bc461cd71af1797e79fcd7e09a19242d080 + branch: offline-cache + specs: + turbo-rails (2.0.23) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + GIT remote: https://github.com/rails/rails.git revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5 @@ -622,7 +631,6 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) - stripe (18.0.1) thor (1.5.0) thruster (0.1.18) thruster (0.1.18-aarch64-linux) @@ -633,9 +641,6 @@ GEM trilogy (2.10.0) bigdecimal tsort (0.2.0) - turbo-rails (2.0.23) - actionpack (>= 7.1.0) - railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) @@ -749,10 +754,9 @@ DEPENDENCIES sqlite3 (>= 2.0) stackprof stimulus-rails - stripe (~> 18.0) thruster trilogy (~> 2.10) - turbo-rails + turbo-rails! useragent! vcr web-console! diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 3dc16d5a2..5db9e7df7 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -156,37 +156,74 @@ @media (max-width: 639px) { --meta-spacer-block: 0.75ch; - inline-size: 100%; - grid-template-areas: - "avatars-author text-added" - "avatars-author text-author" - "text-updated text-updated" - "text-assignees text-assignees" - "avatars-assignees avatars-assignees"; - grid-template-columns: auto 1fr; + min-inline-size: 0; + gap: calc(var(--meta-spacer-block) / 2); + display: flex; + flex-wrap: wrap; .card__meta-text { border: 0; padding: 0; } - .card__meta-text + .card__meta-text { + .card__meta-avatars--author { + --btn-size: 1.5em; + + display: initial; + margin-inline-end: unset; + order: 3; + } + + .card__meta-text--added { + inline-size: 100%; + order: 1; + } + + .card__meta-text--author { + order: 2; + } + + .card__meta-text--updated { border-block-start: var(--card-border); - margin-block-start: var(--meta-spacer-block); + inline-size: 100%; + margin-block-start: calc(var(--meta-spacer-block) * 0.5); + order: 4; padding-block-start: var(--meta-spacer-block); } + .card__meta-text--assignees { + margin-block-start: calc(var(--meta-spacer-block) * 3); + order: 6; + white-space: unset !important; + } + .card__meta-avatars--assignees { - margin-inline: 0; - margin-block-start: var(--meta-spacer-block); + margin-inline: 0 var(--meta-spacer-inline); + margin-block-start: calc(var(--meta-spacer-block) * 3); + order: 5; + + .avatar { + display: grid; + } } - .card__meta-avatars--author { - display: initial; - } + &:has(.card__meta-avatars--assignees .avatar) { + .card__meta-text--assignees { + order: 5; + } - .card__meta-avatars--assignees .avatar { - display: grid; + .card__meta-avatars--assignees { + margin-block-start: var(--meta-spacer-block); + order: 6; + } + } + } + } + + &:has(.card__closed) .card__meta { + @media (max-width: 639px) { + .card__meta-avatars--assignees { + display: none; } } } @@ -252,10 +289,16 @@ @media (max-width: 639px) { display: grid; font-size: var(--text-x-small); + gap: 1ch 0; grid-template-columns: 1fr auto; grid-template-areas: "meta bg-zoom" "meta reactions"; + + &:not(:has(.reaction)), + &:has(.card__background) { + column-gap: 2ch; + } } } diff --git a/app/controllers/account/entropies_controller.rb b/app/controllers/account/entropies_controller.rb index 327098ee2..44c9debe5 100644 --- a/app/controllers/account/entropies_controller.rb +++ b/app/controllers/account/entropies_controller.rb @@ -4,11 +4,12 @@ class Account::EntropiesController < ApplicationController before_action :ensure_admin def update - Current.account.entropy.update!(entropy_params) + @account = Current.account + @account.entropy.update!(entropy_params) respond_to do |format| format.html { redirect_to account_settings_path, notice: "Account updated" } - format.json { head :no_content } + format.json { render "account/settings/show", status: :ok } end rescue ActiveRecord::RecordInvalid head :unprocessable_entity diff --git a/app/controllers/account/settings_controller.rb b/app/controllers/account/settings_controller.rb index 47036d606..e4b949070 100644 --- a/app/controllers/account/settings_controller.rb +++ b/app/controllers/account/settings_controller.rb @@ -5,7 +5,10 @@ class Account::SettingsController < ApplicationController before_action :set_account def show - @users = @account.users.active.alphabetically.includes(:identity) + respond_to do |format| + format.html { @users = @account.users.active.alphabetically.includes(:identity) } + format.json + end end def update diff --git a/app/controllers/boards/entropies_controller.rb b/app/controllers/boards/entropies_controller.rb index 132733aac..89e262545 100644 --- a/app/controllers/boards/entropies_controller.rb +++ b/app/controllers/boards/entropies_controller.rb @@ -10,7 +10,7 @@ class Boards::EntropiesController < ApplicationController respond_to do |format| format.turbo_stream - format.json { head :no_content } + format.json { render "boards/show", status: :ok } end rescue ActiveRecord::RecordInvalid head :unprocessable_entity diff --git a/app/controllers/concerns/set_platform.rb b/app/controllers/concerns/set_platform.rb index 54e77d517..66de85f12 100644 --- a/app/controllers/concerns/set_platform.rb +++ b/app/controllers/concerns/set_platform.rb @@ -7,6 +7,6 @@ module SetPlatform private def platform - @platform ||= ApplicationPlatform.new(request.user_agent) + @platform ||= ApplicationPlatform.new(cookies[:x_user_agent].presence || request.user_agent) end end diff --git a/app/javascript/controllers/clear_offline_cache_controller.js b/app/javascript/controllers/clear_offline_cache_controller.js new file mode 100644 index 000000000..7caa2e89b --- /dev/null +++ b/app/javascript/controllers/clear_offline_cache_controller.js @@ -0,0 +1,8 @@ +import { Controller } from "@hotwired/stimulus" +import { Turbo } from "@hotwired/turbo-rails" + +export default class extends Controller { + clearCache() { + Turbo.offline.clearCache() + } +} diff --git a/app/javascript/controllers/magic_link_controller.js b/app/javascript/controllers/magic_link_controller.js index 3e2e4618c..8217af5aa 100644 --- a/app/javascript/controllers/magic_link_controller.js +++ b/app/javascript/controllers/magic_link_controller.js @@ -15,7 +15,7 @@ export default class extends Controller { submit() { if (this.inputTarget.disabled) return - this.element.submit() + this.element.requestSubmit() this.inputTarget.disabled = true } } diff --git a/app/javascript/controllers/upload_preview_controller.js b/app/javascript/controllers/upload_preview_controller.js index d7b0bd478..7e25d50a3 100644 --- a/app/javascript/controllers/upload_preview_controller.js +++ b/app/javascript/controllers/upload_preview_controller.js @@ -15,7 +15,7 @@ export default class extends Controller { } #showFileName() { - this.fileNameTarget.innerHTML = this.#file.name + this.fileNameTarget.innerText = this.#file.name this.fileNameTarget.removeAttribute("hidden") this.placeholderTarget.setAttribute("hidden", true) } diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js index 90ef36a26..1d16ef205 100644 --- a/app/javascript/initializers/index.js +++ b/app/javascript/initializers/index.js @@ -1,2 +1,3 @@ import "initializers/current" import "initializers/bridge/bridge_element" +import "initializers/offline" diff --git a/app/javascript/initializers/offline.js b/app/javascript/initializers/offline.js new file mode 100644 index 000000000..877ffa0ef --- /dev/null +++ b/app/javascript/initializers/offline.js @@ -0,0 +1,9 @@ +import { Turbo } from "@hotwired/turbo-rails" + +if (Current.user) { + Turbo.offline.start("/service-worker.js", { + scope: "/", + native: true, + preload: /\/assets\// + }) +} diff --git a/app/models/account/data_transfer/active_storage/blob_record_set.rb b/app/models/account/data_transfer/active_storage/blob_record_set.rb index 1637768f3..0c4a553c1 100644 --- a/app/models/account/data_transfer/active_storage/blob_record_set.rb +++ b/app/models/account/data_transfer/active_storage/blob_record_set.rb @@ -13,6 +13,7 @@ class Account::DataTransfer::ActiveStorage::BlobRecordSet < Account::DataTransfe data = load(file) data.slice(*attributes).merge( "account_id" => account.id, + "key" => ::ActiveStorage::Blob.generate_unique_secure_token(length: ::ActiveStorage::Blob::MINIMUM_TOKEN_LENGTH), "service_name" => ::ActiveStorage::Blob.service.name ) end diff --git a/app/models/account/data_transfer/active_storage/file_record_set.rb b/app/models/account/data_transfer/active_storage/file_record_set.rb index 15d359353..421a2e520 100644 --- a/app/models/account/data_transfer/active_storage/file_record_set.rb +++ b/app/models/account/data_transfer/active_storage/file_record_set.rb @@ -22,9 +22,12 @@ class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfe def import_batch(files) files.each do |file| - key = File.basename(file) - blob = ::ActiveStorage::Blob.find_by(key: key, account: account) - next unless blob + old_key = file.delete_prefix("storage/") + blob_id = old_key_to_blob_id[old_key] + raise IntegrityError, "Storage file #{old_key} has no matching blob metadata in export" unless blob_id + + blob = ::ActiveStorage::Blob.find_by(id: blob_id, account: account) + raise IntegrityError, "Blob #{blob_id} not found for storage key #{old_key}" unless blob zip.read(file) do |stream| blob.upload(stream) @@ -32,12 +35,31 @@ class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfe end end - def check_record(file_path) - key = File.basename(file_path) + def old_key_to_blob_id + @old_key_to_blob_id ||= build_old_key_to_blob_id + end - unless zip.exists?("data/active_storage_blobs/#{key}.json") || ::ActiveStorage::Blob.exists?(key: key, account: account) - # File exists without corresponding blob record - could be orphaned or blob not yet imported - # We allow this since blob metadata is imported before files + def build_old_key_to_blob_id + zip.glob("data/active_storage_blobs/*.json").each_with_object({}) do |file, map| + data = load(file) + old_key = data["key"] + if map.key?(old_key) + raise IntegrityError, "Duplicate blob key in export: #{old_key}" + end + map[old_key] = data["id"] + end + end + + def with_zip(zip) + @old_key_to_blob_id = nil + super + end + + def check_record(file_path) + old_key = file_path.delete_prefix("storage/") + + unless old_key_to_blob_id.key?(old_key) + raise IntegrityError, "Storage file #{old_key} has no matching blob metadata in export" end end end diff --git a/app/models/account/data_transfer/manifest.rb b/app/models/account/data_transfer/manifest.rb index 2159926d5..9de76f9bf 100644 --- a/app/models/account/data_transfer/manifest.rb +++ b/app/models/account/data_transfer/manifest.rb @@ -26,20 +26,42 @@ class Account::DataTransfer::Manifest [ Account::DataTransfer::AccountRecordSet.new(account), Account::DataTransfer::UserRecordSet.new(account), - *build_record_sets(::User::Settings, ::Tag, ::Board, ::Column), + *build_record_sets( + ::User::Settings, + ::Tag, + ::Board, + ::Column + ), Account::DataTransfer::EntropyRecordSet.new(account), *build_record_sets( - ::Board::Publication, ::Webhook, ::Access, ::Card, ::Comment, ::Step, - ::Assignment, ::Tagging, ::Closure, ::Card::Goldness, ::Card::NotNow, - ::Card::ActivitySpike, ::Watch, ::Pin, ::Reaction, ::Mention, - ::Filter, ::Webhook::DelinquencyTracker, ::Event, - ::Notification, ::Notification::Bundle, ::Webhook::Delivery + ::Board::Publication, + ::Webhook, + ::Access, + ::Card, + ::Comment, + ::Step, + ::Assignment, + ::Tagging, + ::Closure, + ::Card::Goldness, + ::Card::NotNow, + ::Card::ActivitySpike, + ::Watch, + ::Pin, + ::Reaction, + ::Mention, + ::Filter, + ::Webhook::DelinquencyTracker, + ::Event, + ::Notification, + ::Notification::Bundle, + ::Webhook::Delivery ), Account::DataTransfer::ActiveStorage::BlobRecordSet.new(account), *build_record_sets(::ActiveStorage::Attachment), Account::DataTransfer::ActionText::RichTextRecordSet.new(account), Account::DataTransfer::ActiveStorage::FileRecordSet.new(account) - ] + ].then { set_importable_model_names(it) } end def build_record_sets(*models) @@ -47,4 +69,10 @@ class Account::DataTransfer::Manifest Account::DataTransfer::RecordSet.new(account: account, model: model) end end + + def set_importable_model_names(record_sets) + model_names = record_sets.filter_map { |record_set| record_set.model&.name } + record_sets.each { |record_set| record_set.importable_model_names = model_names } + record_sets + end end diff --git a/app/models/account/data_transfer/record_set.rb b/app/models/account/data_transfer/record_set.rb index 8eeafbe10..2fba9402d 100644 --- a/app/models/account/data_transfer/record_set.rb +++ b/app/models/account/data_transfer/record_set.rb @@ -4,12 +4,14 @@ class Account::DataTransfer::RecordSet IMPORT_BATCH_SIZE = 100 + attr_accessor :importable_model_names attr_reader :account, :model, :attributes - def initialize(account:, model:, attributes: nil) + def initialize(account:, model:, attributes: nil, importable_model_names: nil) @account = account @model = model @attributes = (attributes || model.column_names).map(&:to_s) + @importable_model_names = importable_model_names || [ model.name ] end def export(to:, start: nil) @@ -113,7 +115,7 @@ class Account::DataTransfer::RecordSet def check_association_doesnt_exist(data, association, associated_id) if association.polymorphic? type_column = association.foreign_type.to_s - associated_class = data[type_column].constantize + associated_class = verify_model_type(data[type_column]) else associated_class = association.klass end @@ -123,6 +125,14 @@ class Account::DataTransfer::RecordSet end end + def verify_model_type(type_name) + if importable_model_names.include?(type_name) + type_name.constantize + else + raise IntegrityError, "Unrecognized model type: #{type_name}" + end + end + def skip_to(file_list, last_id) index = file_list.index(last_id) diff --git a/app/models/user/named.rb b/app/models/user/named.rb index d052a79ad..804e516ee 100644 --- a/app/models/user/named.rb +++ b/app/models/user/named.rb @@ -20,6 +20,6 @@ module User::Named def familiar_name names = name.split return name if names.length <= 1 - "#{names.first} #{names[1..].map { |n| "#{n[0]}." }.join}" + "#{names.first}\u00A0#{names[1..].map { |n| "#{n[0]}." }.join}" end end diff --git a/app/models/zip_file.rb b/app/models/zip_file.rb index dc5b7ee8d..3415f5879 100644 --- a/app/models/zip_file.rb +++ b/app/models/zip_file.rb @@ -84,7 +84,8 @@ class ZipFile def read_from_s3(blob) url = blob.url(expires_in: 6.hour) - remote_io = RemoteIO.new(url) + ssl_verify_peer = blob.service.client.client.config.ssl_verify_peer + remote_io = RemoteIO.new(url, ssl_verify_peer: ssl_verify_peer) reader = Reader.new(remote_io) yield reader end diff --git a/app/models/zip_file/remote_io.rb b/app/models/zip_file/remote_io.rb index 1cb00831a..59329d811 100644 --- a/app/models/zip_file/remote_io.rb +++ b/app/models/zip_file/remote_io.rb @@ -1,4 +1,9 @@ class ZipFile::RemoteIO < ZipKit::RemoteIO + def initialize(url, ssl_verify_peer: true) + super(url) + @ssl_verify_peer = ssl_verify_peer + end + protected def request_range(range) with_http do |http| @@ -37,8 +42,7 @@ class ZipFile::RemoteIO < ZipKit::RemoteIO def with_http http = Net::HTTP.new(@uri.hostname, @uri.port) http.use_ssl = @uri.scheme == "https" - # FIXME: Disable SSL verification for now to avoid issues with our self-signed certificates for PureStorage - http.verify_mode = OpenSSL::SSL::VERIFY_NONE + http.verify_mode = OpenSSL::SSL::VERIFY_NONE unless @ssl_verify_peer http.start { yield http } end end diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 91bb85aff..fdb118e57 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -21,5 +21,3 @@ <%= render "account/settings/cancellation" %> - -<%= render "account/settings/subscription_panel" if Fizzy.saas? %> diff --git a/app/views/account/settings/show.json.jbuilder b/app/views/account/settings/show.json.jbuilder index 9a62dabc6..55b623f9d 100644 --- a/app/views/account/settings/show.json.jbuilder +++ b/app/views/account/settings/show.json.jbuilder @@ -1 +1,3 @@ -json.(Current.account, :name) +json.(@account, :id, :name, :cards_count) +json.created_at @account.created_at.utc +json.auto_postpone_period_in_days @account.entropy.auto_postpone_period_in_days diff --git a/app/views/boards/_board.json.jbuilder b/app/views/boards/_board.json.jbuilder index 7c85a1dc5..0eeff1896 100644 --- a/app/views/boards/_board.json.jbuilder +++ b/app/views/boards/_board.json.jbuilder @@ -1,6 +1,7 @@ json.cache! board do json.(board, :id, :name, :all_access) json.created_at board.created_at.utc + json.auto_postpone_period_in_days board.auto_postpone_period_in_days json.url board_url(board) json.creator board.creator, partial: "users/user", as: :user diff --git a/app/views/boards/show/_stream.html.erb b/app/views/boards/show/_stream.html.erb index b43309100..f66fb8fa6 100644 --- a/app/views/boards/show/_stream.html.erb +++ b/app/views/boards/show/_stream.html.erb @@ -12,7 +12,7 @@
<% if page.used? %> - <%= with_automatic_pagination "maybe", @page do %> + <%= with_automatic_pagination "maybe", page do %> <%= render "cards/display/previews", cards: page.records, draggable: true %> <% end %> <% end %> diff --git a/app/views/cards/closures/create.turbo_stream.erb b/app/views/cards/closures/create.turbo_stream.erb index c4898e471..5b76efe7b 100644 --- a/app/views/cards/closures/create.turbo_stream.erb +++ b/app/views/cards/closures/create.turbo_stream.erb @@ -3,7 +3,7 @@ <% if @source_column %> <%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %> <% elsif @was_in_stream %> - <%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> + <%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> <% end %> <%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %> diff --git a/app/views/cards/closures/destroy.turbo_stream.erb b/app/views/cards/closures/destroy.turbo_stream.erb index 8df408979..31a9e41c3 100644 --- a/app/views/cards/closures/destroy.turbo_stream.erb +++ b/app/views/cards/closures/destroy.turbo_stream.erb @@ -3,7 +3,7 @@ <% if @card.column %> <%= turbo_stream.replace(dom_id(@card.column), partial: "boards/show/column", method: :morph, locals: { column: @card.column }) %> <% elsif @card.awaiting_triage? %> - <%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> + <%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> <% end %> <%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %> diff --git a/app/views/cards/container/footer/_create.html.erb b/app/views/cards/container/footer/_create.html.erb index b55cae344..648beffd1 100644 --- a/app/views/cards/container/footer/_create.html.erb +++ b/app/views/cards/container/footer/_create.html.erb @@ -13,7 +13,5 @@ Create and add another <% end %>
- - <%= render "cards/container/footer/saas/near_notice" if Fizzy.saas? %> diff --git a/app/views/cards/drafts/_container.html.erb b/app/views/cards/drafts/_container.html.erb index fa7134b55..9ffc04711 100644 --- a/app/views/cards/drafts/_container.html.erb +++ b/app/views/cards/drafts/_container.html.erb @@ -26,9 +26,5 @@ <% end %> - <% if Fizzy.saas? %> - <%= render "cards/container/footer/saas/create", card: card %> - <% else %> - <%= render "cards/container/footer/create", card: card %> - <% end %> + <%= render "cards/container/footer/create", card: card %> diff --git a/app/views/cards/not_nows/create.turbo_stream.erb b/app/views/cards/not_nows/create.turbo_stream.erb index 9128580e2..945d86ab2 100644 --- a/app/views/cards/not_nows/create.turbo_stream.erb +++ b/app/views/cards/not_nows/create.turbo_stream.erb @@ -3,7 +3,7 @@ <% if @source_column %> <%= turbo_stream.replace(dom_id(@source_column), partial: "boards/show/column", method: :morph, locals: { column: @source_column }) %> <% elsif @was_in_stream %> - <%= turbo_stream.replace("the-stream", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> + <%= turbo_stream.replace("maybe", partial: "boards/show/stream", method: :morph, locals: { board: @card.board, page: @page }) %> <% end %> <%= turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload }) %> diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index 4be536454..9ef153df2 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -17,7 +17,6 @@ <%= render "layouts/theme_preference" %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> - <%= stylesheet_link_tag "fizzy/saas", "data-turbo-track": "reload" if Fizzy.saas? %> <%= javascript_importmap_tags %> <%= tenanted_action_cable_meta_tag %> diff --git a/app/views/my/menus/_settings.html.erb b/app/views/my/menus/_settings.html.erb index d1251b7c0..bf34b991d 100644 --- a/app/views/my/menus/_settings.html.erb +++ b/app/views/my/menus/_settings.html.erb @@ -6,7 +6,7 @@ <%= tag.li class: "popup__item", data: { filter_target: "item", navigable_list_target: "item" } do %> <%= icon_tag "logout", class: "popup__icon" %> - <%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", data: { turbo: false } do %> + <%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %> Sign out <% end %> <% end %> diff --git a/app/views/pwa/service_worker.js b/app/views/pwa/service_worker.js deleted file mode 100644 index 91903e735..000000000 --- a/app/views/pwa/service_worker.js +++ /dev/null @@ -1,41 +0,0 @@ -self.addEventListener('fetch', (event) => { - if (event.request.method !== 'GET') return - - if (event.request.destination === 'document') { - event.respondWith( - fetch(event.request, { cache: 'no-cache' }) - .catch(() => caches.match(event.request)) - ) - } -}) - -self.addEventListener("push", (event) => { - const data = event.data.json() - event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ])) -}) - -async function showNotification({ title, options }) { - return self.registration.showNotification(title, options) -} - -async function updateBadgeCount({ data: { badge } }) { - return self.navigator.setAppBadge?.(badge || 0) -} - -self.addEventListener("notificationclick", (event) => { - event.notification.close() - - const url = new URL(event.notification.data.url, self.location.origin).href - event.waitUntil(openURL(url)) -}) - -async function openURL(url) { - const clients = await self.clients.matchAll({ type: "window" }) - const focused = clients.find((client) => client.focused) - - if (focused) { - await focused.navigate(url) - } else { - await self.clients.openWindow(url) - } -} diff --git a/app/views/pwa/service_worker.js.erb b/app/views/pwa/service_worker.js.erb new file mode 100644 index 000000000..41fac1446 --- /dev/null +++ b/app/views/pwa/service_worker.js.erb @@ -0,0 +1,97 @@ +importScripts("<%= javascript_url("turbo-offline-umd.min") %>") + +// Documents - top-level navigation requests only +// We need `cache: "no-cache"` here to work around +// an annoying Safari PWA bug that predates offline mode +TurboOffline.addRule({ + match: (request) => request.destination === "document", + except: /\/(edit|pin|watch|new)$/, + handler: TurboOffline.handlers.networkFirst({ + cacheName: "main", + maxAge: 60 * 60 * 24 * 3, + networkTimeout: 3, + fetchOptions: { cache: "no-cache" }, + maxEntrySize: 1024 * 1024 + }) +}) + +TurboOffline.addRule({ + match: /\/assets\/.+[-\.][0-9a-f]+\.(js|css|svg|png|jpg|webp|woff2?|ico)$/, + handler: TurboOffline.handlers.cacheFirst({ + cacheName: "assets", + maxAge: 60 * 60 * 24 * 7, + maxEntrySize: 1024 * 1024 + }) +}) + +TurboOffline.addRule({ + match: /\/(boards|cards|users)\//, + except: /\/(edit|pin|watch|new)$/, + handler: TurboOffline.handlers.networkFirst({ + cacheName: "main", + maxAge: 60 * 60 * 24 * 3, + networkTimeout: 2, + maxEntrySize: 1024 * 1024 + }) +}) + +TurboOffline.addRule({ + match: /\/rails\/active_storage\//, + handler: TurboOffline.handlers.networkFirst({ + cacheName: "storage", + maxAge: 60 * 60 * 24 * 7, + networkTimeout: 2, + maxEntrySize: 2 * 1024 * 1024, // 2MB covers about 95% of all Fizzy blobs + maxEntries: 500, + fetchOptions: { mode: "cors" } + }) +}) + +// Everything else +TurboOffline.addRule({ + except: /\/(service-worker\.js|edit|pin|watch|new)$/, + handler: TurboOffline.handlers.networkFirst({ + cacheName: "main", + maxAge: 60 * 60 * 24, + networkTimeout: 3, + maxEntrySize: 1024 * 1024 + }) +}) + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()) +}) + +TurboOffline.start() + + +self.addEventListener("push", (event) => { + const data = event.data.json() + event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ])) +}) + +async function showNotification({ title, options }) { + return self.registration.showNotification(title, options) +} + +async function updateBadgeCount({ data: { badge } }) { + return self.navigator.setAppBadge?.(badge || 0) +} + +self.addEventListener("notificationclick", (event) => { + event.notification.close() + + const url = new URL(event.notification.data.url, self.location.origin).href + event.waitUntil(openURL(url)) +}) + +async function openURL(url) { + const clients = await self.clients.matchAll({ type: "window" }) + const focused = clients.find((client) => client.focused) + + if (focused) { + await focused.navigate(url) + } else { + await self.clients.openWindow(url) + } +} diff --git a/app/views/sessions/magic_links/show.html.erb b/app/views/sessions/magic_links/show.html.erb index aba8411f5..968624a10 100644 --- a/app/views/sessions/magic_links/show.html.erb +++ b/app/views/sessions/magic_links/show.html.erb @@ -8,7 +8,7 @@

- <%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link" } } do |form| %> + <%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: "magic-link clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do |form| %> <%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large txt-uppercase", autofocus: true, autocorrect: "off", autocapitalize: "off", spellcheck: "false", "data-1p-ignore": true, autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code], diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 58068d2d6..eb3e40c64 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -41,7 +41,7 @@ <% if Current.user == @user %> - <%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %> + <%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %> Sign out of Fizzy on this device <% end %> <% end %> diff --git a/config/importmap.rb b/config/importmap.rb index 5da84683c..f6910c3e1 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -2,6 +2,7 @@ pin "application" pin "@hotwired/turbo-rails", to: "turbo.min.js" +pin "@hotwired/turbo/offline", to: "turbo-offline.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin "@hotwired/hotwire-native-bridge", to: "@hotwired--hotwire-native-bridge.js" diff --git a/docs/API.md b/docs/API.md index cec58f36e..e8b60697c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -434,6 +434,7 @@ __Response:__ "name": "Fizzy", "all_access": true, "created_at": "2025-12-05T19:36:35.534Z", + "auto_postpone_period_in_days": 30, "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", @@ -460,6 +461,7 @@ __Response:__ "name": "Fizzy", "all_access": true, "created_at": "2025-12-05T19:36:35.534Z", + "auto_postpone_period_in_days": 30, "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", @@ -484,7 +486,7 @@ Creates a new Board in the account. |-----------|------|----------|-------------| | `name` | string | Yes | The name of the board | | `all_access` | boolean | No | Whether any user in the account can access this board. Defaults to `true` | -| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed | +| `auto_postpone_period_in_days` | integer | No | Number of days of inactivity before cards are automatically postponed (e.g. `30`) | | `public_description` | string | No | Rich text description shown on the public board page | __Request:__ @@ -514,7 +516,7 @@ Updates a Board. Only board administrators can update a board. |-----------|------|----------|-------------| | `name` | string | No | The name of the board | | `all_access` | boolean | No | Whether any user in the account can access this board | -| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed | +| `auto_postpone_period_in_days` | integer | No | Number of days of inactivity before cards are automatically postponed (e.g. `30`) | | `public_description` | string | No | Rich text description shown on the public board page | | `user_ids` | array | No | Array of *all* user IDs who should have access to this board (only applicable when `all_access` is `false`) | @@ -524,7 +526,7 @@ __Request:__ { "board": { "name": "Updated board name", - "auto_postpone_period": 14, + "auto_postpone_period_in_days": 30, "public_description": "This is a **public** description of the board.", "all_access": false, "user_ids": [ @@ -567,6 +569,7 @@ HTTP/1.1 201 Created "name": "Fizzy", "all_access": true, "created_at": "2025-12-05T19:36:35.534Z", + "auto_postpone_period_in_days": 30, "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", @@ -591,6 +594,72 @@ __Response:__ Returns `204 No Content` on success. +## Account + +### `GET /account/settings` + +Returns the current account. + +__Response:__ + +```json +{ + "id": "03f5v9zjvypwh0t0e2rfh0h7k", + "name": "37signals", + "cards_count": 5, + "created_at": "2025-12-05T19:36:35.401Z", + "auto_postpone_period_in_days": 30 +} +``` + +The `auto_postpone_period_in_days` is the account-level default in days (e.g. `30`). Cards are automatically moved to "Not Now" after this period of inactivity. Each board can override this with its own value. + +### `PUT /account/entropy` + +Updates the account-level default auto close period. Requires admin role. + +__Request:__ + +```json +{ + "entropy": { + "auto_postpone_period_in_days": 30 + } +} +``` + +__Response:__ + +Returns the account object: + +```json +{ + "id": "03f5v9zjvypwh0t0e2rfh0h7k", + "name": "37signals", + "cards_count": 5, + "created_at": "2025-12-05T19:36:35.401Z", + "auto_postpone_period_in_days": 30 +} +``` + +### `PUT /:account_slug/boards/:board_id/entropy` + +Updates the auto close period for a specific board. Requires board admin permission. + +__Request:__ + +```json +{ + "board": { + "auto_postpone_period_in_days": 90 + } +} +``` + +__Response:__ + +Returns the board object. + ## Webhooks Webhooks notify another application when something happens on a board. Only account admins can list, view, create, update, delete, or reactivate webhooks. @@ -757,6 +826,7 @@ __Response:__ "name": "Fizzy", "all_access": true, "created_at": "2025-12-05T19:36:35.534Z", + "auto_postpone_period_in_days": 30, "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", @@ -810,6 +880,7 @@ __Response:__ "name": "Fizzy", "all_access": true, "created_at": "2025-12-05T19:36:35.534Z", + "auto_postpone_period_in_days": 30, "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", @@ -1077,6 +1148,7 @@ __Response:__ "name": "Fizzy", "all_access": true, "created_at": "2025-12-05T19:36:35.534Z", + "auto_postpone_period_in_days": 30, "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm", "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", diff --git a/saas/app/assets/stylesheets/fizzy/saas.css b/saas/app/assets/stylesheets/fizzy/saas.css deleted file mode 100644 index a3fedc53d..000000000 --- a/saas/app/assets/stylesheets/fizzy/saas.css +++ /dev/null @@ -1,70 +0,0 @@ -/* Subscriptions -/* ------------------------------------------------------------------------ */ -:root { - --settings-subscription-background: linear-gradient(to bottom, var(--color-canvas), oklch(var(--lch-violet-lighter))); - --settings-subscription-color: oklch(var(--lch-violet-medium)); - --settings-subscription-text-color: oklch(var(--lch-violet-dark)); - - .settings-subscription__button { - --btn-background: var(--settings-subscription-color); - --btn-border-color: var(--color-canvas); - --btn-color: var(--color-canvas); - --focus-ring-color: var(--color-ink); - } - - .settings-subscription__divider { - --divider-color: currentColor; - - color: var(--settings-subscription-color); - margin-block-start: calc(var(--block-space-half) * -1); - } - - .settings-subscription__footer { - color: var(--settings-subscription-text-color); - - &::before { - border-block-start: 1px solid var(--settings-subscription-text-color); - content: ""; - display: block; - inline-size: 8ch; - margin: 0 auto 1ch; - } - } - - .settings-subscription__link { - color: var(--settings-subscription-text-color); - } - - .settings-subscription__notch { - animation: wiggle 500ms ease; - background: var(--settings-subscription-background); - border-radius: 3em; - box-shadow: 0 0 0.3em 0.2em var(--settings-subscription-color); - color: var(--settings-subscription-text-color); - margin-inline: auto; - padding: 0.3em 0.3em 0.3em 1.2em; - transform: rotate(-1deg); - - @media (max-width: 640px) { - border-radius: 1ch; - padding-block: 1ch; - padding-inline: 2ch; - } - - .btn { - /* margin-block: 0.3em; */ - } - } - - .settings-subscription__panel { - background: var(--settings-subscription-background); - } - - .settings_subscription__warning { - color: var(--settings-subscription-text-color); - - a { - color: var(--settings-subscription-text-color); - } - } -} diff --git a/saas/app/controllers/account/billing_portals_controller.rb b/saas/app/controllers/account/billing_portals_controller.rb deleted file mode 100644 index 67eaffe6c..000000000 --- a/saas/app/controllers/account/billing_portals_controller.rb +++ /dev/null @@ -1,19 +0,0 @@ -class Account::BillingPortalsController < ApplicationController - before_action :ensure_admin - before_action :ensure_subscribed_account - - def show - redirect_to create_stripe_billing_portal_session.url, allow_other_host: true - end - - private - def ensure_subscribed_account - unless Current.account.subscribed? - redirect_to account_subscription_path, alert: "No billing information found" - end - end - - def create_stripe_billing_portal_session - Stripe::BillingPortal::Session.create(customer: Current.account.subscription.stripe_customer_id, return_url: account_settings_url) - end -end diff --git a/saas/app/controllers/account/subscriptions/downgrades_controller.rb b/saas/app/controllers/account/subscriptions/downgrades_controller.rb deleted file mode 100644 index d06795841..000000000 --- a/saas/app/controllers/account/subscriptions/downgrades_controller.rb +++ /dev/null @@ -1,12 +0,0 @@ -class Account::Subscriptions::DowngradesController < Account::Subscriptions::UpdatePlanController - before_action :ensure_downgradeable - - private - def target_plan - Plan.paid - end - - def ensure_downgradeable - head :bad_request unless subscription.plan == Plan.paid_with_extra_storage - end -end diff --git a/saas/app/controllers/account/subscriptions/update_plan_controller.rb b/saas/app/controllers/account/subscriptions/update_plan_controller.rb deleted file mode 100644 index c6a665cec..000000000 --- a/saas/app/controllers/account/subscriptions/update_plan_controller.rb +++ /dev/null @@ -1,32 +0,0 @@ -class Account::Subscriptions::UpdatePlanController < ApplicationController - before_action :ensure_admin - - def create - portal_session = Stripe::BillingPortal::Session.create( - customer: subscription.stripe_customer_id, - return_url: account_settings_url(anchor: "subscription"), - flow_data: { - type: "subscription_update_confirm", - subscription_update_confirm: { - subscription: subscription.stripe_subscription_id, - items: [ { id: stripe_subscription_item_id, price: target_plan.stripe_price_id } ] - } - } - ) - - redirect_to portal_session.url, allow_other_host: true - end - - private - def target_plan - raise NotImplementedError - end - - def subscription - @subscription ||= Current.account.subscription - end - - def stripe_subscription_item_id - Stripe::Subscription.retrieve(subscription.stripe_subscription_id).items.data.first.id - end -end diff --git a/saas/app/controllers/account/subscriptions/upgrades_controller.rb b/saas/app/controllers/account/subscriptions/upgrades_controller.rb deleted file mode 100644 index 1de6d8c6c..000000000 --- a/saas/app/controllers/account/subscriptions/upgrades_controller.rb +++ /dev/null @@ -1,12 +0,0 @@ -class Account::Subscriptions::UpgradesController < Account::Subscriptions::UpdatePlanController - before_action :ensure_upgradeable - - private - def target_plan - Plan.paid_with_extra_storage - end - - def ensure_upgradeable - head :bad_request unless subscription.plan == Plan.paid - end -end diff --git a/saas/app/controllers/account/subscriptions_controller.rb b/saas/app/controllers/account/subscriptions_controller.rb deleted file mode 100644 index bbc4ec47a..000000000 --- a/saas/app/controllers/account/subscriptions_controller.rb +++ /dev/null @@ -1,46 +0,0 @@ -class Account::SubscriptionsController < ApplicationController - before_action :ensure_admin - before_action :set_stripe_session, only: :show - - def show - end - - def create - session = Stripe::Checkout::Session.create \ - customer: find_or_create_stripe_customer, - mode: "subscription", - line_items: [ { price: plan_param.stripe_price_id, quantity: 1 } ], - success_url: account_subscription_url + "?session_id={CHECKOUT_SESSION_ID}", - cancel_url: account_subscription_url, - metadata: { account_id: Current.account.id, plan_key: plan_param.key }, - automatic_tax: { enabled: true }, - tax_id_collection: { enabled: true }, - billing_address_collection: "required", - customer_update: { address: "auto", name: "auto" } - - redirect_to session.url, allow_other_host: true - end - - private - def plan_param - @plan_param ||= Plan[params[:plan_key]] || Plan.paid - end - - def set_stripe_session - @stripe_session = Stripe::Checkout::Session.retrieve(params[:session_id]) if params[:session_id] - end - - def find_or_create_stripe_customer - find_stripe_customer || create_stripe_customer - end - - def find_stripe_customer - Stripe::Customer.retrieve(Current.account.subscription.stripe_customer_id) if Current.account.subscription&.stripe_customer_id - end - - def create_stripe_customer - Stripe::Customer.create(email: Current.user.identity.email_address, name: Current.account.name, metadata: { account_id: Current.account.id }).tap do |customer| - Current.account.create_subscription!(stripe_customer_id: customer.id, plan_key: plan_param.key, status: "incomplete") - end - end -end diff --git a/saas/app/controllers/admin/account_searches_controller.rb b/saas/app/controllers/admin/account_searches_controller.rb deleted file mode 100644 index db655a9ef..000000000 --- a/saas/app/controllers/admin/account_searches_controller.rb +++ /dev/null @@ -1,5 +0,0 @@ -class Admin::AccountSearchesController < AdminController - def create - redirect_to saas.edit_admin_account_path(params[:q]) - end -end diff --git a/saas/app/controllers/admin/accounts_controller.rb b/saas/app/controllers/admin/accounts_controller.rb deleted file mode 100644 index 26a2b9c82..000000000 --- a/saas/app/controllers/admin/accounts_controller.rb +++ /dev/null @@ -1,27 +0,0 @@ -class Admin::AccountsController < AdminController - include Admin::AccountScoped - - layout "public" - - before_action :set_account, only: %i[ edit update ] - - def index - end - - def edit - end - - def update - @account.override_limits(**overridden_limits_params.to_h.symbolize_keys) - redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account limits updated" - end - - private - def set_account - @account = Account.find_by!(external_account_id: params[:id]) - end - - def overridden_limits_params - params.expect(account: [ :card_count, :bytes_used ]) - end -end diff --git a/saas/app/controllers/admin/billing_waivers_controller.rb b/saas/app/controllers/admin/billing_waivers_controller.rb deleted file mode 100644 index 713891069..000000000 --- a/saas/app/controllers/admin/billing_waivers_controller.rb +++ /dev/null @@ -1,13 +0,0 @@ -class Admin::BillingWaiversController < AdminController - include Admin::AccountScoped - - def create - @account.comp - redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account comped" - end - - def destroy - @account.uncomp - redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Account uncomped" - end -end diff --git a/saas/app/controllers/admin/overridden_limits_controller.rb b/saas/app/controllers/admin/overridden_limits_controller.rb deleted file mode 100644 index fff8ea3e3..000000000 --- a/saas/app/controllers/admin/overridden_limits_controller.rb +++ /dev/null @@ -1,8 +0,0 @@ -class Admin::OverriddenLimitsController < AdminController - include Admin::AccountScoped - - def destroy - @account.reset_overridden_limits - redirect_to saas.edit_admin_account_path(@account.external_account_id), notice: "Limits reset" - end -end diff --git a/saas/app/controllers/admin/stats_controller.rb b/saas/app/controllers/admin/stats_controller.rb index 6b9592fca..6d508fc2c 100644 --- a/saas/app/controllers/admin/stats_controller.rb +++ b/saas/app/controllers/admin/stats_controller.rb @@ -6,10 +6,6 @@ class Admin::StatsController < AdminController @accounts_last_7_days = Account.where(created_at: 7.days.ago..).count @accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count - @paid_accounts_total = paid_subscriptions.distinct.count(:account_id) - @paid_accounts_last_7_days = paid_subscriptions.where(created_at: 7.days.ago..).distinct.count(:account_id) - @paid_accounts_last_24_hours = paid_subscriptions.where(created_at: 24.hours.ago..).distinct.count(:account_id) - @identities_total = Identity.count @identities_last_7_days = Identity.where(created_at: 7.days.ago..).count @identities_last_24_hours = Identity.where(created_at: 24.hours.ago..).count @@ -21,12 +17,4 @@ class Admin::StatsController < AdminController @recent_accounts = Account.order(created_at: :desc).limit(10) end - - private - def paid_subscriptions - Account::Subscription - .where(status: %w[active trialing past_due]) - .where(plan_key: %w[monthly_v1 monthly_extra_storage_v1]) - .where.not(account_id: Account::BillingWaiver.select(:account_id)) - end end diff --git a/saas/app/controllers/concerns/admin/account_scoped.rb b/saas/app/controllers/concerns/admin/account_scoped.rb deleted file mode 100644 index 314720cd2..000000000 --- a/saas/app/controllers/concerns/admin/account_scoped.rb +++ /dev/null @@ -1,12 +0,0 @@ -module Admin::AccountScoped - extend ActiveSupport::Concern - - included do - before_action :set_account - end - - private - def set_account - @account = Account.find_by!(external_account_id: params[:account_id] || params[:id]) - end -end diff --git a/saas/app/controllers/concerns/card/limited.rb b/saas/app/controllers/concerns/card/limited.rb deleted file mode 100644 index 0576c6c4e..000000000 --- a/saas/app/controllers/concerns/card/limited.rb +++ /dev/null @@ -1,8 +0,0 @@ -module Card::Limited - extend ActiveSupport::Concern - - private - def ensure_under_limits - head :forbidden if Current.account.exceeding_limits? - end -end diff --git a/saas/app/controllers/concerns/card/limited_creation.rb b/saas/app/controllers/concerns/card/limited_creation.rb deleted file mode 100644 index 0e992008a..000000000 --- a/saas/app/controllers/concerns/card/limited_creation.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Card::LimitedCreation - extend ActiveSupport::Concern - - included do - include Card::Limited - - # Only limit API requests. We let you create drafts in the app to actually show the banner, no matter the card count. - # We limit card publications separately. See +Card::LimitedPublishing+. - before_action :ensure_under_limits, only: %i[ create ], if: -> { request.format.json? } - end -end diff --git a/saas/app/controllers/concerns/card/limited_publishing.rb b/saas/app/controllers/concerns/card/limited_publishing.rb deleted file mode 100644 index 8b60a4b53..000000000 --- a/saas/app/controllers/concerns/card/limited_publishing.rb +++ /dev/null @@ -1,9 +0,0 @@ -module Card::LimitedPublishing - extend ActiveSupport::Concern - - included do - include Card::Limited - - before_action :ensure_under_limits, only: %i[ create ] - end -end diff --git a/saas/app/controllers/stripe/webhooks_controller.rb b/saas/app/controllers/stripe/webhooks_controller.rb deleted file mode 100644 index 13dcd4699..000000000 --- a/saas/app/controllers/stripe/webhooks_controller.rb +++ /dev/null @@ -1,85 +0,0 @@ -class Stripe::WebhooksController < ApplicationController - allow_unauthenticated_access - skip_before_action :require_account - skip_forgery_protection - - def create - if event = verify_webhook_signature - dispatch_stripe_event(event) - head :ok - else - head :bad_request - end - end - - private - def dispatch_stripe_event(event) - case event.type - when "checkout.session.completed" - sync_new_subscription(event.data.object.subscription, plan_key: event.data.object.metadata["plan_key"]) if event.data.object.mode == "subscription" - when "customer.subscription.updated", "customer.subscription.deleted" - sync_subscription(event.data.object.id) - end - end - - def verify_webhook_signature - payload = request.body.read - sig_header = request.env["HTTP_STRIPE_SIGNATURE"] - - Stripe::Webhook.construct_event(payload, sig_header, ENV["STRIPE_WEBHOOK_SECRET"]) - rescue Stripe::SignatureVerificationError => e - Rails.logger.error "Stripe webhook signature verification failed: #{e.message}" - nil - end - - def sync_new_subscription(stripe_subscription_id, plan_key:) - sync_subscription(stripe_subscription_id) do |subscription_properties| - subscription_properties[:plan_key] = plan_key if plan_key - end - end - - # Always fetch fresh subscription data from Stripe to handle out-of-order - # event delivery. Not relying on payload data. - def sync_subscription(stripe_subscription_id) - stripe_subscription = Stripe::Subscription.retrieve(stripe_subscription_id) - - if subscription = find_subscription_by_stripe_customer(stripe_subscription.customer) - subscription_properties = { - stripe_subscription_id: stripe_subscription.id, - status: stripe_subscription.status, - current_period_end: current_period_end_for(stripe_subscription), - cancel_at: stripe_subscription.cancel_at ? Time.at(stripe_subscription.cancel_at) : nil, - next_amount_due_in_cents: next_amount_due_for(stripe_subscription), - plan_key: plan_key_for(stripe_subscription) - } - - yield subscription_properties if block_given? - subscription_properties[:stripe_subscription_id] = nil if stripe_subscription.status == "canceled" - - subscription.update!(subscription_properties) - end - end - - def find_subscription_by_stripe_customer(id) - Account::Subscription.find_by(stripe_customer_id: id) - end - - def current_period_end_for(stripe_subscription) - timestamp = stripe_subscription.items.data.first&.current_period_end - Time.at(timestamp) if timestamp - end - - def next_amount_due_for(stripe_subscription) - return nil if stripe_subscription.status == "canceled" - - preview = Stripe::Invoice.create_preview(customer: stripe_subscription.customer, subscription: stripe_subscription.id) - preview.amount_due - rescue Stripe::InvalidRequestError - nil - end - - def plan_key_for(stripe_subscription) - price_id = stripe_subscription.items.data.first&.price&.id - Plan.find_by_price_id(price_id)&.key - end -end diff --git a/saas/app/helpers/subscriptions_helper.rb b/saas/app/helpers/subscriptions_helper.rb deleted file mode 100644 index fd6b5aa47..000000000 --- a/saas/app/helpers/subscriptions_helper.rb +++ /dev/null @@ -1,19 +0,0 @@ -module SubscriptionsHelper - def storage_to_human_size(bytes) - number_to_human_size(bytes).delete(" ") - end - - def subscription_period_end_action(subscription) - if subscription.to_be_canceled? - "Your Fizzy subscription ends on" - elsif subscription.canceled? - "Your Fizzy subscription ended on" - else - "Your next payment is #{ format_currency(subscription.next_amount_due) } on".html_safe - end - end - - def format_currency(amount) - number_to_currency(amount, precision: (amount % 1).zero? ? 0 : 2) - end -end diff --git a/saas/app/jobs/account/sync_stripe_customer_email_job.rb b/saas/app/jobs/account/sync_stripe_customer_email_job.rb deleted file mode 100644 index b84eafa76..000000000 --- a/saas/app/jobs/account/sync_stripe_customer_email_job.rb +++ /dev/null @@ -1,8 +0,0 @@ -class Account::SyncStripeCustomerEmailJob < ApplicationJob - queue_as :default - retry_on Stripe::StripeError, wait: :polynomially_longer - - def perform(subscription) - subscription.sync_customer_email_to_stripe - end -end diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb deleted file mode 100644 index c124fd64c..000000000 --- a/saas/app/models/account/billing.rb +++ /dev/null @@ -1,50 +0,0 @@ -module Account::Billing - extend ActiveSupport::Concern - - included do - has_one :subscription, class_name: "Account::Subscription", dependent: :destroy - has_one :billing_waiver, class_name: "Account::BillingWaiver", dependent: :destroy - - set_callback :incinerate, :before, -> { subscription&.cancel } - set_callback :cancel, :after, -> { subscription&.pause } - set_callback :reactivate, :before, -> { subscription&.resume } - end - - def plan - active_subscription&.plan || Plan.free - end - - def subscribed? - subscription.present? - end - - def comped? - billing_waiver.present? - end - - def comp - create_billing_waiver unless billing_waiver - end - - def uncomp - billing_waiver&.destroy! - reload_billing_waiver - end - - def owner_email_changed - Account::SyncStripeCustomerEmailJob.perform_later(subscription) if subscription - end - - private - def active_subscription - if comped? - comped_subscription - elsif subscription&.active? - subscription - end - end - - def comped_subscription - @comped_subscription ||= billing_waiver&.subscription - end -end diff --git a/saas/app/models/account/billing_waiver.rb b/saas/app/models/account/billing_waiver.rb deleted file mode 100644 index a9ec35eb8..000000000 --- a/saas/app/models/account/billing_waiver.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Account::BillingWaiver < SaasRecord - belongs_to :account - - def subscription - @subscription ||= Account::Subscription.new(plan: Plan.paid_with_extra_storage) - end -end diff --git a/saas/app/models/account/limited.rb b/saas/app/models/account/limited.rb deleted file mode 100644 index 2918e90a3..000000000 --- a/saas/app/models/account/limited.rb +++ /dev/null @@ -1,56 +0,0 @@ -module Account::Limited - extend ActiveSupport::Concern - - included do - has_one :overridden_limits, class_name: "Account::OverriddenLimits", dependent: :destroy - end - - NEAR_CARD_LIMIT_THRESHOLD = 100 - NEAR_STORAGE_LIMIT_THRESHOLD = 500.megabytes - - def override_limits(card_count: nil, bytes_used: nil) - (overridden_limits || build_overridden_limits).update!(card_count:, bytes_used:) - end - - def billed_cards_count - overridden_limits&.card_count || cards_count - end - - def billed_bytes_used - overridden_limits&.bytes_used || bytes_used - end - - def nearing_plan_cards_limit? - plan.limit_cards? && remaining_cards_count <= NEAR_CARD_LIMIT_THRESHOLD - end - - def exceeding_card_limit? - plan.limit_cards? && billed_cards_count >= plan.card_limit - end - - def nearing_plan_storage_limit? - remaining_storage < NEAR_STORAGE_LIMIT_THRESHOLD - end - - def exceeding_storage_limit? - billed_bytes_used > plan.storage_limit - end - - def exceeding_limits? - exceeding_card_limit? || exceeding_storage_limit? - end - - def reset_overridden_limits - overridden_limits&.destroy - reload_overridden_limits - end - - private - def remaining_cards_count - plan.card_limit - billed_cards_count - end - - def remaining_storage - plan.storage_limit - billed_bytes_used - end -end diff --git a/saas/app/models/account/overridden_limits.rb b/saas/app/models/account/overridden_limits.rb deleted file mode 100644 index cdf3c51ec..000000000 --- a/saas/app/models/account/overridden_limits.rb +++ /dev/null @@ -1,4 +0,0 @@ -# To ease testing of limits -class Account::OverriddenLimits < SaasRecord - belongs_to :account -end diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb deleted file mode 100644 index 2ae7e3171..000000000 --- a/saas/app/models/account/subscription.rb +++ /dev/null @@ -1,67 +0,0 @@ -class Account::Subscription < SaasRecord - belongs_to :account - - enum :status, %w[ active past_due unpaid canceled incomplete incomplete_expired trialing paused ].index_by(&:itself) - - validates :plan_key, presence: true, inclusion: { in: Plan::PLANS.keys.map(&:to_s) } - - delegate :paid?, to: :plan - - def plan - @plan ||= Plan.find(plan_key) - end - - def plan=(plan) - self.plan_key = plan.key - end - - def to_be_canceled? - active? && cancel_at.present? - end - - def next_amount_due - next_amount_due_in_cents ? next_amount_due_in_cents / 100.0 : plan.price - end - - def pause - if stripe_subscription_id.present? - Stripe::Subscription.update( - stripe_subscription_id, - pause_collection: { behavior: "void" } - ) - end - end - - def resume - if stripe_subscription_id.present? - Stripe::Subscription.update( - stripe_subscription_id, - pause_collection: "" - ) - end - end - - def cancel - Stripe::Subscription.cancel(stripe_subscription_id) if stripe_subscription_id.present? - rescue Stripe::InvalidRequestError => e - # Subscription already deleted/canceled in Stripe - treat as success - Rails.logger.warn "Stripe subscription #{stripe_subscription_id} not found during cancel: #{e.message}" - end - - def sync_customer_email_to_stripe - if stripe_customer_id && (email = owner_email) - Stripe::Customer.update(stripe_customer_id, email: email) - end - rescue Stripe::InvalidRequestError => e - # Customer already deleted in Stripe - treat as success - Rails.logger.warn "Stripe customer #{stripe_customer_id} not found during email sync: #{e.message}" - end - - private - # Account owner email for Stripe customer record. Returns nil when: - # - No owner exists (ownership being transferred, account in limbo) - # - Owner has no identity (deactivated user) - def owner_email - account.users.owner.first&.identity&.email_address - end -end diff --git a/saas/app/models/plan.rb b/saas/app/models/plan.rb deleted file mode 100644 index e77ea81e8..000000000 --- a/saas/app/models/plan.rb +++ /dev/null @@ -1,59 +0,0 @@ -class Plan - PLANS = { - free_v1: { name: "Free", price: 0, card_limit: 1000, storage_limit: 1.gigabytes }, - monthly_v1: { name: "Unlimited", price: 20, card_limit: Float::INFINITY, storage_limit: 5.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_V1_PRICE_ID"] }, - monthly_extra_storage_v1: { name: "Unlimited + Extra Storage", price: 25, card_limit: Float::INFINITY, storage_limit: 500.gigabytes, stripe_price_id: ENV["STRIPE_MONTHLY_EXTRA_STORAGE_V1_PRICE_ID"] } - } - - attr_reader :key, :name, :price, :card_limit, :storage_limit, :stripe_price_id - - class << self - def all - @all ||= PLANS.map { |key, properties| new(key: key, **properties) } - end - - def free - @free ||= find(:free_v1) - end - - def paid - @paid ||= find(:monthly_v1) - end - - def paid_with_extra_storage - @paid_with_extra_storage ||= find(:monthly_extra_storage_v1) - end - - def find(key) - @all_by_key ||= all.index_by(&:key).with_indifferent_access - @all_by_key[key] - end - - def find_by_price_id(price_id) - all.find { |plan| plan.stripe_price_id == price_id } - end - - alias [] find - end - - def initialize(key:, name:, price:, card_limit:, storage_limit:, stripe_price_id: nil) - @key = key - @name = name - @price = price - @card_limit = card_limit - @storage_limit = storage_limit - @stripe_price_id = stripe_price_id - end - - def free? - price.zero? - end - - def paid? - !free? - end - - def limit_cards? - !card_limit.infinite? - end -end diff --git a/saas/app/models/user/notifies_account_of_email_change.rb b/saas/app/models/user/notifies_account_of_email_change.rb deleted file mode 100644 index daba4ba3d..000000000 --- a/saas/app/models/user/notifies_account_of_email_change.rb +++ /dev/null @@ -1,23 +0,0 @@ -module User::NotifiesAccountOfEmailChange - extend ActiveSupport::Concern - - included do - after_update :notify_account_of_owner_change, if: :account_owner_changed? - end - - private - # Account owner changed when: - # - The current owner changed their email - # - A user just became the owner (ownership transfer) - def account_owner_changed? - owner? && identity && (saved_change_to_identity_id? || became_owner?) - end - - def became_owner? - saved_change_to_role? && role_before_last_save != "owner" - end - - def notify_account_of_owner_change - account.owner_email_changed - end -end diff --git a/saas/app/views/account/settings/_free_plan.html.erb b/saas/app/views/account/settings/_free_plan.html.erb deleted file mode 100644 index ca467d8af..000000000 --- a/saas/app/views/account/settings/_free_plan.html.erb +++ /dev/null @@ -1,18 +0,0 @@ -

- <% 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.

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

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

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

- <% 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 %> -

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

-<% end %> - -

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

- - - diff --git a/saas/app/views/account/settings/_subscription_panel.html.erb b/saas/app/views/account/settings/_subscription_panel.html.erb deleted file mode 100644 index 1cfcda4d8..000000000 --- a/saas/app/views/account/settings/_subscription_panel.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -<% if Current.user.admin? && !Current.account.comped? %> -
-

Subscription

- - <% if Current.account.plan.free? %> - <%= render "account/settings/free_plan" %> - <% else %> - <%= render "account/settings/paid_plan" %> - <% end %> -
-<% end %> diff --git a/saas/app/views/account/subscriptions/_upgrade.html.erb b/saas/app/views/account/subscriptions/_upgrade.html.erb deleted file mode 100644 index 449fecf16..000000000 --- a/saas/app/views/account/subscriptions/_upgrade.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -
- <% if Current.user.admin? %> - <%= render "account/subscriptions/notices/admin_exceeding_card_limit" if Current.account.exceeding_card_limit? %> - <%= render "account/subscriptions/notices/admin_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %> - <% else %> - <%= render "account/subscriptions/notices/user_exceeding_card_limit" if Current.account.exceeding_card_limit? %> - <%= render "account/subscriptions/notices/user_exceeding_storage_limit" if Current.account.exceeding_storage_limit? %> - <% end %> -
diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb deleted file mode 100644 index 3dc759d9e..000000000 --- a/saas/app/views/account/subscriptions/notices/_admin_exceeding_card_limit.html.erb +++ /dev/null @@ -1,4 +0,0 @@ -You’ve used your <%= Plan.free.card_limit %> free cards. -<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> - Upgrade to Unlimited -<% end %> diff --git a/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb deleted file mode 100644 index 8770c1460..000000000 --- a/saas/app/views/account/subscriptions/notices/_admin_exceeding_storage_limit.html.erb +++ /dev/null @@ -1,4 +0,0 @@ -You’ve run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>. -<%= link_to account_settings_path(anchor: "subscription"), class: "btn settings-subscription__button" do %> - Upgrade to get more -<% end %> diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb deleted file mode 100644 index f3f65f3be..000000000 --- a/saas/app/views/account/subscriptions/notices/_user_exceeding_card_limit.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -
- This account has used <%= Plan.free.card_limit %> free cards. Upgrade to get more. -
diff --git a/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb b/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb deleted file mode 100644 index f230443a1..000000000 --- a/saas/app/views/account/subscriptions/notices/_user_exceeding_storage_limit.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -
- This account has run out of <%= Current.account.plan.free? ? "free storage" : "storage" %>. Upgrade to get more. -
diff --git a/saas/app/views/account/subscriptions/show.html.erb b/saas/app/views/account/subscriptions/show.html.erb deleted file mode 100644 index 8d29a379e..000000000 --- a/saas/app/views/account/subscriptions/show.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -<% @page_title = "Thank you" %> - -<% if @stripe_session&.payment_status == "paid" %> -
-

Thanks for buying Fizzy!

-

Your payment was successful. You’re now on the <%= Current.account.plan.name %> plan.

- <%= link_to "Done", account_settings_path, class: "btn settings-subscription__button txt-medium" %> -
-<% end %> diff --git a/saas/app/views/admin/accounts/edit.html.erb b/saas/app/views/admin/accounts/edit.html.erb deleted file mode 100644 index 76c23497c..000000000 --- a/saas/app/views/admin/accounts/edit.html.erb +++ /dev/null @@ -1,63 +0,0 @@ -<% @page_title = "Account admin" %> - -<% content_for :header do %> -
- <%= back_link_to "Accounts", saas.admin_accounts_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> -
-<% end %> - - -
-
-

Edit Account <%= @account.external_account_id %>

- - <%= form_with model: @account, url: saas.admin_account_path(@account.external_account_id), class: "flex flex-column gap" do |form| %> - - - - - - - - - - - - - - - - - - - - - - - - - -
Account Name:<%= @account.name %>
Subscription plan:<%= @account.plan.name %>
Actual card count:<%= @account.cards_count %> cards
Actual bytes used:<%= storage_to_human_size(@account.bytes_used) %>
- <%= form.label :card_count, class: "txt-subtle" do %> - Override card count
<%= "(Exceeds plan limit)" if @account.exceeding_card_limit? %> - <% end %> -
<%= form.number_field :card_count, value: @account.overridden_limits&.card_count, class: "input" %>
- <%= form.label :bytes_used, class: "txt-subtle" do %> - Override bytes used
<%= "(Exceeds plan limit)" if @account.exceeding_storage_limit? %> - <% end %> -
<%= form.number_field :bytes_used, value: @account.overridden_limits&.bytes_used, class: "input" %>
- - - <% end %> - - <% if @account.overridden_limits %> - <%= button_to "Reset limits", saas.admin_account_overridden_limits_path(@account.external_account_id), method: :delete, class: "btn btn--negative full-width" %> - <% end %> - - <% if @account.comped? %> - <%= button_to "Uncomp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :delete, class: "btn btn--plain txt-negative txt-underline" %> - <% else %> - <%= button_to "Comp account", saas.admin_account_billing_waiver_path(@account.external_account_id), method: :post, class: "btn btn--plain txt-positive txt-underline" %> - <% end %> -
-
diff --git a/saas/app/views/admin/accounts/index.html.erb b/saas/app/views/admin/accounts/index.html.erb deleted file mode 100644 index 96bed2338..000000000 --- a/saas/app/views/admin/accounts/index.html.erb +++ /dev/null @@ -1,13 +0,0 @@ -
-
-

Find an account

- - <%= form_with url: saas.admin_account_search_path, class: "flex flex-column gap" do |form| %> - <%= form.text_field :q, placeholder: "Account ID", autofocus: true, class: "input" %> - <%= form.submit "Search", class: "btn btn--link" %> - <% end %> -
-
- - - diff --git a/saas/app/views/admin/stats/show.html.erb b/saas/app/views/admin/stats/show.html.erb index f93bd3eba..c2b1e1d5d 100644 --- a/saas/app/views/admin/stats/show.html.erb +++ b/saas/app/views/admin/stats/show.html.erb @@ -33,32 +33,6 @@ -
-
-

Accounts Subscribed

-
-
-
-
Total
-
- <%= @paid_accounts_total %> -
-
-
-
7 days
-
- <%= @paid_accounts_last_7_days %> -
-
-
-
24 hours
-
- <%= @paid_accounts_last_24_hours %> -
-
-
-
-

Identities Created

diff --git a/saas/app/views/cards/container/footer/saas/_create.html.erb b/saas/app/views/cards/container/footer/saas/_create.html.erb deleted file mode 100644 index 8b0edd308..000000000 --- a/saas/app/views/cards/container/footer/saas/_create.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -<% if Current.account.exceeding_limits? %> - <%= render "account/subscriptions/upgrade" %> -<% else %> - <%= render "cards/container/footer/create", card: card %> -<% end %> diff --git a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb b/saas/app/views/cards/container/footer/saas/_near_notice.html.erb deleted file mode 100644 index 4cf480f3c..000000000 --- a/saas/app/views/cards/container/footer/saas/_near_notice.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -
- <% 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/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 eaf7e103d..000000000 --- a/saas/test/models/account/subscription_test.rb +++ /dev/null @@ -1,194 +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 "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 diff --git a/test/controllers/accounts/entropies_controller_test.rb b/test/controllers/accounts/entropies_controller_test.rb index d00c0d768..fa2819d48 100644 --- a/test/controllers/accounts/entropies_controller_test.rb +++ b/test/controllers/accounts/entropies_controller_test.rb @@ -16,8 +16,9 @@ class Account::EntropiesControllerTest < ActionDispatch::IntegrationTest test "update as JSON" do put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } }, as: :json - assert_response :no_content + assert_response :success assert_equal 7.days, entropies("37s_account").reload.auto_postpone_period + assert_equal 7, @response.parsed_body["auto_postpone_period_in_days"] end test "update requires admin" do @@ -35,4 +36,20 @@ class Account::EntropiesControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity assert_equal original_period, entropies("37s_account").reload.auto_postpone_period end + + test "update as JSON rejects invalid auto_postpone_period" do + original_period = entropies("37s_account").auto_postpone_period + + put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 1 } }, as: :json + + assert_response :unprocessable_entity + assert_equal original_period, entropies("37s_account").reload.auto_postpone_period + end + + test "update as JSON requires admin" do + logout_and_sign_in_as :david + + put account_entropy_path, params: { entropy: { auto_postpone_period_in_days: 7 } }, as: :json + assert_response :forbidden + end end diff --git a/test/controllers/accounts/settings_controller_test.rb b/test/controllers/accounts/settings_controller_test.rb index 6f3fba5b0..88a6ef137 100644 --- a/test/controllers/accounts/settings_controller_test.rb +++ b/test/controllers/accounts/settings_controller_test.rb @@ -16,13 +16,6 @@ class Account::SettingsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to account_settings_path end - test "show as JSON" do - get account_settings_path, as: :json - assert_response :success - - assert_equal Current.account.name, @response.parsed_body["name"] - end - test "update as JSON" do put account_settings_path, params: { account: { name: "New Account Name" } }, as: :json @@ -36,4 +29,13 @@ class Account::SettingsControllerTest < ActionDispatch::IntegrationTest put account_settings_path, params: { account: { name: "New Account Name" } } assert_response :forbidden end + + test "show as JSON" do + get account_settings_path, as: :json + + assert_response :success + assert_equal Current.account.name, @response.parsed_body["name"] + assert_equal Current.account.cards_count, @response.parsed_body["cards_count"] + assert_equal Current.account.entropy.auto_postpone_period_in_days, @response.parsed_body["auto_postpone_period_in_days"] + end end diff --git a/test/controllers/api/flat_json_params_test.rb b/test/controllers/api/flat_json_params_test.rb index 90f55f8fd..7a9a5ea5d 100644 --- a/test/controllers/api/flat_json_params_test.rb +++ b/test/controllers/api/flat_json_params_test.rb @@ -41,14 +41,14 @@ class FlatJsonParamsTest < ActionDispatch::IntegrationTest put board_entropy_path(board), params: { auto_postpone_period_in_days: 90 }, as: :json - assert_response :no_content + assert_response :success assert_equal 90.days, board.entropy.reload.auto_postpone_period end test "update account entropy with flat JSON" do put account_entropy_path, params: { auto_postpone_period_in_days: 7 }, as: :json - assert_response :no_content + assert_response :success assert_equal 7.days, Current.account.entropy.reload.auto_postpone_period end diff --git a/test/controllers/boards/entropies_controller_test.rb b/test/controllers/boards/entropies_controller_test.rb index e0fd4fa52..4173694ca 100644 --- a/test/controllers/boards/entropies_controller_test.rb +++ b/test/controllers/boards/entropies_controller_test.rb @@ -17,10 +17,13 @@ class Boards::EntropiesControllerTest < ActionDispatch::IntegrationTest end test "update as JSON" do - put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 90 } }, as: :json + assert_no_difference -> { Current.account.entropy.reload.auto_postpone_period } do + put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 90 } }, as: :json - assert_response :no_content - assert_equal 90.days, @board.entropy.reload.auto_postpone_period + assert_response :success + assert_equal 90.days, @board.entropy.reload.auto_postpone_period + assert_equal 90, @response.parsed_body["auto_postpone_period_in_days"] + end end test "update requires board admin permission" do @@ -42,4 +45,24 @@ class Boards::EntropiesControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity assert_equal original_period, @board.entropy.reload.auto_postpone_period end + + test "update as JSON rejects invalid auto_postpone_period" do + original_period = @board.entropy.auto_postpone_period + + put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 1 } }, as: :json + + assert_response :unprocessable_entity + assert_equal original_period, @board.entropy.reload.auto_postpone_period + end + + test "update as JSON requires board admin permission" do + logout_and_sign_in_as :jz + + original_period = @board.entropy.auto_postpone_period + + put board_entropy_path(@board), params: { board: { auto_postpone_period_in_days: 7 } }, as: :json + + assert_response :forbidden + assert_equal original_period, @board.entropy.reload.auto_postpone_period + end end diff --git a/test/controllers/boards_controller_test.rb b/test/controllers/boards_controller_test.rb index 915310108..059c38026 100644 --- a/test/controllers/boards_controller_test.rb +++ b/test/controllers/boards_controller_test.rb @@ -258,6 +258,7 @@ class BoardsControllerTest < ActionDispatch::IntegrationTest get board_path(boards(:writebook)), as: :json assert_response :success assert_equal boards(:writebook).name, @response.parsed_body["name"] + assert_equal boards(:writebook).auto_postpone_period_in_days, @response.parsed_body["auto_postpone_period_in_days"] end test "show as JSON includes public_url when published" do diff --git a/test/controllers/concerns/set_platform_test.rb b/test/controllers/concerns/set_platform_test.rb new file mode 100644 index 000000000..26ce9d66a --- /dev/null +++ b/test/controllers/concerns/set_platform_test.rb @@ -0,0 +1,21 @@ +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" + + 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 + end + + test "prefers x_user_agent cookie over request user agent" do + sign_in_as :david + + 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 + end +end diff --git a/test/integration/blob_key_traversal_test.rb b/test/integration/blob_key_traversal_test.rb new file mode 100644 index 000000000..31dc8e179 --- /dev/null +++ b/test/integration/blob_key_traversal_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class Account::DataTransfer::ActiveStorage::BlobKeyTraversalTest < ActionDispatch::IntegrationTest + test "import with path traversal blob key does not leak local files" do + blob_id = ActiveRecord::Type::Uuid.generate + traversal_key = "../../config/deploy.yml" + zip = build_zip_with_blob(id: blob_id, key: traversal_key) + Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip) + blob = ActiveStorage::Blob.find(blob_id) + + assert_not_equal traversal_key, blob.key + + sign_in_as identities(:david) + get rails_blob_path(blob, disposition: "inline") + + assert_response :redirect + + follow_redirect! + + assert_response :not_found + end + + private + def build_zip_with_blob(id:, key:) + tempfile = Tempfile.new([ "malicious", ".zip" ]) + tempfile.binmode + + writer = ZipFile::Writer.new(tempfile) + writer.add_file("data/active_storage_blobs/#{id}.json", { + id: id, + account_id: ActiveRecord::Type::Uuid.generate, + byte_size: 32, + checksum: "", + content_type: "text/plain", + created_at: Time.current.iso8601, + filename: "traversal.txt", + key: key, + metadata: {} + }.to_json) + writer.close + + tempfile.rewind + ZipFile::Reader.new(tempfile) + end +end diff --git a/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb index a28935a3e..2fe2c4b93 100644 --- a/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb +++ b/test/models/account/data_transfer/action_text/rich_text_record_set_test.rb @@ -30,6 +30,7 @@ class Account::DataTransfer::ActionText::RichTextRecordSetTest < ActiveSupport:: reader = ZipFile::Reader.new(tempfile) record_set = Account::DataTransfer::ActionText::RichTextRecordSet.new(importing_account) + record_set.importable_model_names = %w[ ActionText::RichText Card ] error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do record_set.check(from: reader) diff --git a/test/models/account/data_transfer/active_storage/blob_record_set_test.rb b/test/models/account/data_transfer/active_storage/blob_record_set_test.rb new file mode 100644 index 000000000..82b83f428 --- /dev/null +++ b/test/models/account/data_transfer/active_storage/blob_record_set_test.rb @@ -0,0 +1,58 @@ +require "test_helper" + +class Account::DataTransfer::ActiveStorage::BlobRecordSetTest < ActiveSupport::TestCase + test "import generates fresh keys instead of using exported keys" do + blob_id = ActiveRecord::Type::Uuid.generate + exported_key = "original-exported-key-abc123" + + zip = build_zip_with_blob(id: blob_id, key: exported_key) + Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip) + + blob = ActiveStorage::Blob.find(blob_id) + assert_not_equal exported_key, blob.key + assert_equal 28, blob.key.length + end + + test "import preserves blob metadata" do + blob_id = ActiveRecord::Type::Uuid.generate + + zip = build_zip_with_blob( + id: blob_id, + key: "some-key", + filename: "report.pdf", + content_type: "application/pdf", + byte_size: 12345, + checksum: "abc123checksum" + ) + Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip) + + blob = ActiveStorage::Blob.find(blob_id) + assert_equal "report.pdf", blob.filename.to_s + assert_equal "application/pdf", blob.content_type + assert_equal 12345, blob.byte_size + assert_equal "abc123checksum", blob.checksum + end + + private + def build_zip_with_blob(id:, key:, filename: "test.txt", content_type: "text/plain", byte_size: 32, checksum: "") + tempfile = Tempfile.new([ "test_export", ".zip" ]) + tempfile.binmode + + writer = ZipFile::Writer.new(tempfile) + writer.add_file("data/active_storage_blobs/#{id}.json", { + id: id, + account_id: ActiveRecord::Type::Uuid.generate, + byte_size: byte_size, + checksum: checksum, + content_type: content_type, + created_at: Time.current.iso8601, + filename: filename, + key: key, + metadata: {} + }.to_json) + writer.close + + tempfile.rewind + ZipFile::Reader.new(tempfile) + end +end diff --git a/test/models/account/data_transfer/active_storage/file_record_set_test.rb b/test/models/account/data_transfer/active_storage/file_record_set_test.rb new file mode 100644 index 000000000..4784754ae --- /dev/null +++ b/test/models/account/data_transfer/active_storage/file_record_set_test.rb @@ -0,0 +1,177 @@ +require "test_helper" + +class Account::DataTransfer::ActiveStorage::FileRecordSetTest < ActiveSupport::TestCase + test "import uploads file data to blobs with regenerated keys" do + blob_id = ActiveRecord::Type::Uuid.generate + old_key = "original-key-for-file" + file_content = "hello world file content" + + zip = build_zip_with_blob_and_file(blob_id: blob_id, old_key: old_key, file_content: file_content) + + Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip) + Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip) + + blob = ActiveStorage::Blob.find(blob_id) + assert_not_equal old_key, blob.key + assert_equal file_content, blob.download + end + + test "import handles keys containing path separators" do + blob_id = ActiveRecord::Type::Uuid.generate + old_key = "folder/subfolder/file-key" + file_content = "nested key content" + + zip = build_zip_with_blob_and_file(blob_id: blob_id, old_key: old_key, file_content: file_content) + + Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip) + Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip) + + blob = ActiveStorage::Blob.find(blob_id) + assert_not_equal old_key, blob.key + assert_equal file_content, blob.download + end + + test "import raises IntegrityError for storage file without matching blob metadata" do + blob_id = ActiveRecord::Type::Uuid.generate + old_key = "key-with-metadata" + orphan_key = "orphaned-storage-key" + + zip = build_zip_with_orphaned_storage_file( + blob_id: blob_id, + old_key: old_key, + orphan_key: orphan_key + ) + + Account::DataTransfer::ActiveStorage::BlobRecordSet.new(Current.account).import(from: zip) + + assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do + Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip) + end + end + + test "import raises IntegrityError when mapped blob is not found in database" do + blob_id = ActiveRecord::Type::Uuid.generate + old_key = "key-for-missing-blob" + + zip = build_zip_with_blob_and_file(blob_id: blob_id, old_key: old_key, file_content: "data") + + # Import file data WITHOUT importing blob metadata first + assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do + Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip) + end + end + + test "check raises IntegrityError for storage file without matching blob metadata" do + blob_id = ActiveRecord::Type::Uuid.generate + old_key = "key-with-metadata" + orphan_key = "orphaned-storage-key" + + zip = build_zip_with_orphaned_storage_file( + blob_id: blob_id, + old_key: old_key, + orphan_key: orphan_key + ) + + assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do + Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).check(from: zip) + end + end + + test "import raises IntegrityError for duplicate blob keys in export" do + blob_id_1 = ActiveRecord::Type::Uuid.generate + blob_id_2 = ActiveRecord::Type::Uuid.generate + duplicate_key = "same-key-for-both" + + zip = build_zip_with_duplicate_keys( + blob_id_1: blob_id_1, + blob_id_2: blob_id_2, + key: duplicate_key + ) + + assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do + Account::DataTransfer::ActiveStorage::FileRecordSet.new(Current.account).import(from: zip) + end + end + + private + def build_zip_with_blob_and_file(blob_id:, old_key:, file_content:) + tempfile = Tempfile.new([ "test_export", ".zip" ]) + tempfile.binmode + + writer = ZipFile::Writer.new(tempfile) + writer.add_file("data/active_storage_blobs/#{blob_id}.json", { + id: blob_id, + account_id: ActiveRecord::Type::Uuid.generate, + byte_size: file_content.bytesize, + checksum: Digest::MD5.base64digest(file_content), + content_type: "text/plain", + created_at: Time.current.iso8601, + filename: "test.txt", + key: old_key, + metadata: {} + }.to_json) + writer.add_file("storage/#{old_key}", file_content, compress: false) + writer.close + + tempfile.rewind + ZipFile::Reader.new(tempfile) + end + + def build_zip_with_orphaned_storage_file(blob_id:, old_key:, orphan_key:) + tempfile = Tempfile.new([ "test_export", ".zip" ]) + tempfile.binmode + + writer = ZipFile::Writer.new(tempfile) + writer.add_file("data/active_storage_blobs/#{blob_id}.json", { + id: blob_id, + account_id: ActiveRecord::Type::Uuid.generate, + byte_size: 10, + checksum: "", + content_type: "text/plain", + created_at: Time.current.iso8601, + filename: "test.txt", + key: old_key, + metadata: {} + }.to_json) + writer.add_file("storage/#{old_key}", "file data", compress: false) + writer.add_file("storage/#{orphan_key}", "orphan data", compress: false) + writer.close + + tempfile.rewind + ZipFile::Reader.new(tempfile) + end + + def build_zip_with_duplicate_keys(blob_id_1:, blob_id_2:, key:) + tempfile = Tempfile.new([ "test_export", ".zip" ]) + tempfile.binmode + + writer = ZipFile::Writer.new(tempfile) + writer.add_file("data/active_storage_blobs/#{blob_id_1}.json", { + id: blob_id_1, + account_id: ActiveRecord::Type::Uuid.generate, + byte_size: 10, + checksum: "", + content_type: "text/plain", + created_at: Time.current.iso8601, + filename: "file1.txt", + key: key, + metadata: {} + }.to_json) + writer.add_file("data/active_storage_blobs/#{blob_id_2}.json", { + id: blob_id_2, + account_id: ActiveRecord::Type::Uuid.generate, + byte_size: 10, + checksum: "", + content_type: "text/plain", + created_at: Time.current.iso8601, + filename: "file2.txt", + key: key, + metadata: {} + }.to_json) + writer.add_file("storage/#{key}", "file data", compress: false) + writer.close + + tempfile.rewind + ZipFile::Reader.new(tempfile) + end +end diff --git a/test/models/account/data_transfer/record_set_test.rb b/test/models/account/data_transfer/record_set_test.rb new file mode 100644 index 000000000..8c8d75e5e --- /dev/null +++ b/test/models/account/data_transfer/record_set_test.rb @@ -0,0 +1,93 @@ +require "test_helper" + +class Account::DataTransfer::RecordSetTest < ActiveSupport::TestCase + setup do + @importable_model_names = %w[ Card Board Event ] + end + + test "check rejects polymorphic type not in the importable models allowlist" do + event_data = build_event_data(eventable_type: "Identity") + + record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names) + + error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do + record_set.check(from: build_reader(dir: "events", data: event_data)) + end + + assert_match(/unrecognized.*type/i, error.message) + end + + test "check rejects nonexistent polymorphic type" do + event_data = build_event_data(eventable_type: "Nonexistent::ClassName") + + record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names) + + error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do + record_set.check(from: build_reader(dir: "events", data: event_data)) + end + + assert_match(/unrecognized.*type/i, error.message) + end + + test "check rejects non-ActiveRecord class used as polymorphic type" do + event_data = build_event_data(eventable_type: "ActiveSupport::BroadcastLogger") + + record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names) + + error = assert_raises(Account::DataTransfer::RecordSet::IntegrityError) do + record_set.check(from: build_reader(dir: "events", data: event_data)) + end + + assert_match(/unrecognized.*type/i, error.message) + end + + test "check accepts polymorphic type in the importable models allowlist" do + event_data = build_event_data(eventable_type: "Card") + + record_set = Account::DataTransfer::RecordSet.new(account: importing_account, model: Event, importable_model_names: @importable_model_names) + + assert_nothing_raised do + record_set.check(from: build_reader(dir: "events", data: event_data)) + end + end + + private + def importing_account + @importing_account ||= Account.create!(name: "Importing Account", external_account_id: 99999999) + end + + def build_event_data(eventable_type:) + { + "id" => "test_event_id_12345678901234", + "account_id" => "nonexistent_account_id_1234567", + "board_id" => "nonexistent_board_id_12345678", + "creator_id" => "nonexistent_user_id_123456789", + "eventable_type" => eventable_type, + "eventable_id" => "nonexistent_id_1234567890123", + "action" => "created", + "particulars" => "{}", + "created_at" => Time.current.iso8601, + "updated_at" => Time.current.iso8601 + } + end + + def build_reader(dir:, data:) + tempfile = Tempfile.new([ "import_test", ".zip" ]) + tempfile.binmode + + writer = ZipFile::Writer.new(tempfile) + writer.add_file("data/#{dir}/#{data['id']}.json", data.to_json) + writer.close + tempfile.rewind + + @tempfiles ||= [] + @tempfiles << tempfile + + ZipFile::Reader.new(tempfile) + end + + def teardown + @tempfiles&.each { |f| f.close; f.unlink } + @importing_account&.destroy + end +end diff --git a/test/models/account/import_test.rb b/test/models/account/import_test.rb index 7bf407041..de384b385 100644 --- a/test/models/account/import_test.rb +++ b/test/models/account/import_test.rb @@ -174,6 +174,52 @@ class Account::ImportTest < ActiveSupport::TestCase export_tempfile&.unlink end + test "export and import round-trip preserves blobs and attachments" do + source_account = accounts("37s") + exporter = users(:david) + identity = exporter.identity + + ActiveStorage::Blob.create_and_upload!( + io: StringIO.new("test image data"), + filename: "logo.png", + content_type: "image/png" + ) + + source_blob_count = ActiveStorage::Blob.where(account: source_account).count + source_blob_keys = ActiveStorage::Blob.where(account: source_account).pluck(:key) + + assert_operator source_blob_count, :>, 0 + + export = Account::Export.create!(account: source_account, user: exporter) + export.build + + export_tempfile = Tempfile.new([ "export", ".zip" ]) + export.file.open { |f| FileUtils.cp(f.path, export_tempfile.path) } + + ActiveStorage::Blob.where(account: source_account).delete_all + source_account.destroy! + + target_account = Account.create_with_owner(account: { name: "Import Test" }, owner: { identity: identity, name: exporter.name }) + import = Account::Import.create!(identity: identity, account: target_account) + Current.set(account: target_account) do + import.file.attach(io: File.open(export_tempfile.path), filename: "export.zip", content_type: "application/zip") + end + + import.check + assert_not import.failed? + + import.process + assert import.completed? + + imported_blob = ActiveStorage::Blob.find_by(account: target_account, filename: "logo.png") + assert_not_nil imported_blob + assert_not_includes source_blob_keys, imported_blob.key + assert_equal "test image data", imported_blob.download + ensure + export_tempfile&.close + export_tempfile&.unlink + end + private def account_digest(account) {