From 9b47269349ff28f4a481d1de3cd5d01210b237a3 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 7 Jan 2026 10:05:46 -0500 Subject: [PATCH 1/8] Document closed and column fields in card API response Add missing fields to the GET card endpoint documentation: - closed: indicates whether the card is in "Done" state - column: present only when card is triaged into a column --- docs/API.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/API.md b/docs/API.md index ee1820f28..c43d23477 100644 --- a/docs/API.md +++ b/docs/API.md @@ -574,6 +574,7 @@ __Response:__ "description_html": "

Hello, World!

", "image_url": null, "tags": ["programming"], + "closed": false, "golden": false, "last_active_at": "2025-12-05T19:38:48.553Z", "created_at": "2025-12-05T19:38:48.540Z", @@ -594,6 +595,15 @@ __Response:__ "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" } }, + "column": { + "id": "03f5v9zkft4hj9qq0lsn9ohcn", + "name": "In Progress", + "color": { + "name": "Lime", + "value": "var(--color-card-4)" + }, + "created_at": "2025-12-05T19:36:35.534Z" + }, "creator": { "id": "03f5v9zjw7pz8717a4no1h8a7", "name": "David Heinemeier Hansson", @@ -619,6 +629,8 @@ __Response:__ } ``` +> **Note:** The `closed` field indicates whether the card is in the "Done" state. The `column` field is only present when the card has been triaged into a column; cards in "Maybe?", "Not Now" or "Done" will not have this field. + ### `POST /:account_slug/boards/:board_id/cards` Creates a new card in a board. From 7de65be756a8a5c4e4eabfb718578bdc72014eb3 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 7 Jan 2026 10:12:03 -0500 Subject: [PATCH 2/8] Document goldness endpoints for marking cards as golden Add POST and DELETE endpoints for /:account_slug/cards/:card_number/goldness --- docs/API.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/API.md b/docs/API.md index c43d23477..0fe71404a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -779,6 +779,22 @@ __Response:__ Returns `204 No Content` on success. +### `POST /:account_slug/cards/:card_number/goldness` + +Marks a card as golden. + +__Response:__ + +Returns `204 No Content` on success. + +### `DELETE /:account_slug/cards/:card_number/goldness` + +Removes golden status from a card. + +__Response:__ + +Returns `204 No Content` on success. + ## Comments Comments are attached to cards and support rich text. From 562d340b84131c95771bc9b3b409f06a1199a64c Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 7 Jan 2026 10:17:34 -0500 Subject: [PATCH 3/8] Document DELETE endpoint for removing card header image --- docs/API.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/API.md b/docs/API.md index 0fe71404a..bd4488127 100644 --- a/docs/API.md +++ b/docs/API.md @@ -695,6 +695,14 @@ __Response:__ Returns `204 No Content` on success. +### `DELETE /:account_slug/cards/:card_number/image` + +Removes the header image from a card. + +__Response:__ + +Returns `204 No Content` on success. + ### `POST /:account_slug/cards/:card_number/closure` Closes a card. From eceb6c9b433b13c496b4ef3b07d46bc573003811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanko=20Krtali=C4=87?= Date: Mon, 12 Jan 2026 14:21:05 +0100 Subject: [PATCH 4/8] Add self-service account deletion (#2246) * Add self-service account deletion * Disable access to cancelled accounts & implement Stripe interactions * Add tests * Fix failing tests * Remove cancelled accounts from lists * Fix incorrect redirect * Use _path instead of _url for consistency * Don't track how far the inicieration job got We still want the step tracking so that the job can be interrupted. But, since the scope is idempotent, we don't need to track how far it got. * Specify the exact time when the data will be deleted * Fix crash due to unadvancable cursor * Rename up_for_incineration to due_for_incineration * Regenrate the schema * Fix incorrect path check * Migrate the SQLite schema * Only show the cancel button on cancellable accounts * Check that a subscirption method exists before calling it * Ignore job failures due to missing records when an account gets deleted * Skip sending notifications on cancelled accounts * Use collbacks to integrate * Add a blank line between queue_as and discard_on * Break checks into methods * Inline methods * Rename Account::IncinerateJob * Run migrations * Migrate SQLite --- .../account/cancellations_controller.rb | 13 +++ app/controllers/concerns/authorization.rb | 8 +- app/controllers/my/menus_controller.rb | 2 +- app/controllers/public/base_controller.rb | 5 + app/controllers/sessions/menus_controller.rb | 2 +- app/jobs/account/incinerate_due_job.rb | 14 +++ app/jobs/board/clean_inaccessible_data_job.rb | 2 + app/jobs/card/activity_spike/detection_job.rb | 2 + .../remove_inaccessible_notifications_job.rb | 2 + app/jobs/event/webhook_dispatch_job.rb | 2 + app/jobs/export_account_data_job.rb | 2 + app/jobs/mention/create_job.rb | 2 + .../notification/bundle/deliver_all_job.rb | 2 + app/jobs/notification/bundle/deliver_job.rb | 2 + app/jobs/notify_recipients_job.rb | 2 + app/jobs/push_notification_job.rb | 2 + app/jobs/storage/reconcile_job.rb | 1 + app/jobs/webhook/delivery_job.rb | 2 + app/mailers/account_mailer.rb | 11 ++ app/models/account.rb | 4 +- app/models/account/cancellable.rb | 46 ++++++++ app/models/account/cancellation.rb | 4 + app/models/account/incineratable.rb | 17 +++ app/models/notification/bundle.rb | 2 +- app/models/notification_pusher.rb | 3 +- app/models/webhook/triggerable.rb | 2 +- .../account/settings/_cancellation.html.erb | 26 +++++ app/views/account/settings/show.html.erb | 1 + .../account_mailer/cancellation.html.erb | 14 +++ .../account_mailer/cancellation.text.erb | 13 +++ config/recurring.yml | 3 + config/routes.rb | 1 + ...1224092315_create_account_cancellations.rb | 10 ++ db/schema.rb | 10 +- db/schema_sqlite.rb | 12 ++- saas/app/models/account/billing.rb | 4 + saas/app/models/account/subscription.rb | 25 +++++ saas/test/models/account/billing_test.rb | 40 +++++++ saas/test/models/account/subscription_test.rb | 102 ++++++++++++++++++ .../account/cancellations_controller_test.rb | 46 ++++++++ test/controllers/my/menus_controller_test.rb | 16 +++ .../sessions/menus_controller_test.rb | 20 ++++ test/fixtures/accounts.yml | 6 ++ test/jobs/account/incinerate_due_job_test.rb | 67 ++++++++++++ test/mailers/account_mailer_test.rb | 57 ++++++++++ test/models/account/cancellable_test.rb | 79 ++++++++++++++ test/models/account/cancellation_test.rb | 7 ++ test/models/account/incineratable_test.rb | 26 +++++ test/models/notification/bundle_test.rb | 17 +++ test/models/notification_pusher_test.rb | 32 ++++++ test/models/webhook/triggerable_test.rb | 58 ++++++++++ 51 files changed, 836 insertions(+), 12 deletions(-) create mode 100644 app/controllers/account/cancellations_controller.rb create mode 100644 app/jobs/account/incinerate_due_job.rb create mode 100644 app/mailers/account_mailer.rb create mode 100644 app/models/account/cancellable.rb create mode 100644 app/models/account/cancellation.rb create mode 100644 app/models/account/incineratable.rb create mode 100644 app/views/account/settings/_cancellation.html.erb create mode 100644 app/views/mailers/account_mailer/cancellation.html.erb create mode 100644 app/views/mailers/account_mailer/cancellation.text.erb create mode 100644 db/migrate/20251224092315_create_account_cancellations.rb create mode 100644 test/controllers/account/cancellations_controller_test.rb create mode 100644 test/jobs/account/incinerate_due_job_test.rb create mode 100644 test/mailers/account_mailer_test.rb create mode 100644 test/models/account/cancellable_test.rb create mode 100644 test/models/account/cancellation_test.rb create mode 100644 test/models/account/incineratable_test.rb create mode 100644 test/models/notification_pusher_test.rb create mode 100644 test/models/webhook/triggerable_test.rb diff --git a/app/controllers/account/cancellations_controller.rb b/app/controllers/account/cancellations_controller.rb new file mode 100644 index 000000000..b6ac82ab4 --- /dev/null +++ b/app/controllers/account/cancellations_controller.rb @@ -0,0 +1,13 @@ +class Account::CancellationsController < ApplicationController + before_action :ensure_owner + + def create + Current.account.cancel + redirect_to session_menu_path(script_name: nil), notice: "Account deleted" + end + + private + def ensure_owner + head :forbidden unless Current.user.owner? + end +end diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index f30d518cc..a6456a050 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -2,7 +2,7 @@ module Authorization extend ActiveSupport::Concern included do - before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? } + before_action :ensure_can_access_account, if: :authenticated_account_access? end class_methods do @@ -25,8 +25,12 @@ module Authorization head :forbidden unless Current.identity.staff? end + def authenticated_account_access? + Current.account.present? && authenticated? + end + def ensure_can_access_account - if Current.user.blank? || !Current.user.active? + unless Current.account.active? && Current.user&.active? respond_to do |format| format.html { redirect_to session_menu_path(script_name: nil) } format.json { head :forbidden } diff --git a/app/controllers/my/menus_controller.rb b/app/controllers/my/menus_controller.rb index 32b3da004..24fa2b085 100644 --- a/app/controllers/my/menus_controller.rb +++ b/app/controllers/my/menus_controller.rb @@ -4,7 +4,7 @@ class My::MenusController < ApplicationController @boards = Current.user.boards.ordered_by_recently_accessed @tags = Current.account.tags.all.alphabetically @users = Current.account.users.active.alphabetically - @accounts = Current.identity.accounts + @accounts = Current.identity.accounts.active fresh_when etag: [ @filters, @boards, @tags, @users, @accounts ] end diff --git a/app/controllers/public/base_controller.rb b/app/controllers/public/base_controller.rb index 018b1ab36..503c914e4 100644 --- a/app/controllers/public/base_controller.rb +++ b/app/controllers/public/base_controller.rb @@ -2,6 +2,7 @@ class Public::BaseController < ApplicationController allow_unauthenticated_access before_action :set_board, :set_card, :set_public_cache_expiration + before_action :ensure_board_accessible layout "public" @@ -17,4 +18,8 @@ class Public::BaseController < ApplicationController def set_public_cache_expiration expires_in 30.seconds, public: true end + + def ensure_board_accessible + raise ActionController::RoutingError, "Not Found" if @board&.account&.cancelled? + end end diff --git a/app/controllers/sessions/menus_controller.rb b/app/controllers/sessions/menus_controller.rb index 984e83a43..5fd885c45 100644 --- a/app/controllers/sessions/menus_controller.rb +++ b/app/controllers/sessions/menus_controller.rb @@ -4,7 +4,7 @@ class Sessions::MenusController < ApplicationController layout "public" def show - @accounts = Current.identity.accounts + @accounts = Current.identity.accounts.active if @accounts.one? redirect_to root_url(script_name: @accounts.first.slug) diff --git a/app/jobs/account/incinerate_due_job.rb b/app/jobs/account/incinerate_due_job.rb new file mode 100644 index 000000000..5bb7b35eb --- /dev/null +++ b/app/jobs/account/incinerate_due_job.rb @@ -0,0 +1,14 @@ +class Account::IncinerateDueJob < ApplicationJob + include ActiveJob::Continuable + + queue_as :incineration + + def perform + step :incineration do |step| + Account.due_for_incineration.find_each do |account| + account.incinerate + step.checkpoint! + end + end + end +end diff --git a/app/jobs/board/clean_inaccessible_data_job.rb b/app/jobs/board/clean_inaccessible_data_job.rb index b2cdff085..5aa1eb0eb 100644 --- a/app/jobs/board/clean_inaccessible_data_job.rb +++ b/app/jobs/board/clean_inaccessible_data_job.rb @@ -1,4 +1,6 @@ class Board::CleanInaccessibleDataJob < ApplicationJob + discard_on ActiveJob::DeserializationError + def perform(user, board) board.clean_inaccessible_data_for(user) end diff --git a/app/jobs/card/activity_spike/detection_job.rb b/app/jobs/card/activity_spike/detection_job.rb index 2f5142d0d..82278e5d7 100644 --- a/app/jobs/card/activity_spike/detection_job.rb +++ b/app/jobs/card/activity_spike/detection_job.rb @@ -1,4 +1,6 @@ class Card::ActivitySpike::DetectionJob < ApplicationJob + discard_on ActiveJob::DeserializationError + def perform(card) card.detect_activity_spikes end diff --git a/app/jobs/card/remove_inaccessible_notifications_job.rb b/app/jobs/card/remove_inaccessible_notifications_job.rb index db7dec4e3..018c6cf8d 100644 --- a/app/jobs/card/remove_inaccessible_notifications_job.rb +++ b/app/jobs/card/remove_inaccessible_notifications_job.rb @@ -1,4 +1,6 @@ class Card::RemoveInaccessibleNotificationsJob < ApplicationJob + discard_on ActiveJob::DeserializationError + def perform(card) card.remove_inaccessible_notifications end diff --git a/app/jobs/event/webhook_dispatch_job.rb b/app/jobs/event/webhook_dispatch_job.rb index 1df418cc8..96f01ba50 100644 --- a/app/jobs/event/webhook_dispatch_job.rb +++ b/app/jobs/event/webhook_dispatch_job.rb @@ -5,6 +5,8 @@ class Event::WebhookDispatchJob < ApplicationJob queue_as :webhooks + discard_on ActiveJob::DeserializationError + def perform(event) step :dispatch do |step| Webhook.active.triggered_by(event).find_each(start: step.cursor) do |webhook| diff --git a/app/jobs/export_account_data_job.rb b/app/jobs/export_account_data_job.rb index 82b5071cb..c0286c736 100644 --- a/app/jobs/export_account_data_job.rb +++ b/app/jobs/export_account_data_job.rb @@ -1,6 +1,8 @@ class ExportAccountDataJob < ApplicationJob queue_as :backend + discard_on ActiveJob::DeserializationError + def perform(export) export.build end diff --git a/app/jobs/mention/create_job.rb b/app/jobs/mention/create_job.rb index 816782754..4376ac493 100644 --- a/app/jobs/mention/create_job.rb +++ b/app/jobs/mention/create_job.rb @@ -1,4 +1,6 @@ class Mention::CreateJob < ApplicationJob + discard_on ActiveJob::DeserializationError + def perform(record, mentioner:) record.create_mentions(mentioner:) end diff --git a/app/jobs/notification/bundle/deliver_all_job.rb b/app/jobs/notification/bundle/deliver_all_job.rb index 79aeaed16..d3e2cb738 100644 --- a/app/jobs/notification/bundle/deliver_all_job.rb +++ b/app/jobs/notification/bundle/deliver_all_job.rb @@ -1,6 +1,8 @@ class Notification::Bundle::DeliverAllJob < ApplicationJob queue_as :backend + discard_on ActiveJob::DeserializationError + def perform Notification::Bundle.deliver_all end diff --git a/app/jobs/notification/bundle/deliver_job.rb b/app/jobs/notification/bundle/deliver_job.rb index 11f6bcda5..b4059a779 100644 --- a/app/jobs/notification/bundle/deliver_job.rb +++ b/app/jobs/notification/bundle/deliver_job.rb @@ -3,6 +3,8 @@ class Notification::Bundle::DeliverJob < ApplicationJob queue_as :backend + discard_on ActiveJob::DeserializationError + def perform(bundle) bundle.deliver end diff --git a/app/jobs/notify_recipients_job.rb b/app/jobs/notify_recipients_job.rb index 6083622fd..bba5898da 100644 --- a/app/jobs/notify_recipients_job.rb +++ b/app/jobs/notify_recipients_job.rb @@ -1,4 +1,6 @@ class NotifyRecipientsJob < ApplicationJob + discard_on ActiveJob::DeserializationError + def perform(notifiable) notifiable.notify_recipients end diff --git a/app/jobs/push_notification_job.rb b/app/jobs/push_notification_job.rb index 124366967..c912e141d 100644 --- a/app/jobs/push_notification_job.rb +++ b/app/jobs/push_notification_job.rb @@ -1,4 +1,6 @@ class PushNotificationJob < ApplicationJob + discard_on ActiveJob::DeserializationError + def perform(notification) NotificationPusher.new(notification).push end diff --git a/app/jobs/storage/reconcile_job.rb b/app/jobs/storage/reconcile_job.rb index cd3bf3803..43574f428 100644 --- a/app/jobs/storage/reconcile_job.rb +++ b/app/jobs/storage/reconcile_job.rb @@ -5,6 +5,7 @@ class Storage::ReconcileJob < ApplicationJob limits_concurrency to: 1, key: ->(owner) { owner } discard_on ActiveJob::DeserializationError + retry_on ReconcileAborted, wait: 1.minute, attempts: 3 def perform(owner) diff --git a/app/jobs/webhook/delivery_job.rb b/app/jobs/webhook/delivery_job.rb index 95102b9d6..a6e324985 100644 --- a/app/jobs/webhook/delivery_job.rb +++ b/app/jobs/webhook/delivery_job.rb @@ -1,6 +1,8 @@ class Webhook::DeliveryJob < ApplicationJob queue_as :webhooks + discard_on ActiveJob::DeserializationError + def perform(delivery) delivery.deliver end diff --git a/app/mailers/account_mailer.rb b/app/mailers/account_mailer.rb new file mode 100644 index 000000000..5cb6616f8 --- /dev/null +++ b/app/mailers/account_mailer.rb @@ -0,0 +1,11 @@ +class AccountMailer < ApplicationMailer + def cancellation(cancellation) + @account = cancellation.account + @user = cancellation.initiated_by + + mail( + to: @user.identity.email_address, + subject: "Your Fizzy account was cancelled" + ) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index f9b2589c0..34b63b688 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,7 +1,7 @@ class Account < ApplicationRecord - include Account::Storage, Entropic, MultiTenantable, Seedeable + include Account::Storage, Cancellable, Entropic, Incineratable, MultiTenantable, Seedeable - has_one :join_code + has_one :join_code, dependent: :destroy has_many :users, dependent: :destroy has_many :boards, dependent: :destroy has_many :cards, dependent: :destroy diff --git a/app/models/account/cancellable.rb b/app/models/account/cancellable.rb new file mode 100644 index 000000000..f10624245 --- /dev/null +++ b/app/models/account/cancellable.rb @@ -0,0 +1,46 @@ +module Account::Cancellable + extend ActiveSupport::Concern + + included do + has_one :cancellation, dependent: :destroy + + scope :active, -> { where.missing(:cancellation) } + + define_callbacks :cancel + define_callbacks :reactivate + end + + def cancel(initiated_by: Current.user) + with_lock do + if cancellable? && active? + run_callbacks :cancel do + create_cancellation!(initiated_by: initiated_by) + end + + AccountMailer.cancellation(cancellation).deliver_later + end + end + end + + def reactivate + with_lock do + if cancelled? + run_callbacks :reactivate do + cancellation.destroy + end + end + end + end + + def active? + !cancelled? + end + + def cancelled? + cancellation.present? + end + + def cancellable? + Account.accepting_signups? + end +end diff --git a/app/models/account/cancellation.rb b/app/models/account/cancellation.rb new file mode 100644 index 000000000..ba8e73bed --- /dev/null +++ b/app/models/account/cancellation.rb @@ -0,0 +1,4 @@ +class Account::Cancellation < ApplicationRecord + belongs_to :account + belongs_to :initiated_by, class_name: "User" +end diff --git a/app/models/account/incineratable.rb b/app/models/account/incineratable.rb new file mode 100644 index 000000000..c734c0768 --- /dev/null +++ b/app/models/account/incineratable.rb @@ -0,0 +1,17 @@ +module Account::Incineratable + extend ActiveSupport::Concern + + INCINERATION_GRACE_PERIOD = 30.days + + included do + scope :due_for_incineration, -> { joins(:cancellation).where(account_cancellations: { created_at: ...INCINERATION_GRACE_PERIOD.ago }) } + + define_callbacks :incinerate + end + + def incinerate + run_callbacks :incinerate do + account.destroy + end + end +end diff --git a/app/models/notification/bundle.rb b/app/models/notification/bundle.rb index 8617a0f99..fb57a4fd0 100644 --- a/app/models/notification/bundle.rb +++ b/app/models/notification/bundle.rb @@ -74,7 +74,7 @@ class Notification::Bundle < ApplicationRecord end def deliverable? - user.settings.bundling_emails? && notifications.any? + user.settings.bundling_emails? && notifications.any? && account.active? end def overlapping_bundles diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb index acb5603a1..d6425e561 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -20,7 +20,8 @@ class NotificationPusher def should_push? notification.user.push_subscriptions.any? && !notification.creator.system? && - notification.user.active? + notification.user.active? && + notification.account.active? end def build_payload diff --git a/app/models/webhook/triggerable.rb b/app/models/webhook/triggerable.rb index 443c801fd..7f7ab5771 100644 --- a/app/models/webhook/triggerable.rb +++ b/app/models/webhook/triggerable.rb @@ -7,6 +7,6 @@ module Webhook::Triggerable end def trigger(event) - deliveries.create!(event: event) + deliveries.create!(event: event) unless account.cancelled? end end diff --git a/app/views/account/settings/_cancellation.html.erb b/app/views/account/settings/_cancellation.html.erb new file mode 100644 index 000000000..5c493edd5 --- /dev/null +++ b/app/views/account/settings/_cancellation.html.erb @@ -0,0 +1,26 @@ +<% if Current.account.cancellable? && Current.user.owner? %> +
+

