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? %>
+ Delete your Fizzy accountCancel account
+
Your Fizzy account <%= @account.name %> was cancelled.
+ +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