Skip to content

feat(store): billing account delete hides the account's token transactions with the row - #1999

Open
rohilsurana wants to merge 8 commits into
mainfrom
feat/billing-account-delete-hides-transactions
Open

rohilsurana wants to merge 8 commits into
mainfrom
feat/billing-account-delete-hides-transactions

Conversation

@rohilsurana

@rohilsurana rohilsurana commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Summary

Deleting a billing account now hides the account's token transactions. The delete also tells the caller how many tokens were forfeited.

An account that owes tokens cannot be deleted. The API answers with failed precondition.

When tokens are forfeited, an app.billing.tokens.forfeited audit record is written.

This PR is stacked on #1983.

Changes

  • The account delete in the repository hides the account's live token transactions and returns the forfeited amount and the purchased share.
  • The delete is rolled back if the hidden transactions add up to a debt.
  • The service refuses the delete when the balance is below zero. This check runs before the Stripe call.
  • The service also checks for live subscriptions, invoices and checkouts before the Stripe call, with a new HasLiveRecords repository method. A refused delete no longer leaves a deleted customer at Stripe.
  • The API maps the new ErrHasTokenDebt error to failed precondition.
  • The service writes the forfeited tokens audit record when the amount is above zero.
  • billing/credit and core/deleter are not changed.

Technical Details

  • One SQL statement hides the transactions and returns the totals. It only touches rows where account_id is the customer id, never the system account.
  • The purchased share is the net of rows with source system.buy, kept between zero and the forfeited amount.
  • The debt check happens twice. The service checks the balance before the Stripe call, so nothing is deleted at Stripe for an account that owes tokens. The repository checks again inside the transaction, so a race cannot hide a debt.
  • The audit record is written after the delete. It uses a context that is not cancelled, so a client disconnect cannot lose it.
  • The live records check also runs twice, for the same reason: once before the Stripe call and once inside the transaction, where the row is locked.
  • A gap remains. A credit that passed the account check just before the delete can still land on the hidden account. There is no lock on that insert.
  • A crash between the delete and the audit write loses the event. The hidden rows still carry the amounts.

Test Plan

  • go build ./...
  • go vet on the touched packages
  • go test ./internal/store/postgres/ ./billing/... ./core/deleter/... ./internal/api/v1beta1connect/
  • golangci-lint run on the touched packages, 0 issues
  • gofmt on the changed files
  • Each commit was written test first and the new tests were seen failing before the code

SQL Safety

  • Values go in through goqu.Record and goqu.Ex or bind parameters
  • Callers of ToSQL forward the params
  • No ? placeholders inside quoted literals in goqu.L
  • No new nolint annotations

@vercel

vercel Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
frontier Ready Ready Preview Oct 19, 2026 9:14am UTC

@coderabbitai

coderabbitai Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Review in 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: Repository: raystack/frontier/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: bee7133b-f40f-49ee-9549-a1a3d86d9789

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

Summary by CodeRabbit

  • Bug Fixes
    • Customer account deletion is blocked when the account has token debt or live billing records, helping prevent deletion when outstanding balances or billing activity remain.
    • When deletion succeeds, token totals are recorded with the deletion and can be recovered for organization deletion notices, including after a retry.
  • Changes
    • The forfeited-tokens webhook event is no longer listed among supported webhook events.

Billing-customer deletion now checks for token debt and live records, hides transactions, and records forfeited and purchased token totals. Organization delete notices recover those totals from billing-customer deletion audit records. Organization teardown no longer separately deletes credit transactions or writes token-forfeiture audit records.

Changes: Billing deletion and token recovery

