Skip to content

Commit f8575b8

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 338eeb6 commit f8575b8

9 files changed

Lines changed: 610 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: 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+
}

core/deleter/service.go

Lines changed: 197 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,13 @@ type Service struct {
137147
checkoutService CheckoutService
138148
creditService CreditService
139149
kycService KycService
150+
planService PlanService
151+
// defaultPlan is the billing.customer.default_plan config value: the
152+
// plan orgs start on. It holds the plan's name or its uuid, the lookup
153+
// resolves both. A subscription on this plan may still be running when
154+
// the org is deleted; any other plan blocks the delete until the
155+
// caller downgrades it
156+
defaultPlan string
140157
}
141158

142159
func NewCascadeDeleter(orgService OrganizationService, projService ProjectService,
@@ -148,7 +165,8 @@ func NewCascadeDeleter(orgService OrganizationService, projService ProjectServic
148165
serviceUserService ServiceUserService,
149166
customerService CustomerService, subService SubscriptionService,
150167
invoiceService InvoiceService, checkoutService CheckoutService,
151-
creditService CreditService, kycService KycService) *Service {
168+
creditService CreditService, kycService KycService,
169+
planService PlanService, defaultPlan string) *Service {
152170
return &Service{
153171
projService: projService,
154172
orgService: orgService,
@@ -167,6 +185,8 @@ func NewCascadeDeleter(orgService OrganizationService, projService ProjectServic
167185
checkoutService: checkoutService,
168186
creditService: creditService,
169187
kycService: kycService,
188+
planService: planService,
189+
defaultPlan: defaultPlan,
170190
}
171191
}
172192

@@ -217,9 +237,16 @@ func (d Service) DeleteGroup(ctx context.Context, id string) error {
217237
// leaves the org owned and the delete can simply be run again. Every step
218238
// treats already-deleted data as success for the same reason.
219239
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)
240+
// an org that is already gone has nothing left to check or tear down;
241+
// disabled orgs stay deletable
242+
if _, err := d.orgService.Get(ctx, id); err != nil && !errors.Is(err, organization.ErrDisabled) {
243+
return err
244+
}
245+
246+
// clear what we can and collect what still blocks the delete, before
247+
// touching any data
248+
if err := d.ensureDeletable(ctx, id); err != nil {
249+
return err
223250
}
224251

225252
// delete all billing accounts
@@ -364,6 +391,22 @@ func (d Service) DeleteCustomers(ctx context.Context, id string) error {
364391
slog.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.BillingCheckoutDeletedEvent, "checkout_id", ch.ID)
365392
}
366393
}
394+
// tokens still on the account are forfeited by this delete, so
395+
// record the amount before the transactions are removed
396+
balance, err := d.creditService.GetBalance(ctx, c.ID)
397+
if err != nil {
398+
return fmt.Errorf("failed to delete org while checking balance of billing account[%s]: %w", c.ID, err)
399+
}
400+
if balance > 0 {
401+
if err := auditLogger.LogWithAttrs(audit.BillingTokensForfeitedEvent, audit.Target{
402+
ID: c.ID,
403+
Type: "billing_account",
404+
}, map[string]string{
405+
"amount": strconv.FormatInt(balance, 10),
406+
}); err != nil {
407+
slog.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.BillingTokensForfeitedEvent, "customer_id", c.ID)
408+
}
409+
}
367410
if err := d.creditService.DeleteByAccountID(ctx, c.ID); err != nil {
368411
return fmt.Errorf("failed to delete org while deleting a billing account transactions[%s]: %w", c.ID, err)
369412
}
@@ -412,23 +455,162 @@ func (d Service) DeleteUser(ctx context.Context, userID string) error {
412455
return d.userService.Delete(ctx, userID)
413456
}
414457

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

