B2C Account Deletion & Pause¶
Self-service B2C users (learner or trainer, role == b2c_user) get an Account tab in Settings with two actions: Pause (reversible deactivate) and Delete (DPDP-grade erasure). Before this, the only option was admin-driven deactivation, which kept all PII in the database forever.
How it works¶
Delete picks a reason, confirms, anonymizes PII immediately, keeps a 30-day recovery grace window, then a background job hard-purges child data. The chatbot's cross-cloud long-term memory is erased via a signed S2S call.
State model:
active ──pause──▶ paused ──login/reactivate──▶ active
│ │
│ └──(stays until user returns; reversible)
│
└──delete(reason)──▶ pending_deletion ──+30d──▶ purged
│
└──reactivate within 30d──▶ active (PII already scrubbed*)
Invariants:
- paused:
is_active=false,paused_atset,deleted_atnull. PII intact. Login reactivates. - pending_deletion:
is_active=false,deleted_atset. PII already anonymized. Login is blocked with a "restore until" path. Reason already captured. - purged: user row + child PII hard-deleted by background job. Financial records retained with anonymized FK. Deletion-reason feedback row retained (no PII).
* Recovery within grace restores access and account structure, but anonymized identity fields are not un-scrubbed. Recovery means "your account works again," not "your old email/name is back." The delete-confirm copy says so.
Flow rules:
- Delete: modal asks "Why are you leaving?" (single-select reason + required free text for Other), then a confirm step with warning copy + password entry (or type-to-confirm for OAuth-only accounts with no password) + a destructive confirm. On confirm the backend stores the reason, anonymizes PII, sets
deleted_at, fires the chatbot erasure call, then the client logs out to a "scheduled for deletion, restore until" screen. - Pause: confirm modal, backend sets
paused_at+is_active=false, client logs out. Next login detects paused and reactivates. - The Account tab is gated on
role === b2c_user; backend endpoints reject non-B2C with 403. - Financial/payment/invoice records are retained (user link anonymized) for Indian tax/accounting retention, never deleted.
- The chatbot erasure call (
POST /v1/memory/erase, signed with the existing S2S HMAC) is best-effort and behindCHATBOT_ERASURE_ENABLED. Failures are logged and retried, never block local deletion.
Deletion reasons (single-select)¶
reason_code |
Label (en) |
|---|---|
not_using |
I'm not using Kwilo enough |
found_alternative |
I found a better alternative |
too_expensive |
It's too expensive |
missing_features |
Missing features I need |
privacy_concerns |
I have privacy or data concerns |
created_by_mistake |
I created this account by mistake / duplicate account |
temporary_account |
Just a temporary account, no longer needed |
other |
Other (free text, required) |
Reasons are stored in account_deletion_feedback (reason_code, reason_text, role, was_b2c, plan_at_deletion, created_at) with an anonymized/nullable user link so the row survives the purge.
API endpoints¶
| Method | Endpoint | Body | Purpose |
|---|---|---|---|
| POST | /api/v1/users/me/account/pause |
none | Set paused_at, is_active=false |
| POST | /api/v1/users/me/account/reactivate |
none | Clear paused_at, is_active=true (also via login) |
| POST | /api/v1/users/me/account/delete |
{ reason_code, reason_text?, password? } |
Store reason, anonymize PII, set deleted_at, fire chatbot erasure |
All three require CurrentUser and reject non-b2c_user with 403. delete requires password when password_hash is set; otherwise type-to-confirm is enforced client-side. Reason other requires non-empty reason_text.
Where it lives¶
- Route:
/settings(Account tab). - Backend:
apps/backend/src/services/account_lifecycle.py,apps/backend/src/models/account_deletion_feedback.py,/me/account/*routes inapps/backend/src/api/v1/users.py, login handling inapps/backend/src/api/v1/auth.py. - Web:
apps/web/src/pages/shared/SettingsPage/components/AccountTab.tsx,apps/web/src/constants/accountDeletion.ts.