Account Deletion Live
In-app account deletion for KA26 consumers. Required by:
- Google Play Store policy (Aug 2024) — every app supporting account creation must offer in-app deletion
- Apple App Store guideline 5.1.1(v) — same
- India's Digital Personal Data Protection Act, 2023 — Right to Erasure under Section 12
Without this, every Play Store submission would auto-reject.
Shipped 2026-05-02. Live-verified end-to-end against prod the same day.
1. Architecture — anonymise + soft-delete (NOT hard-delete)
The most important decision: we do not hard-delete the Consumer row. We anonymise it instead. Why:
- Indian tax law requires 7 years of order/payment records. Hard-delete would orphan those rows or violate retention.
- UGC the user posted (reels, request messages, bid messages) is preserved, attributed to "Deleted user" via the anonymised parent row — protects the integrity of public conversations.
- Hard-delete cascading 30+ FK relations is much higher risk than a row-level update.
What gets removed (immediately):
- All PII fields on the Consumer row (
name → "Deleted user",email + phone → tomb-mangled strings,passwordHash → null,googleId → null,photoUrl → null,bio → null,latitude/longitude → null,alias → null,referralCode → null,qrCode → null) - Push tokens (Expo + Web Push)
- Notification preferences
- Saved addresses
tokenInvalidatedAt = now()— force-logs-out every devicestatus = "deleted",isActive = false
What is preserved (with PII stripped via the parent row):
- Order records (StoreOrder + Order) — 7-year tax retention
- Payments — same
- Reels the user uploaded — attributed to "Deleted user"
- Request messages + bid messages — same
- Wallet balance is cleared but the WalletTransaction history is preserved
2. Tomb-mangling — why same email can re-register
Both email and phone have unique constraints on the Consumer table. If we just nulled them, you'd hit unique-violations on re-registration. Instead, we mangle them to collision-free tomb strings:
email → "deleted-{id}-{base36-nonce}@deleted.ka26.local"
phone → "__deleted_{id}_{base36-nonce}"
This frees up the original email + phone for re-use. The user can re-register tomorrow with the same email and gets a brand-new Consumer ID.
This is the standard "soft-delete with collision-free tomb" pattern — used by Stripe, GitHub, and most production systems. It satisfies "the right to erasure" (no PII survives on the row) while keeping referential integrity.
3. API surface
GET /api/consumer/account
Returns a deletion summary so the in-app confirmation screen can show the user what they'll lose:
{
"summary": {
"reels": 12,
"orders": 7,
"requests": 3,
"subscriptions": 2,
"walletBalancePaise": 2500
},
"notes": [
"Order records are retained for 7 years per Indian tax law, with your personal details removed.",
"Reels and request messages you posted will be attributed to a deleted user.",
"Wallet balance is non-refundable.",
"You can re-register with the same email or phone after deletion."
]
}
DELETE /api/consumer/account
Body MUST include { "confirm": "DELETE" }. Without it, returns 400 — prevents accidental deletes if a CSRF or buggy UI ever issues the call without consent.
Response on success:
{
"ok": true,
"message": "Your account has been deleted. Order records are retained for 7 years per Indian tax law; all personal information has been removed."
}
Idempotent: deleting an already-deleted account returns 200 with a no-op message.
4. Mobile UX — mobile/app/delete-account.tsx
Two-step destructive confirmation:
- Step 1 — review screen showing the deletion summary + what gets retained vs removed. "I understand — continue" button (red).
- Step 2 — type
DELETEexactly to enable the final submit button. Final button is red, says "Permanently delete my account."
Discoverability: linked from mobile/app/settings.tsx as a small grey "Delete Account" link below the red Logout button (deliberately understated — destructive enough to find when wanted, subtle enough not to be tapped accidentally).
Full i18n in 6 locales (en/kn/hi proper translations + sa/ta/te English-fallback). 26 keys under delete_account.* namespace.
Footer: "Need help? Email privacy@ka-26.com — our Grievance Officer responds within 15 days under the Digital Personal Data Protection Act, 2023." (Grievance Officer is a mandatory DPDP requirement.)
5. Admin surface — /admin/consumers
The admin dashboard at /admin/consumers:
- Has a 5th stat card: Deleted with count
- Filter chip lets you view only deleted users
- Status column shows ⚫ Deleted
- The deleted Consumer row remains visible to admins (with anonymised values) so audit trails are intact
6. Live verification (2026-05-02)
Real e2e run against production:
# 1. Create test consumer
POST /api/auth/consumer-register → consumer id=489
# 2. Mint JWT directly (skip OTP — we own JWT_SECRET)
# 3. GET /api/consumer/account
{ summary: { reels:0, orders:0, ...}, notes:[...] }
# 4. DELETE /api/consumer/account { confirm: "DELETE" }
{ "ok": true, "message": "Your account has been deleted. ..." }
# 5. Verify DB state
{
id: 489, name: "Deleted user",
email: "deleted-489-mop82a81@deleted.ka26.local",
phone: "__deleted_489_mop82a81",
status: "deleted", isActive: false,
tokenInvalidatedAt: <set>,
photoUrl: null, latitude: null, longitude: null
}
{ pushTokens: 0, addresses: 0, notifications: 0 }
# 6. Re-register with same email → succeeded as id=490
Every assertion passed. ✅
7. Tests
tests/consumer-account-deletion.test.ts — 15 regression-pinning assertions. Pinned aspects:
- Endpoint exists +
DELETEmethod exported GETsummary endpoint exists + returns reel/order counts- Auth required (
resolveConsumerId(req)) - Explicit
confirm: "DELETE"required (no accidental triggers) - Mangle pattern:
name → "Deleted user",passwordHash → null,latitude/longitude → null,googleId → null,email + phone → @deleted.ka26.local tokenInvalidatedAt = new Date()setstatus = "deleted",isActive = false- Cascades: pushToken / notification / consumerAddress deleteMany called
- Auth cookie cleared on response
- Idempotent on already-deleted accounts
- Mobile screen exists at
mobile/app/delete-account.tsx - Settings links to it
- Mobile screen requires "DELETE" type-confirmation before enabling submit
- Calls API with
confirm: "DELETE"payload - Logs out + navigates away on success
8. Privacy policy reference
This flow is documented in the public privacy policy at https://ka-26.com/privacy Section 7 ("Your rights and choices") and Section 14 ("Grievance Officer").
9. Known follow-ups
- JWT iat-based force-logout corner case: the
tokenInvalidatedAtcheck works for normally-issued tokens but has a subtle interaction withjsonwebtoken'snoTimestampoption. Not a security risk in practice (the deleted account has no recoverable login path), but worth tightening when next touchingsrc/lib/consumer-auth.ts. - Seller account deletion is NOT yet implemented — sellers contact admin for account closure. Worth adding parallel flow once seller acquisition picks up.
- Doctor account deletion same — sellers can use the existing admin disable flow for now.
10. Related
- Authentication — the JWT system this hooks into
- Admin Panel — where deleted consumers surface
- Privacy Policy — the public legal commitment this implements
- CHANGELOG entry — full context