Skip to content

Commit 3b1e2c8

Browse files
feat(deleter): clear or report org delete blockers up front
The delete first checks everything that blocks it and returns all the reasons together as one failed_precondition response: a running subscription on a paid plan (the caller downgrades it to the standard plan), invoices that still ask for money, and a negative token balance which support has to settle. Plans resolve lazily, only when a running subscription references one. When nothing blocks, subscriptions still running on a free plan are canceled immediately with unbilled usage invoiced on the spot — tolerating copies already gone on the provider — and the invoice check runs again so a final invoice still blocks. The plan is judged again in that pass, so a paid subscription created mid-delete blocks instead of being canceled. Unused tokens do not block: the delete forfeits them and writes the amount to an audit record. An already-deleted org returns not found before any checks run. Invoices are judged straight from the billing provider through a new cheap ListPayableOnProvider (three status-filtered pages, no local writes) instead of a full sync, and drafts with a non-zero amount now block too: the provider finalizes them shortly, and deleting inside that window would silently lose the charge.
1 parent 908fb91 commit 3b1e2c8

10 files changed

Lines changed: 781 additions & 30 deletions

File tree

billing/invoice/invoice.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ const (
4343
DraftState State = "draft"
4444
OpenState State = "open"
4545
PaidState State = "paid"
46+
// UncollectibleState marks an invoice the provider has written off; it
47+
// can still be paid.
48+
UncollectibleState State = "uncollectible"
4649
)
4750

4851
type Invoice struct {

billing/invoice/service.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,53 @@ func (s *Service) isCreditOverdraftDayOfInvoice() bool {
244244
return time.Now().UTC().Day() == s.creditOverdraftInvoiceDay
245245
}
246246

247+
// ListPayableOnProvider returns the customer's invoices that still ask for
248+
// money — open, uncollectible, or being prepared as drafts — read straight
249+
// from the billing provider so the answer is current. Zero-amount invoices
250+
// are skipped. Where a local row exists for the invoice it keeps its local
251+
// id; an invoice the sync has not seen yet is returned with an empty id and
252+
// only its provider reference. Unlike SyncWithProvider this touches no local
253+
// rows and reads only three small filtered pages, so it is cheap enough for
254+
// a request path.
255+
func (s *Service) ListPayableOnProvider(ctx context.Context, customr customer.Customer) ([]Invoice, error) {
256+
localInvoices, err := s.repository.List(ctx, Filter{
257+
CustomerID: customr.ID,
258+
})
259+
if err != nil {
260+
return nil, err
261+
}
262+
localByProviderID := make(map[string]Invoice, len(localInvoices))
263+
for _, inv := range localInvoices {
264+
localByProviderID[inv.ProviderID] = inv
265+
}
266+
267+
var payable []Invoice
268+
for _, status := range []State{DraftState, OpenState, UncollectibleState} {
269+
stripeInvoices := s.stripeClient.Invoices.List(&stripe.InvoiceListParams{
270+
Customer: stripe.String(customr.ProviderID),
271+
Status: stripe.String(string(status)),
272+
ListParams: stripe.ListParams{
273+
Context: ctx,
274+
},
275+
})
276+
for stripeInvoices.Next() {
277+
stripeInvoice := stripeInvoices.Invoice()
278+
if stripeInvoice.Total == 0 {
279+
continue
280+
}
281+
inv := stripeInvoiceToInvoice(customr.ID, stripeInvoice)
282+
if local, ok := localByProviderID[stripeInvoice.ID]; ok {
283+
inv.ID = local.ID
284+
}
285+
payable = append(payable, inv)
286+
}
287+
if err := stripeInvoices.Err(); err != nil {
288+
return nil, fmt.Errorf("failed to list %s invoices: %w", status, billingerrors.TranslateStripeError(err))
289+
}
290+
}
291+
return payable, nil
292+
}
293+
247294
func (s *Service) SyncWithProvider(ctx context.Context, customr customer.Customer) error {
248295
s.mu.Lock()
249296
defer s.mu.Unlock()

cmd/serve.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -587,7 +587,7 @@ func buildAPIDependencies(
587587
cascadeDeleter := deleter.NewCascadeDeleter(organizationService, projectService, resourceService,
588588
groupService, membershipService, policyService, roleService, invitationService, userService, userPATService,
589589
serviceUserService, customerService, subscriptionService, invoiceService, checkoutService,
590-
creditService, orgKycService,
590+
creditService, orgKycService, planService,
591591
)
592592

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

core/audit/audit.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ const (
9797

9898
BillingAccountDetailsUpdatedEvent EventName = "app.billing.account.details.updated"
9999
BillingCheckoutDeletedEvent EventName = "app.billing.checkout.deleted"
100+
BillingTokensForfeitedEvent EventName = "app.billing.tokens.forfeited"
100101
)
101102

102103
var systemEvents = []EventName{
@@ -113,6 +114,7 @@ var systemEvents = []EventName{
113114
OrgDeletedEvent,
114115
OrgDisabledEvent,
115116
BillingCheckoutDeletedEvent,
117+
BillingTokensForfeitedEvent,
116118
}
117119

118120
func IsSystemEvent(event EventName) bool {

core/deleter/deleter.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,38 @@
11
package deleter
22

3-
import "fmt"
3+
import "strings"
44

5-
var (
6-
ErrDeleteNotAllowed = fmt.Errorf("deletion not allowed for billed accounts")
5+
// Reasons an organization delete can be blocked. The API error carries
6+
// them as violation types, so a client can tell the reasons apart
7+
// without reading the message text.
8+
const (
9+
BlockerActiveSubscription = "ACTIVE_SUBSCRIPTION"
10+
BlockerUnpaidInvoice = "UNPAID_INVOICE"
11+
BlockerNegativeTokenBalance = "NEGATIVE_TOKEN_BALANCE"
712
)
13+
14+
// Blocker is one reason an organization cannot be deleted right now.
15+
type Blocker struct {
16+
// Type is one of the Blocker* constants.
17+
Type string
18+
// Subject is the id of the entity behind the reason.
19+
Subject string
20+
// Message says what blocks the delete and how to clear it.
21+
Message string
22+
}
23+
24+
// BlockedError carries every blocker the up-front check found, so the
25+
// caller gets one checklist instead of discovering blockers one retry at
26+
// a time.
27+
type BlockedError struct {
28+
OrgID string
29+
Blockers []Blocker
30+
}
31+
32+
func (e *BlockedError) Error() string {
33+
msgs := make([]string, 0, len(e.Blockers))
34+
for _, b := range e.Blockers {
35+
msgs = append(msgs, b.Message)
36+
}
37+
return "organization cannot be deleted yet: " + strings.Join(msgs, "; ")
38+
}

0 commit comments

Comments
 (0)