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-
4140type 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
10099type 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
104105type 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
114116type 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.
219222func (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