Skip to content

Allow users to self-delete their accounts in multiuser mode#70

Merged
philipithomas merged 2 commits into
mainfrom
account-self-deletion
Jun 10, 2026
Merged

Allow users to self-delete their accounts in multiuser mode#70
philipithomas merged 2 commits into
mainfrom
account-self-deletion

Conversation

@philipithomas

@philipithomas philipithomas commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds account self-deletion to the page settings in multiuser mode, with type-to-confirm friction, synchronous billing cancellation, and asynchronous full data cleanup.

User flow

  • Settings page danger zone (pages/edit): a "Delete account" section shown only in multiuser mode and only to the account owner (not to admins viewing another account's settings).
  • Confirmation modal (new delete-account Stimulus controller): the user must type their postcard address (e.g. username.postcard.page) before the delete button enables. Closes on Escape or backdrop click. The confirmation is re-verified server-side (case/whitespace-insensitive).

Billing cancellation (synchronous, fail-loud)

Pay's cancel-on-destroy callback is unsafe for this: it swallows API errors (logs and continues, so a failed Stripe call would delete local records while the subscription keeps billing) and its active? check skips past_due subscriptions (a customer in dunning would keep getting retried after deletion). Instead, AccountController#destroy deletes the Stripe customer itself — Stripe immediately cancels all of a deleted customer's subscriptions regardless of status, and scrubs their details from Stripe. If the API call fails, deletion aborts with a contact-support message.

Deletion (asynchronous)

Established accounts can have hundreds of thousands of analytics, subscriber, and email rows; the previous cascade destroyed them row by row (visits → events individually instantiated, audited subscriber destroys) — far too slow for a request. Now:

  1. The controller locks the account (lock_access!) — sign-in is blocked and the public page 403s immediately — signs the user out, and enqueues DestroyAccountJob.
  2. The job bulk-deletes in batches: the account's visits/events, visitor page-view events (matched via the GIN jsonb_path_ops index on ahoy_events.properties — these were previously orphaned), email messages, and subscriber records; destroys Pay::Customer records (Pay never cleans these up on owner destroy); runs account.destroy! for the rest (posts, domains, imports, attachments) with normal callbacks; and finally purges audit snapshots, which contain account PII.

Also

  • Closed a bypass: Devise's default DELETE /accounts destroy (no typed confirmation, no billing cleanup) now redirects to this flow in multiuser mode; solo mode unchanged.
  • Reworded the cancel-account copy to "Done with Postcard?".

Tests

test/controllers/account_controller_test.rb (8 tests): lock + enqueue + sign-out on success, full data cascade when the job runs, case/whitespace-insensitive confirmation, wrong-confirmation rejection, solo-mode block, other-account block, auth requirement, Devise destroy redirect. test/jobs/destroy_account_job_test.rb: bulk deletion of visits, own and visitor analytics events, subscribers, email messages, and audits, while shared EmailAddress records survive.

rails test, rails zeitwerk:check, and brakeman all pass.

🤖 Generated with Claude Code

philipithomas and others added 2 commits June 10, 2026 11:13
Adds a "Delete account" danger zone to the bottom of the page settings
(Edit your page) in multiuser mode. Deletion opens a confirmation modal
that requires typing the account's postcard address before the submit
button enables, and the confirmation is re-verified server-side.

AccountController#destroy cancels billing first by destroying the
account's Pay::Customer records (Pay::Subscription's before_destroy
cancels any active subscription at Stripe), then destroys the account,
cascading to posts, subscribers, domains, imports, messages, visits,
and feedbacks via dependent: :destroy. If billing cancellation fails,
deletion aborts and the user is asked to contact support.

The default Devise registration destroy is redirected to this flow in
multiuser mode so deletion can't bypass confirmation and billing
cleanup. Solo mode behavior is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… job

Billing: Pay's cancel-on-destroy callback swallows API errors and skips
past_due subscriptions, either of which could leave a Stripe subscription
billing forever after the local records were deleted. Instead, deletion
now synchronously deletes the Stripe customer itself, which immediately
cancels all of its subscriptions regardless of status and scrubs the
customer's details from Stripe. Failures abort deletion and surface to
the user.

Deletion: established accounts can have hundreds of thousands of
analytics, subscriber, and email rows, all previously destroyed row by
row within the request. The controller now locks the account (blocking
sign-in and taking the public page offline immediately) and enqueues
DestroyAccountJob, which bulk-deletes the large tables in batches,
purges visitor page-view events via the GIN index on
ahoy_events.properties (previously orphaned), removes Pay records that
the owner destroy never cleaned up, and deletes audit snapshots
containing account PII.

Also rewords the cancel-account copy to "Done with Postcard?".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@philipithomas philipithomas merged commit 0b1b985 into main Jun 10, 2026
5 checks passed
@philipithomas philipithomas deleted the account-self-deletion branch June 10, 2026 21:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant