Account deletion
GDPR account deletion runs local erasure first, via
UsersService.ghostDelete - the legally binding half, which must never
be blocked by a Stripe outage or a bad key. Only after it succeeds does
UserDeletionWorker call BillingService.deleteCustomerData, a no-op
when billing is dormant or the user has no Stripe customer yet. Otherwise
it deletes the customer stored in billing_customers, and the one on the
user's billing_subscriptions row (at most one, per the UNIQUE
constraint on user_id) when that names a different customer, as it can
for a user who paid under two customers before billing_customers
existed. Deleting a customer cancels its live subscriptions. A customer
Stripe no longer has counts as deleted. A non-missing Stripe failure
throws, so the queue retries the whole item: ghostDelete again
(idempotent and cheap) then deleteCustomerData again, since
ghostDelete never touches billing_subscriptions. Which tables the
local half reaches, and which it deliberately does not, is in
Architecture.
That retry is capped at five attempts, roughly 43 minutes (30s, 2min, 8min, 32min of backoff). After that the row is abandoned and nothing retries it. Because deleting the customer is also what cancels the subscription, an abandoned row means a deleted user may keep being billed every month, and their email and name are still on the Stripe Customer. An outage longer than that window, or a key rotated and not noticed, abandons every deletion in flight.
List them with admin_get_abandoned_deletions on the admin MCP server,
or directly:
SELECT id, user_id, status, attempts, last_error, updated_at
FROM user_deletion_queue
WHERE attempts >= 5; -- USER_DELETION_MAX_ATTEMPTS in users.service.ts
last_error is prefixed with the half that failed, and the two leave very
different things behind:
stripe cleanup: …- local erasure succeeded. An orphaned Stripe customer remains, and it may still be billing a deleted user.local erasure: …- the worse one. The user's own record was never anonymized, so their email, name and sessions are all still there. Erase that user by hand first; this is the legally binding half.status = 'processing'with a nulllast_error- the run died mid-item and recorded neither. Assume nothing and check the user.
Once the underlying cause is fixed (usually a restored API key), requeue them:
UPDATE user_deletion_queue
SET attempts = 0, status = 'pending', next_retry_at = NULL, last_error = NULL
WHERE attempts >= 5;
Cancel the subscription and delete the customer by hand in Stripe for any row you do not requeue. See Architecture for the worker mechanics.
The local row itself isn't touched by deletion directly. Where the
customer delete does happen, Stripe then emits
customer.subscription.deleted (already a handled event), and when that
delivery arrives the handler drops the row to plan=free, status=canceled through the normal webhook path. Until it arrives, or if
it never does, the row reads as it did before.
stripe_customer_id, here and in billing_customers, is then left
pointing at a deleted Stripe customer - a harmless dead pointer for a
deleted account: the only code that re-reads it (createCheckoutSession)
is unreachable for a ghosted user, who can no longer sign in.
Deletion erases no billing table, and where it reaches Stripe it adds
to one. Both effects below are downstream of the customer delete, so
they only happen when billing is on and the user had a Stripe customer;
otherwise deleteCustomerData returns early and neither table moves. When it does run, billing_subscriptions is dropped
to free/canceled through the webhook path rather than cleared, and
billing_webhook_events gains a row for that cancellation carrying the
deleted user's user_id and a free-text detail, which the hourly
worker sweeps 30 days later.
billing_disputed_customers is the one that outlives even those
sweeps. It is keyed on the Stripe customer id, has no foreign key to
users, and is never swept, so a chargeback record outlives the account
it came from. That is deliberate: the reason to keep it is precisely that
the person may come back. Whether a Stripe customer id counts as personal
data you may retain on a legitimate-interest basis after an erasure
request is your call and your jurisdiction's, not the template's. If you
decide it does not, delete it alongside the account:
DELETE FROM billing_disputed_customers
WHERE stripe_customer_id = '<cus_...>';