Skip to content

feat(deleter): email the org owners when a delete forfeits tokens - #1880

Draft
whoAbhishekSah wants to merge 1 commit into
org-delete-preflight-blockersfrom
org-delete-forfeit-email
Draft

feat(deleter): email the org owners when a delete forfeits tokens#1880
whoAbhishekSah wants to merge 1 commit into
org-delete-preflight-blockersfrom
org-delete-forfeit-email

Conversation

@whoAbhishekSah

@whoAbhishekSah whoAbhishekSah commented Aug 13, 2026

Copy link
Copy Markdown
Member

Part of #1837. Stacked on #1857; the check RPC follows in #1893/#1894.

Deleting an organization forfeits any unused tokens (the client confirms that with the user before calling). This PR adds the second half of that flow: the org owners get an email saying how many tokens were left and that support can transfer the purchased amount to their bank account.

How it works:

  • The email subject and body come from new config keys billing.token_forfeit_notice.subject and billing.token_forfeit_notice.body. Both are Go templates with .Amount (tokens forfeited), .Purchased (the share of it that was bought), .Org (the deleted organization), .User (the owner receiving the mail), and .DeletedBy (who ran the delete, when known). Empty config falls back to plain built-in text. This is the same pattern the PAT expiry alert emails use.
  • Only the purchased share is transferable. Every token grant is recorded in billing_transactions with a source (system.buy for purchases, system.starter for plan starter grants, system.awarded for awards). Spending is not attributed to a source, so complimentary tokens are counted as spent first: the purchased share is the smaller of the balance and everything ever bought. Accounts holding starter or awarded grants get the split; when everything was purchased the share equals the whole amount and the email shows one number.
  • The forfeit audit record (app.billing.tokens.forfeited) now carries purchased next to amount — the transaction rows are deleted with the org, so the audit row is what support settles from later.
  • The owners are resolved through the membership service (the same path ListOrganizationUsers uses) and every owner gets the mail, through the shared app.mailer dialer.
  • The owners and the amounts are read before teardown starts (both are gone after), but the mail is only sent after the delete fully succeeded. A failed delete never emails anyone; repeated calls on a deleted org return not_found first, so nobody gets the mail twice. A retry that lost the balances mid-teardown recovers the amounts from the forfeit audit records the earlier attempt wrote (needs the db audit sink). Send and render failures are logged per owner and never fail the API call.
  • The owner lookup is best-effort: the delete never depends on the policy machinery, and a forfeit with no reachable owner logs a warning instead of silently mailing no one.
  • The balances are read once and reused by the blocker check and the teardown audit, so the audit numbers always match what the pre-check saw.

Side change: the org lookup at the start of the delete switched from Get to GetRaw, so a disabled org keeps its title for the email and stays deletable without the ErrDisabled special case.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview Aug 21, 2026 8:17am

@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4a1d036a-82dd-48f7-8036-dfb5761b5e10

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coveralls

coveralls commented Aug 13, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 32462394554

Coverage increased (+0.09%) to 49.004%

Details

  • Coverage increased (+0.09%) from the base build.
  • Patch coverage: 64 uncovered changes across 3 files (158 of 222 lines covered, 71.17%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
core/deleter/forfeit_notice.go 178 117 65.73%
core/deleter/service.go 43 41 95.35%
cmd/serve.go 1 0 0.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 40527
Covered Lines: 19860
Line Coverage: 49.0%
Coverage Strength: 15.67 hits per line

💛 - Coveralls

Comment thread core/deleter/forfeit_notice.go
Comment thread core/deleter/forfeit_notice.go Outdated
Comment thread core/deleter/forfeit_notice.go

@rohilsurana rohilsurana left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stack-aware review (base org-delete-preflight-blockers). The retry issue in the first comment is the one worth fixing; the rest are smaller robustness and cleanup notes. One efficiency note that spans functions: customerService.List(OrgID) now runs three times per delete (ensureDeletable, collectForfeitNotice, deleteCustomers) and GetBalance runs twice per account, where one list and one balance read per account would do.

Comment thread core/deleter/service.go
Comment thread core/deleter/forfeit_notice.go
Comment thread core/deleter/forfeit_notice.go Outdated
Comment thread core/deleter/forfeit_notice.go
Comment thread core/deleter/forfeit_notice.go
Comment thread core/deleter/forfeit_notice.go Outdated
@whoAbhishekSah

Copy link
Copy Markdown
Member Author

On the review-body efficiency note: fixed across the stack. DeleteOrganization now lists the billing accounts once and passes them down (blocker check, notice collection, teardown), the balances are read once in the notice collection and reused by the blocker check and the forfeit audit, and the plan lookups share one per-delete cache between the blocker pass and the cancel pass. The exported DeleteCustomers still reads for itself when called directly.

@rohilsurana rohilsurana left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after the fix commits. The retry recovery, the non-fatal owner lookup, the no-owner warn, parse-once, and the rename all look good. The new recoverForfeitFromAudit has a couple of reconciliation gaps worth a look, and one owner case is still uncovered.

Comment thread core/deleter/service.go Outdated
Comment thread core/deleter/forfeit_notice.go
Comment thread core/deleter/forfeit_notice.go
Deleting an organization forfeits any unused tokens. The owners now get
an email saying how many tokens were left, how many of those came from
purchases (complimentary grants count as spent first, refunded
purchases are taken back out), and that support can transfer the
purchased amount. The subject and body come from the billing config
(billing.token_forfeit_notice.subject/body) as Go templates with
.Amount, .Purchased, .Org, .User, and .DeletedBy, falling back to plain
built-in text — the same pattern the PAT expiry alerts use.

The notice is collected before teardown (balances, purchased shares,
and owners are gone after) and sent only after the delete fully
succeeded; a retry that lost the balances mid-teardown recovers the
amounts from the audit records the earlier attempt wrote. The owner
lookup is best-effort so the delete never depends on the policy
machinery, a forfeit with no reachable owner logs a warning, and the
per-account numbers thread into the teardown so the forfeit audit
records (amount plus purchased) match what the pre-check read without
reading balances twice. Send and render failures are logged per owner
and never fail the delete.

The org lookup at the start switched from Get to GetRaw so a disabled
org keeps its title in the email and stays deletable without a special
case.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants