Skip to content

Commit 5172886

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 88bdb27 commit 5172886

10 files changed

Lines changed: 873 additions & 37 deletions

File tree

billing/invoice/invoice.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package invoice
22

33
import (
44
"fmt"
5+
"slices"
56
"time"
67

78
"github.com/raystack/frontier/pkg/pagination"
@@ -43,8 +44,22 @@ const (
4344
DraftState State = "draft"
4445
OpenState State = "open"
4546
PaidState State = "paid"
47+
// UncollectibleState marks an invoice the provider has written off; it
48+
// can still be paid.
49+
UncollectibleState State = "uncollectible"
4650
)
4751

52+
// PayableStates are the invoice states that can still ask the customer for
53+
// money: open and uncollectible invoices are payable now, a draft becomes
54+
// payable once the provider finalizes it. The delete blockers and the
55+
// provider-side payable listing must agree on this set.
56+
var PayableStates = []State{DraftState, OpenState, UncollectibleState}
57+
58+
// IsPayable reports whether the invoice's state is one of PayableStates.
59+
func (i Invoice) IsPayable() bool {
60+
return slices.Contains(PayableStates, i.State)
61+
}
62+
4863
type Invoice struct {
4964
ID string
5065
CustomerID string

billing/invoice/service.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,59 @@ 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, expands nothing, and reads one status-filtered listing per payable
254+
// state — a customer with very many payable invoices pages through more
255+
// requests, but the cost scales with what is still owed, not with the whole
256+
// invoice history.
257+
func (s *Service) ListPayableOnProvider(ctx context.Context, customr customer.Customer) ([]Invoice, error) {
258+
localInvoices, err := s.repository.List(ctx, Filter{
259+
CustomerID: customr.ID,
260+
})
261+
if err != nil {
262+
return nil, err
263+
}
264+
localByProviderID := make(map[string]Invoice, len(localInvoices))
265+
for _, inv := range localInvoices {
266+
localByProviderID[inv.ProviderID] = inv
267+
}
268+
269+
var payable []Invoice
270+
for _, status := range PayableStates {
271+
stripeInvoices := s.stripeClient.Invoices.List(&stripe.InvoiceListParams{
272+
Customer: stripe.String(customr.ProviderID),
273+
Status: stripe.String(string(status)),
274+
ListParams: stripe.ListParams{
275+
Context: ctx,
276+
},
277+
})
278+
for stripeInvoices.Next() {
279+
stripeInvoice := stripeInvoices.Invoice()
280+
// AmountRemaining is what the customer still owes; Total is not:
281+
// a negative total is a credit owed to the customer, and an open
282+
// invoice fully covered by their credit balance has a positive
283+
// total with nothing due. Neither asks for money.
284+
if stripeInvoice.AmountRemaining <= 0 {
285+
continue
286+
}
287+
inv := stripeInvoiceToInvoice(customr.ID, stripeInvoice)
288+
if local, ok := localByProviderID[stripeInvoice.ID]; ok {
289+
inv.ID = local.ID
290+
}
291+
payable = append(payable, inv)
292+
}
293+
if err := stripeInvoices.Err(); err != nil {
294+
return nil, fmt.Errorf("failed to list %s invoices: %w", status, billingerrors.TranslateStripeError(err))
295+
}
296+
}
297+
return payable, nil
298+
}
299+
247300
func (s *Service) SyncWithProvider(ctx context.Context, customr customer.Customer) error {
248301
s.mu.Lock()
249302
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)