Cancel account

+

Delete your Fizzy account

+
+ +
+ + + +

Delete your account?

+
    +
  • All users, including you, will lose access
  • + <% if Current.account.try(:active_subscription) %> +
  • Your subscription will be canceled
  • + <% end %> +
  • After 30 days your data will be permanently deleted
  • +
+ +
+ + <%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %> +
+
+
+<% end %> diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index fc5e799b3..74e274414 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -18,6 +18,7 @@
<%= render "account/settings/entropy", account: @account %> <%= render "account/settings/export" %> + <%= render "account/settings/cancellation" %>
diff --git a/app/views/mailers/account_mailer/cancellation.html.erb b/app/views/mailers/account_mailer/cancellation.html.erb new file mode 100644 index 000000000..831a1cdab --- /dev/null +++ b/app/views/mailers/account_mailer/cancellation.html.erb @@ -0,0 +1,14 @@ +

Your Fizzy account <%= @account.name %> was cancelled.

+ +

What happens now?

+ +
    +
  • No one can access the account anymore
  • + <% if @account.try(:subscription) %> +
  • We won't charge you anymore
  • + <% end %> +
  • Everything in the account will be deleted in <%= distance_of_time_in_words_to_now(Account::Incineratable::INCINERATION_GRACE_PERIOD.from_now) %>
  • +
