Add self-service account deletion (#2246)
* Add self-service account deletion * Disable access to cancelled accounts & implement Stripe interactions * Add tests * Fix failing tests * Remove cancelled accounts from lists * Fix incorrect redirect * Use _path instead of _url for consistency * Don't track how far the inicieration job got We still want the step tracking so that the job can be interrupted. But, since the scope is idempotent, we don't need to track how far it got. * Specify the exact time when the data will be deleted * Fix crash due to unadvancable cursor * Rename up_for_incineration to due_for_incineration * Regenrate the schema * Fix incorrect path check * Migrate the SQLite schema * Only show the cancel button on cancellable accounts * Check that a subscirption method exists before calling it * Ignore job failures due to missing records when an account gets deleted * Skip sending notifications on cancelled accounts * Use collbacks to integrate * Add a blank line between queue_as and discard_on * Break checks into methods * Inline methods * Rename Account::IncinerateJob * Run migrations * Migrate SQLite
This commit is contained in:
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -1,4 +1,6 @@
|
||||
class Board::CleanInaccessibleDataJob < ApplicationJob
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(user, board)
|
||||
board.clean_inaccessible_data_for(user)
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Card::ActivitySpike::DetectionJob < ApplicationJob
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(card)
|
||||
card.detect_activity_spikes
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Card::RemoveInaccessibleNotificationsJob < ApplicationJob
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(card)
|
||||
card.remove_inaccessible_notifications
|
||||
end
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class ExportAccountDataJob < ApplicationJob
|
||||
queue_as :backend
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(export)
|
||||
export.build
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Mention::CreateJob < ApplicationJob
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(record, mentioner:)
|
||||
record.create_mentions(mentioner:)
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class Notification::Bundle::DeliverAllJob < ApplicationJob
|
||||
queue_as :backend
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform
|
||||
Notification::Bundle.deliver_all
|
||||
end
|
||||
|
||||
@@ -3,6 +3,8 @@ class Notification::Bundle::DeliverJob < ApplicationJob
|
||||
|
||||
queue_as :backend
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(bundle)
|
||||
bundle.deliver
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class NotifyRecipientsJob < ApplicationJob
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(notifiable)
|
||||
notifiable.notify_recipients
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class PushNotificationJob < ApplicationJob
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(notification)
|
||||
NotificationPusher.new(notification).push
|
||||
end
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class Webhook::DeliveryJob < ApplicationJob
|
||||
queue_as :webhooks
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(delivery)
|
||||
delivery.deliver
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
class Account::Cancellation < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :initiated_by, class_name: "User"
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,6 @@ module Webhook::Triggerable
|
||||
end
|
||||
|
||||
def trigger(event)
|
||||
deliveries.create!(event: event)
|
||||
deliveries.create!(event: event) unless account.cancelled?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<% if Current.account.cancellable? && Current.user.owner? %>
|
||||
<header class="margin-block-start-double">
|
||||
<h2 class="divider txt-large txt-negative">Cancel account</h2>
|
||||
<p class="margin-none-block">Delete your Fizzy account</p>
|
||||
</header>
|
||||
|
||||
<div data-controller="dialog" data-dialog-modal-value="true">
|
||||
<button type="button" class="btn btn--negative" data-action="dialog#open">Delete account</button>
|
||||
|
||||
<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog">
|
||||
<h2 class="margin-none txt-large txt-negative">Delete your account?</h2>
|
||||
<ul class="txt-align-start">
|
||||
<li>All users, including you, will lose access</li>
|
||||
<% if Current.account.try(:active_subscription) %>
|
||||
<li>Your subscription will be canceled</li>
|
||||
<% end %>
|
||||
<li>After 30 days your data will be permanently deleted</li>
|
||||
</ul>
|
||||
|
||||
<div class="flex gap justify-center">
|
||||
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
|
||||
<%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -18,6 +18,7 @@
|
||||
<div class="settings__panel settings__panel--entropy panel shadow center">
|
||||
<%= render "account/settings/entropy", account: @account %>
|
||||
<%= render "account/settings/export" %>
|
||||
<%= render "account/settings/cancellation" %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<p>Your Fizzy account <strong><%= @account.name %></strong> was cancelled.</p>
|
||||
|
||||
<h2>What happens now?</h2>
|
||||
|
||||
<ul>
|
||||
<li>No one can access the account anymore</li>
|
||||
<% if @account.try(:subscription) %>
|
||||
<li>We won't charge you anymore</li>
|
||||
<% end %>
|
||||
<li>Everything in the account will be deleted in <%= distance_of_time_in_words_to_now(Account::Incineratable::INCINERATION_GRACE_PERIOD.from_now) %></li>
|
||||
</ul>
|
||||
|
||||
<i>Changed your mind?</i>
|
||||
<p>If you want to cancel this deletion and restore your account, send us an email to <a href="mailto:support@fizzy.do">support@fizzy.do</a> as soon as possible.</p>
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Generated
+9
-1
@@ -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"
|
||||
|
||||
+10
-2
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+6
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class Account::CancellationTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user