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,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
142159func 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.
219239func (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