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? %>
+
+
+
+
+
+
+
+<% end %>
diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb
index 799045c17..0e6fcf1c2 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..8aba572ca 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:
+ class: "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/docs/API.md b/docs/API.md
index ee1820f28..bd4488127 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -574,6 +574,7 @@ __Response:__
"description_html": "",
"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.
@@ -683,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.
@@ -767,6 +787,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.
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