Skip to Content
The production modelAccount lifecycle and erasure

Account lifecycle and erasure

Applies to The Fabrica v0.1.2. Documentation source: 1ee1bc9cb661, including post-release documentation corrections.

DELETE /api/v1/account/me doesn’t actually delete the row. It sets User.deleted_at = now() and a daily Celery beat task runs the hard-delete after a 30-day grace period.

What competitors ship

@router.delete("/me") async def delete_account(user, db): await db.delete(user) # cascades, scorched earth await db.commit() return {"message": "Account deleted."}

GDPR Art. 17 says “right to erasure”, so the row goes. Job done.

Where that breaks

  1. The “oops” account: user clicks delete from the wrong account (signed into two browsers), or clicks delete then changes their mind 20 minutes later. No undo. The data is gone. Cascades take subscriptions, audit history, credit ledger with them.
  2. The chargeback dispute: user deletes their account, then disputes their last subscription payment 30 days later. You have no record of the user, can’t show Paddle the audit trail, lose the dispute. Paddle keeps records itself (Merchant of Record), but your own audit log is gone.
  3. The compromised account: attacker gets temporary access to a user’s session, deletes their account out of malice. User has zero recourse. With soft-delete + grace, the user signs back in, sees a “your account is scheduled for deletion on …” banner, and clicks cancel.

What we ship

@router.delete("/me", response_model=AccountDeleteResponse) async def delete_my_account(user, db, request): now = datetime.now(UTC) user.deleted_at = now await log_action_async(db, ...) return AccountDeleteResponse( deleted_at=now, hard_delete_after=now + timedelta(days=GRACE_PERIOD_DAYS), )

The matching Celery beat task is workers/account_deletion.py — it runs daily, finds rows where deleted_at < now() - GRACE_PERIOD_DAYS, and DELETEs them. ON DELETE CASCADE in the subscription / audit / credit-transaction FKs handles the rest.

Until the worker runs, the user can:

  1. Sign back in via Clerk.
  2. Hit the (admin / buyer-extension) “cancel deletion” endpoint — nulls deleted_at, the row stays.

GDPR-wise this is still compliant: Art. 17 allows a “without undue delay” window, and 30 days is well within that. We document the window in the privacy policy + the deletion-scheduled email.

Buyer override

Some buyers DO want hard-delete on the API path (regulated industries where “soft delete” doesn’t satisfy auditors). Override:

# src/workers/account_deletion.py GRACE_PERIOD_DAYS = 0

The daily worker now hard-deletes anything with deleted_at set on the next run. The API still soft-deletes first — the only way to get truly-synchronous hard delete is to await the worker from the endpoint, which is a bad pattern (DELETE blocks for as long as cascades take, which on a fat account can be seconds).

Receipts

  • Endpoint: backend/src/api/routers/account.py:delete_my_account
  • Worker: backend/src/workers/account_deletion.py

Provenance. Documentation source: 1ee1bc9cb6619c19f57766731e7884b37f515dc9 (main).

Extracted from docs/recipes/soft-delete-vs-hard-delete.md.

That commit is later than the release these pages describe: it carries documentation corrections made after thefabrica-v0.1.2 went out. Nothing that changes how the product behaves landed between the two, which is what makes naming that version honest.

Last updated on