Skip to content

Commit 969ef16

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: unpaid (open or uncollectible) invoices, and a negative token balance which support has to settle. When nothing blocks, running subscriptions 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 2f3076f commit 969ef16

8 files changed

Lines changed: 503 additions & 27 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 {

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: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,39 @@
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+
BlockerUnpaidInvoice = "UNPAID_INVOICE"
10+
BlockerNegativeTokenBalance = "NEGATIVE_TOKEN_BALANCE"
711
)
12+
13+
// Blocker is one reason an organization cannot be deleted right now. The
14+
// message names the fix: paying an invoice is something the caller can do
15+
// themselves, settling a token debt goes through support.
16+
type Blocker struct {
17+
// Type is one of the Blocker* constants.
18+
Type string
19+
// Subject is the id of the blocking entity, e.g. a subscription id.
20+
Subject string
21+
// Message says what blocks the delete and what to do about it.
22+
Message string
23+
}
24+
25+
// BlockedError carries every blocker the pre-flight check found, so the
26+
// caller gets one checklist instead of discovering blockers one retry at
27+
// a time.
28+
type BlockedError struct {
29+
OrgID string
30+
Blockers []Blocker
31+
}
32+
33+
func (e *BlockedError) Error() string {
34+
msgs := make([]string, 0, len(e.Blockers))
35+
for _, b := range e.Blockers {
36+
msgs = append(msgs, b.Message)
37+
}
38+
return "organization cannot be deleted yet: " + strings.Join(msgs, "; ")
39+
}

core/deleter/service.go

Lines changed: 134 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"log/slog"
8+
"strconv"
89

910
"github.com/raystack/frontier/core/audit"
1011

@@ -16,6 +17,8 @@ import (
1617

1718
"github.com/raystack/frontier/billing/customer"
1819

20+
"github.com/raystack/frontier/billing/subscription"
21+
1922
"github.com/raystack/frontier/core/organization"
2023

2124
"github.com/raystack/frontier/internal/bootstrap/schema"
@@ -34,10 +37,6 @@ import (
3437
"github.com/raystack/frontier/core/serviceuser"
3538
)
3639

37-
const (
38-
DisableDeleteIfBilled = true
39-
)
40-
4140
type ProjectService interface {
4241
List(ctx context.Context, flt project.Filter) ([]project.Project, error)
4342
DeleteModel(ctx context.Context, id string) error
@@ -98,11 +97,14 @@ type CustomerService interface {
9897
}
9998

10099
type SubscriptionService interface {
100+
List(ctx context.Context, filter subscription.Filter) ([]subscription.Subscription, error)
101+
Cancel(ctx context.Context, id string, immediate bool) (subscription.Subscription, error)
101102
DeleteByCustomer(ctx context.Context, customr customer.Customer) error
102103
}
103104

104105
type InvoiceService interface {
105106
List(ctx context.Context, flt invoice.Filter) ([]invoice.Invoice, error)
107+
SyncWithProvider(ctx context.Context, customr customer.Customer) error
106108
DeleteByCustomer(ctx context.Context, customr customer.Customer) error
107109
}
108110

@@ -112,6 +114,7 @@ type CheckoutService interface {
112114
}
113115

114116
type CreditService interface {
117+
GetBalance(ctx context.Context, accountID string) (int64, error)
115118
DeleteByAccountID(ctx context.Context, accountID string) error
116119
}
117120

@@ -217,9 +220,16 @@ func (d Service) DeleteGroup(ctx context.Context, id string) error {
217220
// leaves the org owned and the delete can simply be run again. Every step
218221
// treats already-deleted data as success for the same reason.
219222
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)
223+
// an org that is already gone has nothing left to check or tear down;
224+
// disabled orgs stay deletable
225+
if _, err := d.orgService.Get(ctx, id); err != nil && !errors.Is(err, organization.ErrDisabled) {
226+
return err
227+
}
228+
229+
// clear what we can and collect what still blocks the delete, before
230+
// touching any data
231+
if err := d.preflight(ctx, id); err != nil {
232+
return err
223233
}
224234

225235
// delete all billing accounts
@@ -364,6 +374,22 @@ func (d Service) DeleteCustomers(ctx context.Context, id string) error {
364374
slog.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.BillingCheckoutDeletedEvent, "checkout_id", ch.ID)
365375
}
366376
}
377+
// tokens still on the account are forfeited by this delete, so
378+
// record the amount before the transactions are removed
379+
balance, err := d.creditService.GetBalance(ctx, c.ID)
380+
if err != nil {
381+
return fmt.Errorf("failed to delete org while checking balance of billing account[%s]: %w", c.ID, err)
382+
}
383+
if balance > 0 {
384+
if err := auditLogger.LogWithAttrs(audit.BillingTokensForfeitedEvent, audit.Target{
385+
ID: c.ID,
386+
Type: "billing_account",
387+
}, map[string]string{
388+
"amount": strconv.FormatInt(balance, 10),
389+
}); err != nil {
390+
slog.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.BillingTokensForfeitedEvent, "customer_id", c.ID)
391+
}
392+
}
367393
if err := d.creditService.DeleteByAccountID(ctx, c.ID); err != nil {
368394
return fmt.Errorf("failed to delete org while deleting a billing account transactions[%s]: %w", c.ID, err)
369395
}
@@ -412,23 +438,117 @@ func (d Service) DeleteUser(ctx context.Context, userID string) error {
412438
return d.userService.Delete(ctx, userID)
413439
}
414440