Layer / File(s) Summary
Billing deletion rules and persistence
billing/customer/errors.go, internal/store/postgres/billing_customer_repository.go, internal/store/postgres/billing_customer_repository_pg_test.go
Deletion hides live transactions, rejects net debt, checks for live child records, and records forfeited and purchased token totals in audit metadata. PostgreSQL tests cover deletion, rollback, and live-record checks.
Customer deletion guards and error mapping
billing/customer/service.go, billing/customer/mocks/repository.go, billing/customer/service_test.go, internal/api/v1beta1connect/billing_errors.go, internal/api/v1beta1connect/billing_errors_test.go
The customer service checks balance and live records before deletion. The API maps token debt to a failed-precondition error. Tests cover the guards and mapping.
Organization teardown and audit-service wiring
cmd/serve.go, core/deleter/service.go, core/deleter/mocks/credit_service.go, core/deleter/service_test.go
The cascade deleter receives an audit-record service. Customer teardown no longer separately resolves balances, writes token-forfeiture audit records, or deletes credit transactions.
Delete-notice recovery from audit records
core/deleter/delete_notice.go, core/deleter/delete_notice_test.go, core/deleter/mocks/audit_record_service.go, core/deleter/service_test.go, core/audit/audit.go, docs/content/docs/reference/webhook.mdx, web/sdk/admin/utils/webhook-events.ts
Delete notices recover token totals from billing-customer deletion records. The legacy token-forfeiture event is removed from the event declarations and webhook lists.

Priority: ➖ Normal

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

Change: Feature

Suggested reviewers: amangit07, whoabhisheksah

Merge Risk: 🟡 Moderate · up to 7f926

Organization deletion can overstate forfeited tokens, while a concurrent credit can be left on a deleted account. Both should be addressed before merging.

Pre-merge checks: Passed 2
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.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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 Oct 14, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 39228452466

Coverage increased (+0.06%) to 55.524%

Details

  • Coverage increased (+0.06%) from the base build.
  • Patch coverage: 11 uncovered changes across 3 files (64 of 75 lines covered, 85.33%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
internal/store/postgres/billing_customer_repository.go 37 31 83.78%
billing/customer/service.go 14 10 71.43%
cmd/serve.go 1 0 0.0%
Total (6 files) 75 64 85.33%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 41784
Covered Lines: 23200
Line Coverage: 55.52%
Coverage Strength: 19.28 hits per line

💛 - Coveralls

@rohilsurana
rohilsurana force-pushed the feat/billing-account-delete-hides-transactions branch from 4f3a2c0 to 88f48ee Compare October 14, 2026 06:27
@rohilsurana
rohilsurana force-pushed the feat/billing-rows-soft-delete branch from be61aad to 49f5fe5 Compare October 14, 2026 09:03
@rohilsurana
rohilsurana force-pushed the feat/billing-account-delete-hides-transactions branch from 88f48ee to 55510df Compare October 14, 2026 09:04
@rohilsurana
rohilsurana marked this pull request as ready for review October 14, 2026 10:45
@rohilsurana
rohilsurana force-pushed the feat/billing-rows-soft-delete branch from 49f5fe5 to 170adbe Compare October 16, 2026 08:44
@rohilsurana
rohilsurana force-pushed the feat/billing-account-delete-hides-transactions branch from 55510df to 5a6a5bc Compare October 16, 2026 10:04
@rohilsurana
rohilsurana force-pushed the feat/billing-rows-soft-delete branch from 170adbe to c2b82b8 Compare October 16, 2026 10:04
@rohilsurana
rohilsurana force-pushed the feat/billing-rows-soft-delete branch from c2b82b8 to e625872 Compare October 16, 2026 10:20
@rohilsurana
rohilsurana force-pushed the feat/billing-account-delete-hides-transactions branch from 5a6a5bc to 0286c5f Compare October 16, 2026 10:21
Comment thread billing/customer/service.go Outdated
}

