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?

+ + +
+ + <%= 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 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?

+ + + +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": "

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