In a two-sided marketplace mobile app, a business posts paid campaigns, creators apply, and the business funds each accepted creator with a card payment that later becomes a payout once the work is done. Creators and businesses both see payment history inside the app while Stripe keeps the authoritative PaymentIntents and transfers. When someone taps Delete my account in settings, they expect their profile and personal data to go away. Finance and support expect the money trail for the other party to stay intact.
Who gets stuck: businesses reconciling in-app history against Stripe after a creator leaves; finance when ledger rows vanish; creators who delete by accident; anyone who reads "30 days to change your mind" but gets an instant wipe. What broke: delete was a hard DELETE on the user row. The payments table pointed at users with a non-nullable foreign key and ON DELETE CASCADE, so one creator's exit silently removed payment rows the business still needed. Stripe still had the charges. The app did not. Deletion was immediate even though copy promised a grace period. Frozen accounts could still show up in discover, and a refresh token could keep a deleted session alive.
Hard delete vs soft delete on a shared ledger
A hard delete removes the database row. A soft delete keeps the row but marks it inactive, often with a timestamp. Anonymization scrubs personally identifiable fields (name, email, auth ids, address, photo links) while keeping the primary key so related history can still point at "someone who used to be user 4821". A grace period is the window where logging back in cancels the deletion request. A ledger row here is one stored payment: amount, status, Stripe id, and who the creator was at the time. The counterparty is the other side of the marketplace (the business when the creator deletes). Right to erasure under GDPR-style privacy law is often read as "delete everything", but in practice you usually must keep certain financial records for tax and audit; the workable pattern is remove personal data, not necessarily destroy every row that mentions a person (this is not legal advice).
The trap: putting CASCADE (or a non-nullable FK you cannot clear) on a table both parties share turns "delete my profile" into "delete the money trail". Only one person pressed delete. Both parties owned that history.
Why CASCADE feels obvious and fails quietly
ORM defaults and early schemas treat payments as "belonging to" the creator because the creator earned the payout. Tests often create a fresh user with no payments, delete them, and green. Nothing throws. The ledger just shrinks. Reconciliation notices weeks later.
Try it below: pick Hard delete + CASCADE, press Delete account, and watch the business payment history empty while Rows lost jumps to three. Switch to Soft delete + anonymize, delete again, drag Days since request past 30, and see all three rows survive with Deleted User in the creator column. Turn on Pending payment and soft delete refuses with a clear message instead of half-freezing the account.
Simulation
Account deletion vs payment ledger
Mock creator profile, discover search, and business payment history. Stripe PaymentIntent ids are fake; amounts are illustrative.
Creator account
Alex Creator
alex@example.test
Discover search
- Alex Creator · Campaigns · Photography
- Other creators omitted in this toy list.
Business payment history
| Amount | Status | Stripe id | Creator |
|---|---|---|---|
| €420.00 | released | pi_3Nx8k2Example001 | Alex Creator |
| €180.00 | released | pi_3Nx8k2Example002 | Alex Creator |
| €95.00 | released | pi_3Nx8k2Example003 | Alex Creator |
Payment rows kept
3
Rows the business still needs
Rows lost
0
No cascade loss
Account phase
active
Pick a strategy, toggle pending payment if you want, then press Delete account or Log in.
Drag past 30 after a soft delete to simulate purge.
Request, grace, purge, and SET NULL
The fix I shipped in four beats on a NestJS API with TypeORM on PostgreSQL and Firebase Auth for identities.
Request: set deletionRequestedAt, freeze the account (private, off discover/search, push tokens cleared). Refuse deletion with a readable error if campaigns are live or payments pending.
Grace: logging in within 30 days clears the request. Token refresh on a purged or deleted auth user is refused.
Purge: after grace, a scheduler (or the next login attempt) anonymizes the user row in place: scrub PII, set deletedAt, keep the id, delete the Firebase user.
Backstop: payment foreign keys become nullable with ON DELETE SET NULL. Application code treats a missing user on a payment as the anonymized label, not a missing row.
Identity and history split: the person is gone from a GDPR perspective, the record keeps amount, status, and Stripe id, and the business counterparty still sees the line item.
Before:
@ManyToOne(() => User, { nullable: false, onDelete: 'CASCADE' })
user!: User
async deleteAccount(userId: string) {
await this.users.delete(userId) // payment rows vanish
}After:
@ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' })
user?: User | null
async requestDeletion(userId: string) {
await this.guardNoOpenObligations(userId)
await this.users.update(userId, {
deletionRequestedAt: new Date(),
discoverable: false,
pushTokens: [],
})
}
async purgeExpired(cutoff: Date) {
const stale = await this.users.findPendingDeletionBefore(cutoff)
for (const user of stale) {
await this.auth.deleteUser(user.firebaseUid)
user.email = null
user.displayName = ANONYMOUS_LABEL
user.deletedAt = new Date()
await this.users.save(user)
}
}RESTRICT instead of CASCADE at least fails loudly, but it is still user-hostile: the creator cannot delete and sees a database error. Soft delete plus anonymize matches app-store delete-account expectations without gaslighting finance.
When not to bother with this shape
If data has no counterparty or retention duty (drafts, prefs, private notes), CASCADE or a hard delete is simpler and honest. If policy demands immediate erasure with no grace window, skip the 30-day restore path but keep anonymize-in-place on shared tables. If you already maintain an append-only ledger that denormalizes both parties at write time, the FK story differs. If linked tables still hold re-identifiable PII, scrub those too or anonymization is false comfort.
For another pattern on erasure vs retention (audio chunks and terminal purge), see Convex session audio purge is terminal-only. Here the lesson is narrower: never let one user's delete CASCADE into another party's books.
Happy coding! Sander