Skip to content

feat(deleter): check all org delete blockers up front and return them together - #1857

Open
whoAbhishekSah wants to merge 1 commit into
org-delete-preflight-mocksfrom
org-delete-preflight-blockers
Open

feat(deleter): check all org delete blockers up front and return them together#1857
whoAbhishekSah wants to merge 1 commit into
org-delete-preflight-mocksfrom
org-delete-preflight-blockers

Conversation

@whoAbhishekSah

@whoAbhishekSah whoAbhishekSah commented Aug 5, 2026

Copy link
Copy Markdown
Member

Closes #1837. Top of a 3-PR stack (#1866): #1864 (proto) → #1865 (mocks) → this. Depends on raystack/proton#497 for the new request field.

What changed

Deleting an organization used to be gated by one rule: if the billing account ever had an invoice, the delete was refused — and the caller saw a bare internal server error. That rule was too strict (a fully paid invoice blocked the org forever) and too loose at the same time (an active subscription didn't block anything; the delete silently cancelled it mid-flight, and token balances were never looked at).

This PR replaces that gate with a pre-flight check that runs before any deletion starts, collects all blockers, and returns them together as a failed_precondition error with one standard PreconditionFailure violation per blocker:

Violation type What blocks What the caller does
ACTIVE_SUBSCRIPTION an active or trialing subscription cancel it, then retry
UNPAID_INVOICE an open or uncollectible invoice pay it, then retry
NEGATIVE_TOKEN_BALANCE token debt on the account buy tokens to clear the debt, then retry
UNUSED_TOKENS a positive token balance retry with acknowledge_token_forfeit set

Paid, void, draft, and zero-amount invoices no longer block: the billing provider keeps its own permanent copy of every invoice, so deleting our rows loses nothing.

The token forfeit acknowledgment

Unused prepaid tokens have no clean-up action — the user already paid for them. What the server needs is informed consent, and a UI popup alone can't provide that because the API is public (SDKs, scripts, curl). So DeleteOrganizationRequest gains a bool acknowledge_token_forfeit field (added in raystack/proton#497; pulled in by #1864). Without the flag, a positive balance returns an UNUSED_TOKENS violation naming the amount at stake. With it, the delete proceeds and the forfeited amount is written to a new app.billing.tokens.forfeited audit record during the credit teardown. The flag cannot bypass a debt.

Accounts without a billing provider (offline accounts) only get the token checks: their subscription and invoice rows have nothing behind them the caller could cancel or pay, and the teardown already skips the provider for them.

Error mapping

The handler maps deleter.BlockedError to failed_precondition and attaches the violations as errdetails.PreconditionFailure. The error sanitizer interceptor passes non-internal codes through untouched, so both the message and the structured details reach the caller. Everything unexpected stays internal, same as before.

Example response:

{
  "code": "failed_precondition",
  "message": "organization cannot be deleted yet: subscription[...] is active: cancel it, then retry the delete; billing account[...] has 500 unused tokens that deleting the organization forfeits: retry the delete with acknowledge_token_forfeit set to proceed",
  "details": [{
    "type": "google.rpc.PreconditionFailure",
    "value": { "violations": [
      { "type": "ACTIVE_SUBSCRIPTION", "subject": "<subscription-id>", "description": "..." },
      { "type": "UNUSED_TOKENS", "subject": "<billing-account-id>", "description": "..." }
    ]}
  }]
}

Notes for review

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 5, 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 10, 2026 6:28am

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

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: 62914615-8383-4fd5-bb69-04f575550f0a

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
📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Organization deletion now checks for active subscriptions, unpaid invoices, and token balances before making changes.
    • Deletion responses clearly identify all conditions preventing completion.
    • Positive token balances can be forfeited when explicitly acknowledged.
    • Billing-account deletion records forfeited tokens for audit purposes.
  • Bug Fixes
    • Improved handling and reporting of billing-related deletion failures.
  • API Updates
    • Added support for the “uncollectible” invoice status.

Walkthrough

Organization deletion now performs billing preflight checks, aggregates structured blockers, and accepts token-forfeit acknowledgement. Billing-account deletion audits forfeited tokens. The Connect API returns structured failed-precondition details for blocked deletions.

Changes

Organization deletion workflow

Layer / File(s) Summary
Deletion contracts and billing events
billing/invoice/invoice.go, core/audit/audit.go, core/deleter/deleter.go, core/deleter/service.go, core/deleter/mocks/*
Added the uncollectible invoice state, token-forfeiture system event, structured deletion blockers, and subscription and balance service contracts with generated mocks.
Deletion preflight and token forfeiture
core/deleter/service.go, core/deleter/service_test.go
Organization deletion aggregates subscription, invoice, and balance blockers before modifying data. Billing-account deletion audits positive token balances before deleting credit transactions.
Connect API propagation and validation
internal/api/v1beta1connect/deleter.go, internal/api/v1beta1connect/interfaces.go, internal/api/v1beta1connect/mocks/*, internal/api/v1beta1connect/deleter_test.go, go.mod, Makefile
The API forwards token-forfeit acknowledgement and maps blocked deletions to structured FailedPrecondition responses. RPC dependencies and the protobuf source commit were updated.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • raystack/frontier#1838 — Also changes organization-deletion behavior and core/deleter/service.go.
  • raystack/frontier#1839 — Also changes billing-related deletion audit handling in core/audit/audit.go and core/deleter/service.go.

Suggested reviewers: rohilsurana

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch org-delete-preflight-blockers

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 51d78c26-5a73-43e9-8050-b9aec5ac46cb

📥 Commits

Reviewing files that changed from the base of the PR and between c294c5f and 783da24.

⛔ Files ignored due to path filters (1)
  • proto/v1beta1/frontier.pb.go is excluded by !**/*.pb.go, !proto/**
📒 Files selected for processing (13)
  • Makefile
  • billing/invoice/invoice.go
  • core/audit/audit.go
  • core/deleter/deleter.go
  • core/deleter/mocks/credit_service.go
  • core/deleter/mocks/subscription_service.go
  • core/deleter/service.go
  • core/deleter/service_test.go
  • go.mod
  • internal/api/v1beta1connect/deleter.go
  • internal/api/v1beta1connect/deleter_test.go
  • internal/api/v1beta1connect/interfaces.go
  • internal/api/v1beta1connect/mocks/cascade_deleter.go

Comment on lines +250 to +252
// the ack flag cannot bypass a debt
err = m.build().DeleteOrganization(context.Background(), "org-1", true)
assert.ErrorAs(t, err, &blocked)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the blocker type on the acknowledged retry.

The second call only checks that the error is a BlockedError. A regression that returns a different blocker type for ackTokenForfeit=true would still pass. Assert the type to close the gap.

💚 Proposed test assertion
 		// the ack flag cannot bypass a debt
 		err = m.build().DeleteOrganization(context.Background(), "org-1", true)
 		assert.ErrorAs(t, err, &blocked)
+		assert.Len(t, blocked.Blockers, 1)
+		assert.Equal(t, deleter.BlockerNegativeTokenBalance, blocked.Blockers[0].Type)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// the ack flag cannot bypass a debt
err = m.build().DeleteOrganization(context.Background(), "org-1", true)
assert.ErrorAs(t, err, &blocked)
// the ack flag cannot bypass a debt
err = m.build().DeleteOrganization(context.Background(), "org-1", true)
assert.ErrorAs(t, err, &blocked)
assert.Len(t, blocked.Blockers, 1)
assert.Equal(t, deleter.BlockerNegativeTokenBalance, blocked.Blockers[0].Type)

… together

Replace the blanket "any invoice ever" gate with a pre-flight check
that runs before any deletion starts and collects every blocker:

- active or trialing subscription: cancel it, then retry
- open or uncollectible invoice: pay it, then retry; paid invoices
  stop blocking since the billing provider keeps its own copy
- negative token balance: buy tokens to clear the debt, then retry
- unused tokens: retry with acknowledge_token_forfeit set; the
  forfeited amount is written to an audit record during the delete

Accounts without a billing provider only get the token checks, since
their subscription and invoice rows have nothing the caller could
cancel or pay.

The handler returns the blockers as a failed_precondition error with
one PreconditionFailure violation per blocker, so a caller sees the
whole checklist in one response instead of one blocker per retry.
Unexpected failures stay internal.

Closes #1837

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

Organization delete: check all blockers up front and tell the user what to fix

1 participant