415-
func (d Service) canDelete(ctx context.Context, id string) error {
416-
// check if any invoice is present for customer
441+
// preflight collects everything that blocks deleting the organization and
442+
// returns it all as one BlockedError, so the caller gets a full checklist
443+
// instead of discovering blockers one retry at a time.
444+
//
445+
// Running subscriptions do not block: once nothing else does, preflight
446+
// cancels them itself, before any deletion starts. The cancel is immediate
447+
// and invoices unbilled usage on the spot, so the invoice check runs again
448+
// after it — a final invoice coming out of the cancellation still blocks
449+
// the delete until it is paid.
450+
//
451+
// Unused tokens do not block either: the delete forfeits them. The client
452+
// gets the caller's confirmation before sending the delete, and the
453+
// forfeited amount is written to an audit record during teardown.
454+
//
455+
// Accounts without a billing provider are only checked for token balances:
456+
// their subscription and invoice rows have nothing behind them the caller
457+
// could cancel or pay.
458+
func (d Service) preflight(ctx context.Context, id string) error {
417459
customers, err := d.customerService.List(ctx, customer.Filter{
418460
OrgID: id,
419461
})
420462
if err != nil {
421463
return err
422464
}
423465

466+
var blockers []Blocker
424467
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)
468+
if !c.IsOffline() {
469+
bs, err := d.invoiceBlockers(ctx, c)
470+
if err != nil {
471+
return err
430472
}
473+
blockers = append(blockers, bs...)
474+
}
475+
476+
balance, err := d.creditService.GetBalance(ctx, c.ID)
477+
if err != nil {
478+
return fmt.Errorf("failed to check token balance of billing account[%s]: %w", c.ID, err)
431479
}
480+
if balance < 0 {
481+
blockers = append(blockers, Blocker{
482+
Type: BlockerNegativeTokenBalance,
483+
Subject: c.ID,
484+
Message: fmt.Sprintf("billing account[%s] owes %d tokens: contact support to settle the balance, then retry the delete", c.ID, -balance),
485+
})
486+
}
487+
}
488+
if len(blockers) > 0 {
489+
return &BlockedError{OrgID: id, Blockers: blockers}
490+
}
491+
492+
// nothing blocks the delete so far; cancel running subscriptions and
493+
// re-check the invoices the cancellation may have created. Subscriptions
494+
// are only canceled once every other blocker is clear, so a delete that
495+
// stays blocked does not cost the caller their subscription
496+
for _, c := range customers {
497+
if c.IsOffline() {
498+
continue
499+
}
500+
subs, err := d.subService.List(ctx, subscription.Filter{CustomerID: c.ID})
501+
if err != nil {
502+
return fmt.Errorf("failed to check subscriptions for billing account[%s]: %w", c.ID, err)
503+
}
504+
canceled := false
505+
for _, sub := range subs {
506+
if !sub.IsActive() {
507+
continue
508+
}
509+
if _, err := d.subService.Cancel(ctx, sub.ID, true); err != nil {
510+
return fmt.Errorf("failed to cancel subscription[%s] of billing account[%s]: %w", sub.ID, c.ID, err)
511+
}
512+
canceled = true
513+
}
514+
if canceled {
515+
bs, err := d.invoiceBlockers(ctx, c)
516+
if err != nil {
517+
return err
518+
}
519+
blockers = append(blockers, bs...)
520+
}
521+
}
522+
if len(blockers) > 0 {
523+
return &BlockedError{OrgID: id, Blockers: blockers}
432524
}
433525
return nil
434526
}
527+
528+
// invoiceBlockers returns a blocker for every invoice of the account the
529+
// caller can still pay; paid, void, and draft invoices don't block. The
530+
// billing provider keeps its own permanent copy of every invoice, so
531+
// deleting our rows later loses nothing.
532+
func (d Service) invoiceBlockers(ctx context.Context, c customer.Customer) ([]Blocker, error) {
533+
// the local invoice rows sync from the provider on a timer, so pull
534+
// them fresh first: a just-paid invoice must not block, a just-created
535+
// one must
536+
if err := d.invoiceService.SyncWithProvider(ctx, c); err != nil {
537+
return nil, fmt.Errorf("failed to sync invoices for billing account[%s]: %w", c.ID, err)
538+
}
539+
invoices, err := d.invoiceService.List(ctx, invoice.Filter{CustomerID: c.ID, NonZeroOnly: true})
540+
if err != nil {
541+
return nil, fmt.Errorf("failed to check invoices for billing account[%s]: %w", c.ID, err)
542+
}
543+
var blockers []Blocker
544+
for _, inv := range invoices {
545+
if inv.State == invoice.OpenState || inv.State == invoice.UncollectibleState {
546+
blockers = append(blockers, Blocker{
547+
Type: BlockerUnpaidInvoice,
548+
Subject: inv.ID,
549+
Message: fmt.Sprintf("invoice[%s] is unpaid: pay it via its hosted payment page, then retry the delete", inv.ID),
550+
})
551+
}
552+
}
553+
return blockers, nil
554+
}

0 commit comments

Comments
 (0)