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,161 @@ 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 caller downgrades it to
463+ // the standard (default) plan first. A subscription on the standard plan
464+ // does not block — once nothing else does, ensureDeletable cancels it itself,
465+ // before any deletion starts. The cancel is immediate and invoices unbilled
466+ // usage on the spot, so the invoice check runs again after it — a final
467+ // invoice coming out of the cancellation still blocks the delete until it
468+ // 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+ // nothing blocks the delete; the only subscriptions still running are on
537+ // the standard plan — cancel them and re-check the invoices the cancel
538+ // may have created. This happens only once every blocker is clear, so a
539+ // delete that stays blocked does not cost the caller their subscription
424540 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 )
541+ if c .IsOffline () {
542+ continue
543+ }
544+ subs , err := d .subService .List (ctx , subscription.Filter {CustomerID : c .ID })
545+ if err != nil {
546+ return fmt .Errorf ("failed to check subscriptions for billing account[%s]: %w" , c .ID , err )
547+ }
548+ canceled := false
549+ for _ , sub := range subs {
550+ if ! sub .IsActive () {
551+ continue
552+ }
553+ if _ , err := d .subService .Cancel (ctx , sub .ID , true ); err != nil {
554+ return fmt .Errorf ("failed to cancel subscription[%s] of billing account[%s]: %w" , sub .ID , c .ID , err )
430555 }
556+ canceled = true
431557 }
558+ if canceled {
559+ bs , err := d .invoiceBlockers (ctx , c )
560+ if err != nil {
561+ return err
562+ }
563+ blockers = append (blockers , bs ... )
564+ }
565+ }
566+ if len (blockers ) > 0 {
567+ return & BlockedError {OrgID : id , Blockers : blockers }
432568 }
433569 return nil
434570}
571+
572+ // standardPlanID resolves the configured default plan to its id. Without a
573+ // configured default plan every active subscription blocks the delete. The
574+ // lookup is skipped when no billing account talks to a provider.
575+ func (d Service ) standardPlanID (ctx context.Context , customers []customer.Customer ) (string , error ) {
576+ if d .defaultPlan == "" {
577+ return "" , nil
578+ }
579+ if ! slices .ContainsFunc (customers , func (c customer.Customer ) bool { return ! c .IsOffline () }) {
580+ return "" , nil
581+ }
582+ standardPlan , err := d .planService .GetByID (ctx , d .defaultPlan )
583+ if err != nil {
584+ return "" , fmt .Errorf ("failed to resolve the default plan[%s]: %w" , d .defaultPlan , err )
585+ }
586+ return standardPlan .ID , nil
587+ }
588+
589+ // invoiceBlockers returns a blocker for every invoice of the account the
590+ // caller can still pay; paid, void, and draft invoices don't block. The
591+ // billing provider keeps its own permanent copy of every invoice, so
592+ // deleting our rows later loses nothing.
593+ func (d Service ) invoiceBlockers (ctx context.Context , c customer.Customer ) ([]Blocker , error ) {
594+ // the local invoice rows sync from the provider on a timer, so pull
595+ // them fresh first: a just-paid invoice must not block, a just-created
596+ // one must
597+ if err := d .invoiceService .SyncWithProvider (ctx , c ); err != nil {
598+ return nil , fmt .Errorf ("failed to sync invoices for billing account[%s]: %w" , c .ID , err )
599+ }
600+ invoices , err := d .invoiceService .List (ctx , invoice.Filter {CustomerID : c .ID , NonZeroOnly : true })
601+ if err != nil {
602+ return nil , fmt .Errorf ("failed to check invoices for billing account[%s]: %w" , c .ID , err )
603+ }
604+ var blockers []Blocker
605+ for _ , inv := range invoices {
606+ if inv .State == invoice .OpenState || inv .State == invoice .UncollectibleState {
607+ blockers = append (blockers , Blocker {
608+ Type : BlockerUnpaidInvoice ,
609+ Subject : inv .ID ,
610+ Message : fmt .Sprintf ("invoice[%s] is unpaid: pay it via its hosted payment page, then retry the delete" , inv .ID ),
611+ })
612+ }
613+ }
614+ return blockers , nil
615+ }
0 commit comments