Skip to content

Commit b479ddd

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), unpaid (open or uncollectible) invoices, and a negative token balance which support has to settle. When nothing blocks, subscriptions still running on the standard plan are canceled immediately with unbilled usage invoiced on the spot, and the invoice check runs again so a final invoice still blocks the delete. 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. Invoice checks always sync from the billing provider first so the decision is made on fresh data.
1 parent 5a8e3f8 commit b479ddd

9 files changed

Lines changed: 606 additions & 31 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 {

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, cfg.Billing.AccountConfig.DefaultPlan,
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: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,41 @@
11
package deleter
22

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

5-
var (
6-
ErrDeleteNotAllowed = fmt.Errorf("deletion not allowed for billed accounts")
5+
// Blocker types returned by the org delete pre-flight check. They are
6+
// machine-readable and end up as PreconditionFailure violation types on
7+
// the API error, so clients can branch on them.
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. The
15+
// message names the fix: downgrading a paid subscription and paying an
16+
// invoice are things the caller can do themselves, settling a token debt
17+
// goes through support.
18+
type Blocker struct {
19+
// Type is one of the Blocker* constants.
20+
Type string
21+
// Subject is the id of the blocking entity, e.g. a subscription id.
22+
Subject string
23+
// Message says what blocks the delete and what to do about it.
24+
Message string
25+
}
26+
27+
// BlockedError carries every blocker the pre-flight check found, so the
28+
// caller gets one checklist instead of discovering blockers one retry at
29+
// a time.
30+
type BlockedError struct {
31+
OrgID string
32+
Blockers []Blocker
33+
}
34+
35+
func (e *BlockedError) Error() string {
36+
msgs := make([]string, 0, len(e.Blockers))
37+
for _, b := range e.Blockers {
38+
msgs = append(msgs, b.Message)
39+
}
40+
return "organization cannot be deleted yet: " + strings.Join(msgs, "; ")
41+
}

core/deleter/service.go

Lines changed: 190 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"errors"
66
"fmt"
77
"log/slog"
8+
"slices"
9+
"strconv"
810

911
"github.com/raystack/frontier/core/audit"
1012

@@ -16,6 +18,10 @@ import (
1618

1719
"github.com/raystack/frontier/billing/customer"
1820

21+
"github.com/raystack/frontier/billing/plan"
22+
23+
"github.com/raystack/frontier/billing/subscription"
24+
1925
"github.com/raystack/frontier/core/organization"
2026

2127
"github.com/raystack/frontier/internal/bootstrap/schema"
@@ -34,10 +40,6 @@ import (
3440
"github.com/raystack/frontier/core/serviceuser"
3541
)
3642

37-
const (
38-
DisableDeleteIfBilled = true
39-
)
40-
4143
type ProjectService interface {
4244
List(ctx context.Context, flt project.Filter) ([]project.Project, error)
4345
DeleteModel(ctx context.Context, id string) error
@@ -98,11 +100,14 @@ type CustomerService interface {
98100
}
99101

100102
type SubscriptionService interface {
103+
List(ctx context.Context, filter subscription.Filter) ([]subscription.Subscription, error)
104+
Cancel(ctx context.Context, id string, immediate bool) (subscription.Subscription, error)
101105
DeleteByCustomer(ctx context.Context, customr customer.Customer) error
102106
}
103107

104108
type InvoiceService interface {
105109
List(ctx context.Context, flt invoice.Filter) ([]invoice.Invoice, error)
110+
SyncWithProvider(ctx context.Context, customr customer.Customer) error
106111
DeleteByCustomer(ctx context.Context, customr customer.Customer) error
107112
}
108113

@@ -112,13 +117,18 @@ type CheckoutService interface {
112117
}
113118

114119
type CreditService interface {
120+
GetBalance(ctx context.Context, accountID string) (int64, error)
115121
DeleteByAccountID(ctx context.Context, accountID string) error
116122
}
117123

118124
type KycService interface {
119125
DeleteKyc(ctx context.Context, orgID string) error
120126
}
121127

128+
type PlanService interface {
129+
GetByID(ctx context.Context, id string) (plan.Plan, error)
130+
}
131+
122132
type Service struct {
123133
projService ProjectService
124134
orgService OrganizationService
@@ -137,6 +147,11 @@ type Service struct {
137147
checkoutService CheckoutService
138148
creditService CreditService
139149
kycService KycService
150+
planService PlanService
151+
// defaultPlan names the plan a subscription may still be on when the org
152+
// is deleted; subscriptions on any other plan block the delete until the
153+
// caller downgrades them
154+
defaultPlan string
140155
}
141156

142157
func NewCascadeDeleter(orgService OrganizationService, projService ProjectService,
@@ -148,7 +163,8 @@ func NewCascadeDeleter(orgService OrganizationService, projService ProjectServic
148163
serviceUserService ServiceUserService,
149164
customerService CustomerService, subService SubscriptionService,
150165
invoiceService InvoiceService, checkoutService CheckoutService,
151-
creditService CreditService, kycService KycService) *Service {
166+
creditService CreditService, kycService KycService,
167+
planService PlanService, defaultPlan string) *Service {
152168
return &Service{
153169
projService: projService,
154170
orgService: orgService,
@@ -167,6 +183,8 @@ func NewCascadeDeleter(orgService OrganizationService, projService ProjectServic
167183
checkoutService: checkoutService,
168184
creditService: creditService,
169185
kycService: kycService,
186+
planService: planService,
187+
defaultPlan: defaultPlan,
170188
}
171189
}
172190

@@ -217,9 +235,16 @@ func (d Service) DeleteGroup(ctx context.Context, id string) error {
217235
// leaves the org owned and the delete can simply be run again. Every step
218236
// treats already-deleted data as success for the same reason.
219237
func (d Service) DeleteOrganization(ctx context.Context, id string) error {
220-
// check if delete is allowed
221-
if err := d.canDelete(ctx, id); err != nil {
222-
return fmt.Errorf("%s: %w", err.Error(), ErrDeleteNotAllowed)
238+
// an org that is already gone has nothing left to check or tear down;
239+
// disabled orgs stay deletable
240+
if _, err := d.orgService.Get(ctx, id); err != nil && !errors.Is(err, organization.ErrDisabled) {
241+
return err
242+
}
243+
244+
// clear what we can and collect what still blocks the delete, before
245+
// touching any data
246+
if err := d.preflight(ctx, id); err != nil {
247+
return err
223248
}
224249

225250
// delete all billing accounts
@@ -364,6 +389,22 @@ func (d Service) DeleteCustomers(ctx context.Context, id string) error {
364389
slog.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.BillingCheckoutDeletedEvent, "checkout_id", ch.ID)
365390
}
366391
}
392+
// tokens still on the account are forfeited by this delete, so
393+
// record the amount before the transactions are removed
394+
balance, err := d.creditService.GetBalance(ctx, c.ID)
395+
if err != nil {
396+
return fmt.Errorf("failed to delete org while checking balance of billing account[%s]: %w", c.ID, err)
397+
}
398+
if balance > 0 {
399+
if err := auditLogger.LogWithAttrs(audit.BillingTokensForfeitedEvent, audit.Target{
400+
ID: c.ID,
401+
Type: "billing_account",
402+
}, map[string]string{
403+
"amount": strconv.FormatInt(balance, 10),
404+
}); err != nil {
405+
slog.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.BillingTokensForfeitedEvent, "customer_id", c.ID)
406+
}
407+
}
367408
if err := d.creditService.DeleteByAccountID(ctx, c.ID); err != nil {
368409
return fmt.Errorf("failed to delete org while deleting a billing account transactions[%s]: %w", c.ID, err)
369410
}
@@ -412,23 +453,157 @@ func (d Service) DeleteUser(ctx context.Context, userID string) error {
412453
return d.userService.Delete(ctx, userID)
413454
}
414455

415-
func (d Service) canDelete(ctx context.Context, id string) error {
416-
// check if any invoice is present for customer
456+
// preflight collects everything that blocks deleting the organization and
457+
// returns it all as one BlockedError, so the caller gets a full checklist
458+
// instead of discovering blockers one retry at a time.
459+
//
460+
// A running subscription on a paid plan blocks: the caller downgrades it to
461+
// the standard (default) plan first. A subscription on the standard plan
462+
// does not block — once nothing else does, preflight cancels it itself,
463+
// before any deletion starts. The cancel is immediate and invoices unbilled
464+
// usage on the spot, so the invoice check runs again after it — a final
465+
// invoice coming out of the cancellation still blocks the delete until it
466+
// is paid.
467+
//
468+
// Unused tokens do not block either: the delete forfeits them. The client
469+
// gets the caller's confirmation before sending the delete, and the
470+
// forfeited amount is written to an audit record during teardown.
471+
//
472+
// Accounts without a billing provider are only checked for token balances:
473+
// their subscription and invoice rows have nothing behind them the caller
474+
// could cancel or pay.
475+
func (d Service) preflight(ctx context.Context, id string) error {
417476
customers, err := d.customerService.List(ctx, customer.Filter{
418477
OrgID: id,
419478
})
420479
if err != nil {
421480
return err
422481
}
423482

483+
// resolve the one plan a running subscription may still be on; any other
484+
// active plan must be downgraded by the caller first
485+
standardPlanID, err := d.standardPlanID(ctx, customers)
486+
if err != nil {
487+
return err
488+
}
489+
490+
var blockers []Blocker
491+
for _, c := range customers {
492+
if !c.IsOffline() {
493+
subs, err := d.subService.List(ctx, subscription.Filter{CustomerID: c.ID})
494+
if err != nil {
495+
return fmt.Errorf("failed to check subscriptions for billing account[%s]: %w", c.ID, err)
496+
}
497+
for _, sub := range subs {
498+
if sub.IsActive() && sub.PlanID != standardPlanID {
499+
blockers = append(blockers, Blocker{
500+
Type: BlockerActiveSubscription,
501+
Subject: sub.ID,
502+
Message: fmt.Sprintf("subscription[%s] is %s on a paid plan: downgrade to the standard plan, then retry the delete", sub.ID, sub.State),
503+
})
504+
}
505+
}
506+
507+
bs, err := d.invoiceBlockers(ctx, c)
508+
if err != nil {
509+
return err
510+
}
511+
blockers = append(blockers, bs...)
512+
}
513+
514+
balance, err := d.creditService.GetBalance(ctx, c.ID)
515+
if err != nil {
516+
return fmt.Errorf("failed to check token balance of billing account[%s]: %w", c.ID, err)
517+
}
518+
if balance < 0 {
519+
blockers = append(blockers, Blocker{
520+
Type: BlockerNegativeTokenBalance,
521+
Subject: c.ID,
522+
Message: fmt.Sprintf("billing account[%s] owes %d tokens: contact support to settle the balance, then retry the delete", c.ID, -balance),
523+
})
524+
}
525+
}
526+
if len(blockers) > 0 {
527+
return &BlockedError{OrgID: id, Blockers: blockers}
528+
}
529+
530+
// nothing blocks the delete; the only subscriptions still running are on
531+
// the standard plan — cancel them and re-check the invoices the cancel
532+
// may have created. This happens only once every blocker is clear, so a
533+
// delete that stays blocked does not cost the caller their subscription
424534
for _, c := range customers {
425-
if invoices, err := d.invoiceService.List(ctx, invoice.Filter{CustomerID: c.ID}); err != nil {
426-
return fmt.Errorf("failed to check invoices for billing account[%s]: %w", c.ID, err)
427-
} else if len(invoices) > 0 {
428-
if DisableDeleteIfBilled {
429-
return fmt.Errorf("cannot delete organization with billing account[%s]", c.ID)
535+
if c.IsOffline() {
536+
continue
537+
}
538+
subs, err := d.subService.List(ctx, subscription.Filter{CustomerID: c.ID})
539+
if err != nil {
540+
return fmt.Errorf("failed to check subscriptions for billing account[%s]: %w", c.ID, err)
541+
}
542+
canceled := false
543+
for _, sub := range subs {
544+
if !sub.IsActive() {
545+
continue
546+
}
547+
if _, err := d.subService.Cancel(ctx, sub.ID, true); err != nil {
548+
return fmt.Errorf("failed to cancel subscription[%s] of billing account[%s]: %w", sub.ID, c.ID, err)
430549
}
550+
canceled = true
431551
}
552+
if canceled {
553+
bs, err := d.invoiceBlockers(ctx, c)
554+
if err != nil {
555+
return err
556+
}
557+
blockers = append(blockers, bs...)
558+
}
559+
}
560+
if len(blockers) > 0 {
561+
return &BlockedError{OrgID: id, Blockers: blockers}
432562
}
433563
return nil
434564
}
565+
566+
// standardPlanID resolves the configured default plan to its id. Without a
567+
// configured default plan every active subscription blocks the delete. The
568+
// lookup is skipped when no billing account talks to a provider.
569+
func (d Service) standardPlanID(ctx context.Context, customers []customer.Customer) (string, error) {
570+
if d.defaultPlan == "" {
571+
return "", nil
572+
}
573+
if !slices.ContainsFunc(customers, func(c customer.Customer) bool { return !c.IsOffline() }) {
574+
return "", nil
575+
}
576+
standardPlan, err := d.planService.GetByID(ctx, d.defaultPlan)
577+
if err != nil {
578+
return "", fmt.Errorf("failed to resolve the default plan[%s]: %w", d.defaultPlan, err)
579+
}
580+
return standardPlan.ID, nil
581+
}
582+
583+
// invoiceBlockers returns a blocker for every invoice of the account the
584+
// caller can still pay; paid, void, and draft invoices don't block. The
585+
// billing provider keeps its own permanent copy of every invoice, so
586+
// deleting our rows later loses nothing.
587+
func (d Service) invoiceBlockers(ctx context.Context, c customer.Customer) ([]Blocker, error) {
588+
// the local invoice rows sync from the provider on a timer, so pull
589+
// them fresh first: a just-paid invoice must not block, a just-created
590+
// one must
591+
if err := d.invoiceService.SyncWithProvider(ctx, c); err != nil {
592+
return nil, fmt.Errorf("failed to sync invoices for billing account[%s]: %w", c.ID, err)
593+
}
594+
invoices, err := d.invoiceService.List(ctx, invoice.Filter{CustomerID: c.ID, NonZeroOnly: true})
595+
if err != nil {
596+
return nil, fmt.Errorf("failed to check invoices for billing account[%s]: %w", c.ID, err)
597+
}
598+
var blockers []Blocker
599+
for _, inv := range invoices {
600+
if inv.State == invoice.OpenState || inv.State == invoice.UncollectibleState {
601+
blockers = append(blockers, Blocker{
602+
Type: BlockerUnpaidInvoice,
603+
Subject: inv.ID,
604+
Message: fmt.Sprintf("invoice[%s] is unpaid: pay it via its hosted payment page, then retry the delete", inv.ID),
605+
})
606+
}
607+
}
608+
return blockers, nil
609+
}

0 commit comments

Comments
 (0)