+ +Changed your mind? +

If you want to cancel this deletion and restore your account, send us an email to support@fizzy.do as soon as possible.

diff --git a/app/views/mailers/account_mailer/cancellation.text.erb b/app/views/mailers/account_mailer/cancellation.text.erb new file mode 100644 index 000000000..3cfaf2689 --- /dev/null +++ b/app/views/mailers/account_mailer/cancellation.text.erb @@ -0,0 +1,13 @@ +Your Fizzy account "<%= @account.name %>" was cancelled. + +WHAT HAPPENS NOW? + +- No one can access the account anymore +<% if @account.try(:subscription) %> +- We won't charge you anymore +<% end %> +- Everything in the account will be deleted in <%= distance_of_time_in_words_to_now(Account::Incineratable::INCINERATION_GRACE_PERIOD.from_now) %> + +CHANGED YOUR MIND? + +If you want to cancel this deletion and restore your account, send us an email to support@fizzy.do as soon as possible. diff --git a/config/recurring.yml b/config/recurring.yml index bc1d069cc..b044a4978 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -27,6 +27,9 @@ production: &production cleanup_exports: command: "Account::Export.cleanup" schedule: every hour at minute 20 + incineration: + job: Account::IncinerateDueJob + schedule: every 8 hours at minute 16 <% if Fizzy.saas? %> # Metrics diff --git a/config/routes.rb b/config/routes.rb index 0fd75fd98..97e34290c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,6 +2,7 @@ Rails.application.routes.draw do root "events#index" namespace :account do + resource :cancellation, only: [ :create ] resource :entropy resource :join_code resource :settings diff --git a/db/migrate/20251224092315_create_account_cancellations.rb b/db/migrate/20251224092315_create_account_cancellations.rb new file mode 100644 index 000000000..cf12286f0 --- /dev/null +++ b/db/migrate/20251224092315_create_account_cancellations.rb @@ -0,0 +1,10 @@ +class CreateAccountCancellations < ActiveRecord::Migration[8.2] + def change + create_table :account_cancellations, id: :uuid do |t| + t.uuid :account_id, null: false, index: { unique: true } + t.uuid :initiated_by_id, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index c120a87ae..86b9f2937 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do +ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -25,6 +25,14 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do t.index ["user_id"], name: "index_accesses_on_user_id" end + create_table "account_cancellations", 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.uuid "initiated_by_id", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true + end + create_table "account_exports", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.datetime "completed_at" diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 68b3ea422..b76f99865 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do +ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -25,6 +25,14 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do t.index ["user_id"], name: "index_accesses_on_user_id" end + create_table "account_cancellations", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.uuid "initiated_by_id", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_cancellations_on_account_id", unique: true + end + create_table "account_exports", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.datetime "completed_at" @@ -469,7 +477,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_19_120755) do t.string "operation", limit: 255, null: false t.uuid "recordable_id" t.string "recordable_type", limit: 255 - t.string "request_id" + t.string "request_id", limit: 255 t.uuid "user_id" t.index ["account_id"], name: "index_storage_entries_on_account_id" t.index ["blob_id"], name: "index_storage_entries_on_blob_id" diff --git a/saas/app/models/account/billing.rb b/saas/app/models/account/billing.rb index dada43721..ab4b75a06 100644 --- a/saas/app/models/account/billing.rb +++ b/saas/app/models/account/billing.rb @@ -4,6 +4,10 @@ module Account::Billing 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 diff --git a/saas/app/models/account/subscription.rb b/saas/app/models/account/subscription.rb index dd7833dfc..6a8bea0ef 100644 --- a/saas/app/models/account/subscription.rb +++ b/saas/app/models/account/subscription.rb @@ -22,4 +22,29 @@ class Account::Subscription < SaasRecord 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 end diff --git a/saas/test/models/account/billing_test.rb b/saas/test/models/account/billing_test.rb index 7283f47da..2fe923c1a 100644 --- a/saas/test/models/account/billing_test.rb +++ b/saas/test/models/account/billing_test.rb @@ -28,4 +28,44 @@ class Account::BillingTest < ActiveSupport::TestCase 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 end diff --git a/saas/test/models/account/subscription_test.rb b/saas/test/models/account/subscription_test.rb index 99612ecea..ff5b0837a 100644 --- a/saas/test/models/account/subscription_test.rb +++ b/saas/test/models/account/subscription_test.rb @@ -15,4 +15,106 @@ class Account::SubscriptionTest < ActiveSupport::TestCase 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 end diff --git a/test/controllers/account/cancellations_controller_test.rb b/test/controllers/account/cancellations_controller_test.rb new file mode 100644 index 000000000..2e35534a6 --- /dev/null +++ b/test/controllers/account/cancellations_controller_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class Account::CancellationsControllerTest < ActionDispatch::IntegrationTest + setup do + @account = accounts(:"37s") + @user = users(:jason) + sign_in_as @user + + if @account.respond_to?(:subscription) + Account.any_instance.stubs(:subscription).returns(nil) + end + end + + test "an owner can cancel the account" do + assert_difference -> { Account::Cancellation.count }, 1 do + assert_enqueued_emails 1 do + post account_cancellation_url + end + end + + assert_redirected_to session_menu_path(script_name: nil) + assert_equal "Account deleted", flash[:notice] + assert @account.reload.cancelled? + assert_equal @user, @account.cancellation.initiated_by + end + + test "non-owner cannot cancel the account" do + logout_and_sign_in_as users(:david) + + assert_no_difference -> { Account::Cancellation.count } do + post account_cancellation_url + end + + assert_response :forbidden + end + + test "cancelling an account while in single-tenant mode does nothing" do + with_multi_tenant_mode(false) do + assert_no_difference -> { Account::Cancellation.count } do + post account_cancellation_url + end + + assert_not @account.reload.cancelled? + end + end +end diff --git a/test/controllers/my/menus_controller_test.rb b/test/controllers/my/menus_controller_test.rb index bbd560764..189752fe4 100644 --- a/test/controllers/my/menus_controller_test.rb +++ b/test/controllers/my/menus_controller_test.rb @@ -78,4 +78,20 @@ class My::MenusControllerTest < ActionDispatch::IntegrationTest get my_menu_path, headers: { "If-None-Match" => etag } assert_response :not_modified end + + test "show excludes cancelled accounts" do + # Create another account for the same identity + another_account = Account.create!(external_account_id: 9999996, name: "Cancelled Account") + another_user = @user.identity.users.create!(account: another_account, name: "Kevin", role: "owner") + + # Cancel the other account + another_account.cancel(initiated_by: another_user) + + get my_menu_path + assert_response :success + + # The response should include active account but not cancelled one + assert_select "a[href*='#{@account.slug}']" + assert_select "a[href*='#{another_account.slug}']", count: 0 + end end diff --git a/test/controllers/sessions/menus_controller_test.rb b/test/controllers/sessions/menus_controller_test.rb index 4be2dd415..701de848d 100644 --- a/test/controllers/sessions/menus_controller_test.rb +++ b/test/controllers/sessions/menus_controller_test.rb @@ -47,4 +47,24 @@ class Sessions::MenusControllerTest < ActionDispatch::IntegrationTest assert_response :success end + + test "show excludes cancelled accounts" do + sign_in_as @identity + @identity.users.delete_all + account1 = Account.create!(external_account_id: 9999994, name: "Active Account") + account2 = Account.create!(external_account_id: 9999995, name: "Cancelled Account") + user1 = @identity.users.create!(account: account1, name: "Kevin", role: "owner") + user2 = @identity.users.create!(account: account2, name: "Kevin", role: "owner") + + # Cancel one account + account2.cancel(initiated_by: user2) + + untenanted do + get session_menu_url + end + + # Should redirect to the only active account + assert_response :redirect + assert_redirected_to root_url(script_name: account1.slug) + end end diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index c1c83ee51..5ce9567d4 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -9,3 +9,9 @@ initech: name: Initech LLC external_account_id: <%= ActiveRecord::FixtureSet.identify("initech") %> cards_count: 0 + +acme: + id: <%= ActiveRecord::FixtureSet.identify("acme", :uuid) %> + name: ACME + external_account_id: <%= ActiveRecord::FixtureSet.identify("acme") %> + cards_count: 0 diff --git a/test/jobs/account/incinerate_due_job_test.rb b/test/jobs/account/incinerate_due_job_test.rb new file mode 100644 index 000000000..6c53857a7 --- /dev/null +++ b/test/jobs/account/incinerate_due_job_test.rb @@ -0,0 +1,67 @@ +require "test_helper" + +class Account::IncinerateDueJobTest < ActiveJob::TestCase + setup do + @account = accounts(:"37s") + @user = users(:david) + + # Stub Stripe methods only in SaaS mode + if defined?(Stripe::Subscription) + Stripe::Subscription.stubs(:update).returns(true) + Stripe::Subscription.stubs(:cancel).returns(true) + end + end + + test "finds accounts up for incineration" do + @account.cancel(initiated_by: @user) + @account.cancellation.update!(created_at: 31.days.ago) + + Account.any_instance.expects(:incinerate).once + + Account::IncinerateDueJob.perform_now + end + + test "incinerates each old cancelled account" do + # Cancel the test account + @account.cancel(initiated_by: @user) + @account.cancellation.update!(created_at: 31.days.ago) + + # Just verify it gets incinerated + assert_difference -> { Account.count }, -1 do + Account::IncinerateDueJob.perform_now + end + end + + test "skips recent cancellations" do + @account.cancel(initiated_by: @user) + @account.cancellation.update!(created_at: 29.days.ago) + + assert_no_difference -> { Account.count } do + Account::IncinerateDueJob.perform_now + end + end + + test "handles cursor pagination correctly" do + # Cancel and age the account + @account.cancel(initiated_by: @user) + @account.cancellation.update!(created_at: 31.days.ago) + + # Verify it processes accounts in the scope + assert_difference -> { Account.count }, -1 do + Account::IncinerateDueJob.perform_now + end + end + + test "only incinerates accounts past grace period" do + # Account at 29 days (within grace period - should not be incinerated) + @account.cancel(initiated_by: @user) + @account.cancellation.update!(created_at: 29.days.ago) + + assert_no_difference -> { Account.count } do + Account::IncinerateDueJob.perform_now + end + + # The account should still exist + assert Account.exists?(@account.id) + end +end diff --git a/test/mailers/account_mailer_test.rb b/test/mailers/account_mailer_test.rb new file mode 100644 index 000000000..8d41ddf95 --- /dev/null +++ b/test/mailers/account_mailer_test.rb @@ -0,0 +1,57 @@ +require "test_helper" + +class AccountMailerTest < ActionMailer::TestCase + setup do + @account = accounts(:"37s") + @user = users(:david) + @account.cancel(initiated_by: @user) + @cancellation = @account.cancellation + end + + test "cancellation sends to initiating user" do + email = AccountMailer.cancellation(@cancellation) + + assert_emails 1 do + email.deliver_now + end + + assert_equal [ @user.identity.email_address ], email.to + end + + test "cancellation includes account name" do + email = AccountMailer.cancellation(@cancellation) + + assert_match @account.name, email.body.encoded + end + + test "cancellation includes support email" do + email = AccountMailer.cancellation(@cancellation) + + assert_match "support@fizzy.do", email.body.encoded + end + + test "cancellation has correct subject" do + email = AccountMailer.cancellation(@cancellation) + + assert_equal "Your Fizzy account was cancelled", email.subject + end + + test "cancellation has both HTML and text parts" do + email = AccountMailer.cancellation(@cancellation) + + assert email.html_part.present?, "Email should have HTML part" + assert email.text_part.present?, "Email should have text part" + end + + test "cancellation mentions account access is removed" do + email = AccountMailer.cancellation(@cancellation) + + assert_match /no one can access/i, email.body.encoded + end + + test "cancellation mentions data will be deleted" do + email = AccountMailer.cancellation(@cancellation) + + assert_match /deleted/i, email.body.encoded + end +end diff --git a/test/models/account/cancellable_test.rb b/test/models/account/cancellable_test.rb new file mode 100644 index 000000000..beb63a057 --- /dev/null +++ b/test/models/account/cancellable_test.rb @@ -0,0 +1,79 @@ +require "test_helper" + +class Account::CancellableTest < ActiveSupport::TestCase + setup do + @account = accounts(:"37s") + @user = users(:david) + end + + test "cancel" do + assert_difference -> { Account::Cancellation.count }, 1 do + assert_enqueued_with(job: ActionMailer::MailDeliveryJob) do + @account.cancel(initiated_by: @user) + end + end + + assert @account.cancelled? + assert_equal @user, @account.cancellation.initiated_by + end + + test "cancel does nothing if already cancelled" do + @account.cancel(initiated_by: @user) + + assert_no_changes -> { @account.cancellation.reload.created_at } do + @account.cancel(initiated_by: @user) + end + end + + test "cancel does nothing when in single-tenant mode" do + Account.stubs(:accepting_signups?).returns(false) + + assert_no_difference -> { Account::Cancellation.count } do + @account.cancel(initiated_by: @user) + end + + assert_not @account.cancelled? + end + + test "cancelled? returns true when cancellation exists" do + assert_not @account.cancelled? + + @account.cancel(initiated_by: @user) + + assert @account.cancelled? + end + + test "reactivate" do + @account.cancel(initiated_by: @user) + + assert @account.cancelled? + + @account.reactivate + @account.reload + + assert_not @account.cancelled? + assert_nil @account.cancellation + end + + test "reactivate does nothing if not cancelled" do + assert_not @account.cancelled? + + assert_nothing_raised do + @account.reactivate + end + + assert_not @account.cancelled? + end + + test "active scope excludes cancelled accounts" do + account2 = accounts(:initech) + + initial_active_count = Account.active.count + + @account.cancel(initiated_by: @user) + + assert_equal initial_active_count - 1, Account.active.count + assert_not_includes Account.active, @account + assert_includes Account.active, account2 + end +end diff --git a/test/models/account/cancellation_test.rb b/test/models/account/cancellation_test.rb new file mode 100644 index 000000000..ac88d46df --- /dev/null +++ b/test/models/account/cancellation_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Account::CancellationTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/account/incineratable_test.rb b/test/models/account/incineratable_test.rb new file mode 100644 index 000000000..021f751ba --- /dev/null +++ b/test/models/account/incineratable_test.rb @@ -0,0 +1,26 @@ +require "test_helper" + +class Account::IncineratableTest < ActiveSupport::TestCase + setup do + @account = accounts(:"37s") + @user = users(:david) + end + + test "incinerate destroys account" do + assert_difference -> { Account.count }, -1 do + @account.incinerate + end + + assert_not Account.exists?(@account.id) + end + + test "due_for_incineration finds old cancellations" do + @account.cancel(initiated_by: @user) + + @account.cancellation.update!(created_at: 31.days.ago) + assert_equal [ @account ], Account.due_for_incineration + + @account.cancellation.update!(created_at: 29.days.ago) + assert Account.due_for_incineration.empty? + end +end diff --git a/test/models/notification/bundle_test.rb b/test/models/notification/bundle_test.rb index 612087682..2a8b242af 100644 --- a/test/models/notification/bundle_test.rb +++ b/test/models/notification/bundle_test.rb @@ -1,6 +1,8 @@ require "test_helper" class Notification::BundleTest < ActiveSupport::TestCase + include ActionMailer::TestHelper + setup do @user = users(:david) @user.settings.bundle_email_every_few_hours! @@ -160,4 +162,19 @@ class Notification::BundleTest < ActiveSupport::TestCase assert_includes @user.notification_bundles.last.notifications, first_notification assert_includes @user.notification_bundles.last.notifications, second_notification end + + test "deliver does not send email for cancelled accounts" do + @user.notifications.create!(source: events(:logo_published), creator: @user) + bundle = @user.notification_bundles.pending.last + + @user.account.cancel(initiated_by: @user) + + assert_no_emails do + deliver_enqueued_emails do + bundle.deliver + end + end + + assert bundle.delivered?, "Bundle should be marked as delivered even if not sent" + end end diff --git a/test/models/notification_pusher_test.rb b/test/models/notification_pusher_test.rb new file mode 100644 index 000000000..21b9e202b --- /dev/null +++ b/test/models/notification_pusher_test.rb @@ -0,0 +1,32 @@ +require "test_helper" + +class NotificationPusherTest < ActiveSupport::TestCase + setup do + @user = users(:david) + @notification = @user.notifications.create!( + source: events(:logo_published), + creator: users(:jason) + ) + @pusher = NotificationPusher.new(@notification) + + @user.push_subscriptions.create!( + endpoint: "https://fcm.googleapis.com/fcm/send/test123", + p256dh_key: "test_key", + auth_key: "test_auth" + ) + end + + test "push does not send notifications for cancelled accounts" do + @user.account.cancel(initiated_by: @user) + + result = @pusher.push + + assert_nil result, "Should not push notifications for cancelled accounts" + end + + test "push sends notifications for active accounts with subscriptions" do + result = @pusher.push + + assert_not_nil result, "Should push notifications for active accounts with subscriptions" + end +end diff --git a/test/models/webhook/triggerable_test.rb b/test/models/webhook/triggerable_test.rb new file mode 100644 index 000000000..bd6d4b0a8 --- /dev/null +++ b/test/models/webhook/triggerable_test.rb @@ -0,0 +1,58 @@ +require "test_helper" + +class Webhook::TriggerableTest < ActiveSupport::TestCase + setup do + @account = accounts(:"37s") + @board = boards(:writebook) + @card = @board.cards.first + @webhook = @board.webhooks.create!( + name: "Test Webhook", + url: "https://example.com/webhook", + subscribed_actions: [ "card_published" ] + ) + # Create a test event + @event = @board.events.create!( + creator: users(:david), + eventable: @card, + action: "card_published" + ) + @user = users(:david) + end + + test "trigger creates delivery for active accounts" do + assert_difference -> { Webhook::Delivery.count }, 1 do + @webhook.trigger(@event) + end + + delivery = Webhook::Delivery.last + assert_equal @event, delivery.event + assert_equal @webhook, delivery.webhook + end + + test "trigger skips cancelled accounts" do + @account.cancel(initiated_by: @user) + + assert_no_difference -> { Webhook::Delivery.count } do + @webhook.trigger(@event) + end + end + + test "triggered_by scope finds webhooks for event" do + other_webhook = @board.webhooks.create!( + name: "Other Webhook", + url: "https://example.com/other", + subscribed_actions: [ "card_closed" ] + ) + + matching_webhooks = Webhook.triggered_by(@event) + + assert_includes matching_webhooks, @webhook + assert_not_includes matching_webhooks, other_webhook + end + + test "active scope only returns active webhooks" do + @webhook.update!(active: false) + + assert_not_includes Webhook.active, @webhook + end +end From ae78f25b06787923e402d859fb6be3ba5ed1d52d Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 12 Jan 2026 14:55:31 +0100 Subject: [PATCH 5/8] Fix incorrect recurring job config --- config/recurring.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/recurring.yml b/config/recurring.yml index b044a4978..bb527e891 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -28,7 +28,7 @@ production: &production command: "Account::Export.cleanup" schedule: every hour at minute 20 incineration: - job: Account::IncinerateDueJob + class_name: Account::IncinerateDueJob schedule: every 8 hours at minute 16 <% if Fizzy.saas? %> From 74c7ed30811939829b0a6ac7194409f96a1fb19c Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 12 Jan 2026 14:56:51 +0100 Subject: [PATCH 6/8] Explicitly define incineration priority --- config/queue.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/queue.yml b/config/queue.yml index 722da40b1..50a127210 100644 --- a/config/queue.yml +++ b/config/queue.yml @@ -3,7 +3,7 @@ default: &default - polling_interval: 1 batch_size: 500 workers: - - queues: [ "default", "solid_queue_recurring", "backend", "webhooks", "*" ] + - queues: [ "default", "solid_queue_recurring", "backend", "webhooks", "incineration", "*" ] threads: 3 processes: <%= Integer(ENV.fetch("JOB_CONCURRENCY") { Concurrent.physical_processor_count }) %> polling_interval: 0.1 From 5f11ee154fb99621e9ccaac450aaf7f6b4b585ff Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 12 Jan 2026 15:05:00 +0100 Subject: [PATCH 7/8] Fix attribute name --- config/recurring.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/recurring.yml b/config/recurring.yml index bb527e891..8aba572ca 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -28,7 +28,7 @@ production: &production command: "Account::Export.cleanup" schedule: every hour at minute 20 incineration: - class_name: Account::IncinerateDueJob + class: "Account::IncinerateDueJob" schedule: every 8 hours at minute 16 <% if Fizzy.saas? %> From 7c7b4eb6218d6abaf7e98082707e77d523e3d838 Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Mon, 12 Jan 2026 15:13:46 +0100 Subject: [PATCH 8/8] Remove explicit queue definition --- config/queue.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/queue.yml b/config/queue.yml index 50a127210..722da40b1 100644 --- a/config/queue.yml +++ b/config/queue.yml @@ -3,7 +3,7 @@ default: &default - polling_interval: 1 batch_size: 500 workers: - - queues: [ "default", "solid_queue_recurring", "backend", "webhooks", "incineration", "*" ] + - queues: [ "default", "solid_queue_recurring", "backend", "webhooks", "*" ] threads: 3 processes: <%= Integer(ENV.fetch("JOB_CONCURRENCY") { Concurrent.physical_processor_count }) %> polling_interval: 0.1