Skip to main content

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 device
  • status = "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:

  1. Step 1 — review screen showing the deletion summary + what gets retained vs removed. "I understand — continue" button (red).
  2. Step 2 — type DELETE exactly 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 + DELETE method exported
  • GET summary 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() set
  • status = "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 tokenInvalidatedAt check works for normally-issued tokens but has a subtle interaction with jsonwebtoken's noTimestamp option. Not a security risk in practice (the deleted account has no recoverable login path), but worth tightening when next touching src/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.