485+
// resolve the one plan a running subscription may still be on; any other
486+
// active plan must be downgraded by the caller first
487+
standardPlanID, err := d.standardPlanID(ctx, customers)
488+
if err != nil {
489+
return err
490+
}
491+
492+
var blockers []Blocker
493+
for _, c := range customers {
494+
if !c.IsOffline() {
495+
subs, err := d.subService.List(ctx, subscription.Filter{CustomerID: c.ID})
496+
if err != nil {
497+
return fmt.Errorf("failed to check subscriptions for billing account[%s]: %w", c.ID, err)
498+
}
499+
for _, sub := range subs {
500+
if sub.IsActive() && sub.PlanID != standardPlanID {
501+
blockers = append(blockers, Blocker{
502+
Type: BlockerActiveSubscription,
503+
Subject: sub.ID,
504+
Message: fmt.Sprintf("subscription[%s] is %s on a paid plan: downgrade to the standard plan, then retry the delete", sub.ID, sub.State),
505+
})
506+
}
507+
}
508+
509+
bs, err := d.invoiceBlockers(ctx, c)
510+
if err != nil {
511+
return err
512+
}
513+
blockers = append(blockers, bs...)
514+
}
515+
516+
balance, err := d.creditService.GetBalance(ctx, c.ID)
517+
if err != nil {
518+
return fmt.Errorf("failed to check token balance of billing account[%s]: %w", c.ID, err)
519+
}
520+
// the balance goes below zero when the account has an overdraft
521+
// floor (credit_min under zero, the postpaid setup) and tokens were
522+
// spent on credit. That debt is money owed, so it must be settled
523+
// before the org can go
524+
if balance < 0 {
525+
blockers = append(blockers, Blocker{
526+
Type: BlockerNegativeTokenBalance,
527+
Subject: c.ID,
528+
Message: fmt.Sprintf("billing account[%s] owes %d tokens: contact support to settle the balance, then retry the delete", c.ID, -balance),
529+
})
530+
}
531+
}
532+
if len(blockers) > 0 {
533+
return &BlockedError{OrgID: id, Blockers: blockers}
534+
}
535+
536+
// no blockers were found, so the delete will happen. The only
537+
// subscriptions still running are on the standard plan: cancel them,
538+
// then check the invoices again because the cancel may have created a
539+
// final one. This runs only after every blocker is clear, so a blocked
540+
// delete never costs the caller their subscription
424541
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)
542+
if c.IsOffline() {
543+
continue
544+
}
545+
subs, err := d.subService.List(ctx, subscription.Filter{CustomerID: c.ID})
546+
if err != nil {
547+
return fmt.Errorf("failed to check subscriptions for billing account[%s]: %w", c.ID, err)
548+
}
549+
canceled := false
550+
for _, sub := range subs {
551+
if !sub.IsActive() {
552+
continue
553+
}
554+
if _, err := d.subService.Cancel(ctx, sub.ID, true); err != nil {
555+
return fmt.Errorf("failed to cancel subscription[%s] of billing account[%s]: %w", sub.ID, c.ID, err)
430556
}
557+
canceled = true
431558
}
559+
if canceled {
560+
bs, err := d.invoiceBlockers(ctx, c)
561+
if err != nil {
562+
return err
563+
}
564+
blockers = append(blockers, bs...)
565+
}
566+
}
567+
if len(blockers) > 0 {
568+
return &BlockedError{OrgID: id, Blockers: blockers}
432569
}
433570
return nil
434571
}
572+
573+
// standardPlanID resolves the configured default plan to its id. Without a
574+
// configured default plan every active subscription blocks the delete. The
575+
// lookup is skipped when no billing account talks to a provider.
576+
func (d Service) standardPlanID(ctx context.Context, customers []customer.Customer) (string, error) {
577+
if d.defaultPlan == "" {
578+
return "", nil
579+
}
580+
if !slices.ContainsFunc(customers, func(c customer.Customer) bool { return !c.IsOffline() }) {
581+
return "", nil
582+
}
583+
standardPlan, err := d.planService.GetByID(ctx, d.defaultPlan)
584+
if err != nil {
585+
return "", fmt.Errorf("failed to resolve the default plan[%s]: %w", d.defaultPlan, err)
586+
}
587+
return standardPlan.ID, nil
588+
}
589+
590+
// invoiceBlockers returns a blocker for every invoice of the account the
591+
// caller can still pay; paid, void, and draft invoices don't block. The
592+
// billing provider keeps its own permanent copy of every invoice, so
593+
// deleting our rows later loses nothing.
594+
func (d Service) invoiceBlockers(ctx context.Context, c customer.Customer) ([]Blocker, error) {
595+
// the local invoice rows sync from the provider on a timer, so pull
596+
// them fresh first: a just-paid invoice must not block, a just-created
597+
// one must
598+
if err := d.invoiceService.SyncWithProvider(ctx, c); err != nil {
599+
return nil, fmt.Errorf("failed to sync invoices for billing account[%s]: %w", c.ID, err)
600+
}
601+
invoices, err := d.invoiceService.List(ctx, invoice.Filter{CustomerID: c.ID, NonZeroOnly: true})
602+
if err != nil {
603+
return nil, fmt.Errorf("failed to check invoices for billing account[%s]: %w", c.ID, err)
604+
}
605+
var blockers []Blocker
606+
for _, inv := range invoices {
607+
if inv.State == invoice.OpenState || inv.State == invoice.UncollectibleState {
608+
blockers = append(blockers, Blocker{
609+
Type: BlockerUnpaidInvoice,
610+
Subject: inv.ID,
611+
Message: fmt.Sprintf("invoice[%s] is unpaid: pay it via its hosted payment page, then retry the delete", inv.ID),
612+
})
613+
}
614+
}
615+
return blockers, nil
616+
}

0 commit comments

Comments
 (0)