Skip to content

Commit 6b61473

Browse files
fix(deleter): clean up all billing and kyc records during org delete (#1835)
Organization delete could fail halfway and leave the org without owners and with a billing account pointing to a deleted Stripe customer. Checkout and kyc rows were never removed, so their foreign keys made the hard delete of billing_customers and organizations fail after the Stripe customer was already gone. Retrying hit the same error forever. - delete checkout rows before the billing customer row - delete the org_kyc row before the org row - delete credit transactions instead of orphaning them - always remove local subscription and invoice rows, also for offline billing accounts - tear down billing first and org policies near the end, so a failure at any step leaves the org owned and the delete retryable - treat subscriptions already gone or canceled on the provider as canceled instead of failing the teardown Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c4b8ca5 commit 6b61473

15 files changed

Lines changed: 698 additions & 195 deletions

File tree

billing/checkout/service.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ type Repository interface {
6363
Create(ctx context.Context, ch Checkout) (Checkout, error)
6464
UpdateByID(ctx context.Context, ch Checkout) (Checkout, error)
6565
List(ctx context.Context, filter Filter) ([]Checkout, error)
66+
DeleteByCustomerID(ctx context.Context, customerID string) error
6667
}
6768

6869
type CustomerService interface {
@@ -768,6 +769,12 @@ func (s *Service) List(ctx context.Context, filter Filter) ([]Checkout, error) {
768769
return s.repository.List(ctx, filter)
769770
}
770771

772+
// DeleteByCustomer removes all checkout records of a billing account. Checkout
773+
// sessions on the billing provider are not touched as they expire on their own.
774+
func (s *Service) DeleteByCustomer(ctx context.Context, customerID string) error {
775+
return s.repository.DeleteByCustomerID(ctx, customerID)
776+
}
777+
771778
func (s *Service) CreateSessionForPaymentMethod(ctx context.Context, ch Checkout) (Checkout, error) {
772779
billingCustomer, err := s.customerService.RegisterToProviderIfRequired(ctx, ch.CustomerID)
773780
if err != nil {

billing/credit/mocks/transaction_repository.go

Lines changed: 48 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

billing/credit/service.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type TransactionRepository interface {
2020
GetByID(ctx context.Context, id string) (Transaction, error)
2121
GetBalanceForRange(ctx context.Context, accountID string, start time.Time, end time.Time) (int64, error)
2222
GetBalanceForRangeWithoutOverdraft(ctx context.Context, accountID string, start time.Time, end time.Time) (int64, error)
23+
DeleteByAccountID(ctx context.Context, accountID string) error
2324
}
2425

2526
type CustomerRepository interface {
@@ -175,6 +176,12 @@ func (s Service) GetByID(ctx context.Context, id string) (Transaction, error) {
175176
return s.transactionRepository.GetByID(ctx, id)
176177
}
177178

179+
// DeleteByAccountID removes all credit transactions of a billing account. It is
180+
// meant for account teardown; the deletion is recorded in audit records.
181+
func (s Service) DeleteByAccountID(ctx context.Context, accountID string) error {
182+
return s.transactionRepository.DeleteByAccountID(ctx, accountID)
183+
}
184+
178185
// createAuditRecord creates an audit record for billing transaction events.
179186
func (s Service) createAuditRecord(ctx context.Context, customerID string, eventType pkgAuditRecord.Event, txID string, txEntry Transaction) error {
180187
customerAcc, err := s.customerRepository.GetByID(ctx, customerID)

billing/subscription/service.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -304,11 +304,20 @@ func (s *Service) Cancel(ctx context.Context, id string, immediate bool) (Subscr
304304
}
305305

306306
// check if schedule exists
307-
_, stripeSchedule, err := s.createOrGetSchedule(ctx, sub)
307+
stripeSubscription, stripeSchedule, err := s.createOrGetSchedule(ctx, sub)
308308
if err != nil {
309309
return sub, err
310310
}
311311

312+
if stripeSubscription != nil && stripeSubscription.Status == stripe.SubscriptionStatusCanceled {
313+
// already canceled on the provider, just sync the local state
314+
sub.State = string(stripeSubscription.Status)
315+
if stripeSubscription.CanceledAt > 0 {
316+
sub.CanceledAt = utils.AsTimeFromEpoch(stripeSubscription.CanceledAt)
317+
}
318+
return s.repository.UpdateByID(ctx, sub)
319+
}
320+
312321
if immediate || stripeSchedule == nil {
313322
stripeSubscription, err := s.stripeClient.Subscriptions.Cancel(sub.ProviderID, &stripe.SubscriptionCancelParams{
314323
Params: stripe.Params{
@@ -1076,19 +1085,21 @@ func (s *Service) ensureCreditsForPlan(ctx context.Context, sub Subscription, su
10761085
return nil
10771086
}
10781087

1088+
// DeleteByCustomer tears down all subscriptions of a billing account. Active
1089+
// subscriptions are canceled on the billing provider first. A subscription
1090+
// that is already gone or canceled on the provider counts as canceled, so a
1091+
// teardown that failed midway can be run again. Offline accounts have nothing
1092+
// on the provider; only their local records are removed.
10791093
func (s *Service) DeleteByCustomer(ctx context.Context, customr customer.Customer) error {
10801094
subs, err := s.List(ctx, Filter{
10811095
CustomerID: customr.ID,
10821096
})
10831097
if err != nil {
10841098
return err
10851099
}
1086-
if err := s.SyncWithProvider(ctx, customr); err != nil {
1087-
return err
1088-
}
10891100
for _, sub := range subs {
1090-
if sub.IsActive() {
1091-
if _, err := s.Cancel(ctx, sub.ID, true); err != nil {
1101+
if !customr.IsOffline() && sub.IsActive() {
1102+
if _, err := s.Cancel(ctx, sub.ID, true); err != nil && !errors.Is(err, ErrSubscriptionOnProviderNotFound) {
10921103
return err
10931104
}
10941105
}

cmd/serve.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,7 +618,8 @@ func buildAPIDependencies(
618618

619619
cascadeDeleter := deleter.NewCascadeDeleter(organizationService, projectService, resourceService,
620620
groupService, membershipService, policyService, roleService, invitationService, userService, userPATService,
621-
serviceUserService, customerService, subscriptionService, invoiceService,
621+
serviceUserService, customerService, subscriptionService, invoiceService, checkoutService,
622+
creditService, orgKycService,
622623
)
623624

624625
// we should default it with a stdout logger repository as postgres can start to bloat really fast

core/deleter/mocks/checkout_service.go

Lines changed: 83 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/deleter/mocks/credit_service.go

Lines changed: 83 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)