func (s *Service) logTokensForfeited(ctx context.Context, customer Customer, forfeit ForfeitedTokens) {
if err := audit.NewLogger(ctx, customer.OrgID).LogWithAttrs(audit.BillingTokensForfeitedEvent, audit.Target{

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.

this is not audit records, can we add audit records or is this event audit log intended?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Moved to an audit record. The forfeited and purchased amounts are now event metadata on the billing_customer.deleted record, written in the delete transaction. The old log event is gone, and org delete no longer writes its own.

Base automatically changed from feat/billing-rows-soft-delete to main October 19, 2026 09:12

@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: 2


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository: raystack/frontier/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e8cc6bc4-0fe1-4ef3-ac62-1754a045a56d
📥 Commits

Reviewing files that changed from the base of the PR and between 4e266f1 and 7f9267e.

📒 Files selected for processing: 18
  • billing/customer/errors.go
  • billing/customer/mocks/repository.go
  • billing/customer/service.go
  • billing/customer/service_test.go
  • cmd/serve.go
  • core/audit/audit.go
  • core/deleter/delete_notice.go
  • core/deleter/delete_notice_test.go
  • core/deleter/mocks/audit_record_service.go
  • core/deleter/mocks/credit_service.go
  • core/deleter/service.go
  • core/deleter/service_test.go
  • docs/content/docs/reference/webhook.mdx
  • internal/api/v1beta1connect/billing_errors.go
  • internal/api/v1beta1connect/billing_errors_test.go
  • internal/store/postgres/billing_customer_repository.go
  • internal/store/postgres/billing_customer_repository_pg_test.go
  • web/sdk/admin/utils/webhook-events.ts
💤 Files with no reviewable changes: 4
  • docs/content/docs/reference/webhook.mdx
  • core/deleter/mocks/credit_service.go
  • web/sdk/admin/utils/webhook-events.ts
  • core/audit/audit.go

Comment on lines +138 to +141
records, err := d.auditRecordService.List(ctx, utils.NewRQLQuery("", 0, forfeitRecordsLimit, []rql.Filter{
{Name: "event", Operator: "eq", Value: auditrecord.BillingCustomerDeletedEvent.String()},
{Name: "org_id", Operator: "eq", Value: orgID},
}, []rql.Sort{{Name: "occurred_at", Order: "desc"}}, nil))

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
# Confirm that standalone billing-account deletion reaches customer.Service.Delete.
rg -nP -C4 'DeleteBillingAccount|customerService\.Delete\(' --type=go -g '!**/mocks/**'

Repository: raystack/frontier

Length of output: 4760


🏁 Script executed:

set -u
printf '%s\n' '--- core/deleter/delete_notice.go ---'
nl -ba core/deleter/delete_notice.go | sed -n '1,230p'
printf '%s\n' '--- billing/customer/service.go delete declarations/callers ---'
rg -n -C8 -F -- 'func (s *Service) Delete' billing internal core --glob '*.go' || true
printf '%s\n' '--- billing customer repository delete implementation ---'
rg -n -C16 -F -- 'func (r *BillingCustomerRepository) Delete' internal/store billing --glob '*.go' || true
printf '%s\n' '--- delete notice and customer delete call sites ---'
rg -n -C6 -e 'recoverForfeitFromAudit' -e 'customerService\.Delete\(' -e 'Delete\(ctx,.*customer' core billing internal --glob '*.go' || true
printf '%s\n' '--- relevant delete/audit tests ---'
rg -n -C6 -e 'forfeited_tokens' -e 'BillingCustomerDeletedEvent' -e 'recoverForfeitFromAudit' core billing internal --glob '*_test.go' || true

Repository: raystack/frontier

Length of output: 37442


🏁 Script executed:

set -u
printf '%s\n' '--- customer service Delete ---'
nl -ba billing/customer/service.go | sed -n '255,325p'
printf '%s\n' '--- repository Delete implementations ---'
rg -n -C12 --glob '*.go' -F -- 'func (r *BillingCustomerRepository) Delete' internal/store billing || true
rg -n -C12 --glob '*.go' -F -- 'BillingCustomerDeletedEvent' internal/store billing || true
printf '%s\n' '--- organization deletion flow ---'
nl -ba core/deleter/service.go | sed -n '250,315p'
nl -ba core/deleter/service.go | sed -n '430,495p'
printf '%s\n' '--- delete notice tests ---'
nl -ba core/deleter/delete_notice_test.go | sed -n '1,115p'

Repository: raystack/frontier

Length of output: 20190


🏁 Script executed:

set -u
printf '%s\n' '--- billing customer List and repository visibility ---'
rg -n -C10 --glob '*.go' -F -- 'func (r *BillingCustomerRepository) List' internal/store/postgres
rg -n -C8 --glob '*.go' -F -- 'func (r *BillingCustomerRepository) GetByID' internal/store/postgres
printf '%s\n' '--- complete repository Delete block ---'
nl -ba internal/store/postgres/billing_customer_repository.go | sed -n '500,625p'

Repository: raystack/frontier

Length of output: 5331


Scope forfeiture recovery to organization teardown attempts.

recoverForfeitFromAudit filters only by billing_customer.deleted and organization ID. Standalone DeleteBillingAccount calls the same customer deletion method used by organization teardown, and each deletion writes forfeited and purchased token metadata. A later organization deletion can therefore include a prior standalone deletion in its notice and settlement. The recovery runs on every organization deletion, not only retries.

Add an explicit organization-teardown marker to the deletion audit record and filter recovery by that marker. Do not use the organization ID alone as the scope.

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.

@rohilsurana This is real: DeleteBillingAccount writes the same forfeit metadata on billing_customer.deleted, so a later org delete adds it to the owner notice. Can we instead mark the records the org teardown writes, and only recover those?

Comment on lines +521 to +532
hideTransactionsQuery := `WITH hidden AS (
UPDATE billing_transactions SET deleted_at = now()
WHERE account_id = $1 AND deleted_at IS NULL
RETURNING type, source, amount
)
SELECT coalesce(sum(CASE WHEN type = $2 THEN amount ELSE -amount END), 0),
coalesce(sum(CASE WHEN source = $3 THEN (CASE WHEN type = $2 THEN amount ELSE -amount END) ELSE 0 END), 0)
FROM hidden`
hideTransactionsParams := []any{id, credit.CreditType, credit.SourceSystemBuyEvent}

var customerModel Customer
var forfeitedTokens, purchasedTokens int64

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

rg -n 'func .*CreateEntry|func .*CreateTransaction|Insert.*Transaction|insert into billing_transactions|Insert\("billing_transactions"|billing_transactions.*INSERT' billing internal | head -120
sed -n '105,130p' internal/store/postgres/migrations/20231012175641_create_billing_tables.up.sql
sed -n '500,595p' internal/store/postgres/billing_customer_repository.go

Repository: raystack/frontier

Length of output: 6128


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- transaction repository ---'
sed -n '1,280p' internal/store/postgres/billing_transactions_repository.go
printf '%s\n' '--- concrete CreateEntry callers ---'
rg -n -F --glob '*.go' -e '.CreateEntry(' -e 'CreateEntry(' billing internal | head -240
printf '%s\n' '--- credit service and repository declarations ---'
rg -n -F --glob '*.go' -e 'type TransactionRepository' -e 'func (.*Credit' -e 'Credit(' billing internal | head -240
printf '%s\n' '--- focused tests ---'
sed -n '1,240p' internal/store/postgres/billing_transactions_repository_pg_test.go
printf '%s\n' '--- billing transaction migrations ---'
rg -n -F --glob '*.sql' -e 'billing_transactions' -e 'ALTER TABLE billing_transactions' internal/store/postgres/migrations | head -240

Repository: raystack/frontier

Length of output: 23712


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- CreateEntry dependencies ---'
rg -n -F --glob 'internal/store/postgres/billing_transactions_repository.go' -e 'func (r BillingTransactionRepository) getBalanceInTx' -e 'func (r BillingTransactionRepository) GetBalance' -e 'customerRepo.GetByID' -e 'fromLive(TABLE_BILLING_TRANSACTIONS)' internal/store/postgres/billing_transactions_repository.go
rg -n -F --glob 'internal/store/postgres/billing_customer_repository.go' -e 'func (r BillingCustomerRepository) GetByID' -e 'func (r BillingCustomerRepository) Delete' internal/store/postgres/billing_customer_repository.go
printf '%s\n' '--- relevant transaction and customer blocks ---'
sed -n '100,245p' internal/store/postgres/billing_transactions_repository.go
sed -n '70,135p' internal/store/postgres/billing_customer_repository.go
sed -n '485,585p' internal/store/postgres/billing_customer_repository.go
printf '%s\n' '--- credit service interface and callers ---'
sed -n '1,175p' billing/credit/service.go
rg -n -F --glob '*.go' -e 'NewCreditService' -e 'credit.Service' -e 'Credit(ctx' -e 'Debit(ctx' billing internal cmd | head -240
printf '%s\n' '--- all billing transaction constraints in migrations ---'
rg -n -i --glob '*.sql' -e 'foreign key' -e 'references billing_customers' -e 'billing_transactions' internal/store/postgres/migrations

Repository: raystack/frontier

Length of output: 21189


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- production Add callers and wiring ---'
rg -n -F --glob '*.go' -g '!**/*_test.go' -e '.CreditService.Add(' -e '.creditService.Add(' -e 'CreditService.Add(' -e 'creditService.Add(' -e 'credit.Service' -e 'NewService(' billing internal cmd | head -260
printf '%s\n' '--- exact customer lookup and SQL helper blocks ---'
sed -n '175,235p' internal/store/postgres/billing_customer_repository.go
rg -n -F --glob 'internal/store/postgres/*.go' -e 'func fromLive' -e 'func softDelete' -e 'func (.*WithTxn' -e 'WithTxn(ctx' internal/store/postgres pkg/db
sed -n '1,120p' internal/store/postgres/query.go 2>/dev/null || true
sed -n '1,220p' pkg/db/client.go 2>/dev/null || true

Repository: raystack/frontier

Length of output: 14266


Lock the live customer in the credit transaction.

CreateEntry checks the target customer before it starts its transaction. credit.Service.Add then inserts the customer credit without reading or locking that customer inside the transaction.

If the pre-check succeeds and BillingCustomerRepository.Delete commits before the insert transaction starts, createTransactionEntry can insert a new live transaction for the soft-deleted account. The deletion hides only transactions that exist when its update runs. This leaves a live transaction outside the deletion totals.

Add a transaction-scoped live-customer check with FOR UPDATE before the balance check and transaction inserts. Return customer.ErrNotFound when the row is already soft-deleted.

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.

@rohilsurana This is the gap the description lists. Can we close it in a follow-up with a FOR KEY SHARE read of the live customer inside CreateEntry's transaction? It only has to clash with the delete's FOR UPDATE.

Comment thread core/deleter/service.go
type CreditService interface {
GetBalance(ctx context.Context, accountID string) (int64, error)
List(ctx context.Context, flt credit.Filter) ([]credit.Transaction, error)
DeleteByAccountID(ctx context.Context, accountID string) error

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.

credit.Service.DeleteByAccountID has no callers after this change. Can we remove it, along with the repository method?

@AmanGIT07

Copy link
Copy Markdown
Contributor

The description still says the forfeit goes to app.billing.tokens.forfeited after the delete and that core/deleter is not changed. Can we update it to the new flow, including that this removes app.billing.tokens.forfeited, which has shipped since v0.116.0?

This branch was successfully deployed

1 active deployment
Preview — 7f9267e9 Deployed Oct 19, 2026 by vercel[bot]
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