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-
4143type 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
100102type 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
104108type 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
114119type CreditService interface {
120+ GetBalance (ctx context.Context , accountID string ) (int64 , error )
115121 DeleteByAccountID (ctx context.Context , accountID string ) error
116122}
117123
118124type 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+
122132type 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
142157func 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.
219237func (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