Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions billing/invoice/invoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ const (
DraftState State = "draft"
OpenState State = "open"
PaidState State = "paid"
// UncollectibleState marks an invoice the provider has written off; it
// can still be paid.
UncollectibleState State = "uncollectible"
)

type Invoice struct {
Expand Down
2 changes: 1 addition & 1 deletion cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ func buildAPIDependencies(
cascadeDeleter := deleter.NewCascadeDeleter(organizationService, projectService, resourceService,
groupService, membershipService, policyService, roleService, invitationService, userService, userPATService,
serviceUserService, customerService, subscriptionService, invoiceService, checkoutService,
creditService, orgKycService,
creditService, orgKycService, planService, cfg.Billing.AccountConfig.DefaultPlan,
)

// we should default it with a stdout logger repository as postgres can start to bloat really fast
Expand Down
2 changes: 2 additions & 0 deletions core/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ const (

BillingAccountDetailsUpdatedEvent EventName = "app.billing.account.details.updated"
BillingCheckoutDeletedEvent EventName = "app.billing.checkout.deleted"
BillingTokensForfeitedEvent EventName = "app.billing.tokens.forfeited"
)

var systemEvents = []EventName{
Expand All @@ -113,6 +114,7 @@ var systemEvents = []EventName{
OrgDeletedEvent,
OrgDisabledEvent,
BillingCheckoutDeletedEvent,
BillingTokensForfeitedEvent,
}

func IsSystemEvent(event EventName) bool {
Expand Down
40 changes: 37 additions & 3 deletions core/deleter/deleter.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,41 @@
package deleter

import "fmt"
import "strings"

var (
ErrDeleteNotAllowed = fmt.Errorf("deletion not allowed for billed accounts")
// Blocker types returned by the org delete pre-flight check. They are
// machine-readable and end up as PreconditionFailure violation types on
// the API error, so clients can branch on them.
const (
BlockerActiveSubscription = "ACTIVE_SUBSCRIPTION"
BlockerUnpaidInvoice = "UNPAID_INVOICE"
BlockerNegativeTokenBalance = "NEGATIVE_TOKEN_BALANCE"
)

// Blocker is one reason an organization cannot be deleted right now. The
// message names the fix: downgrading a paid subscription and paying an
// invoice are things the caller can do themselves, settling a token debt
// goes through support.
type Blocker struct {
// Type is one of the Blocker* constants.
Type string
// Subject is the id of the blocking entity, e.g. a subscription id.
Subject string
// Message says what blocks the delete and what to do about it.
Message string
}

// BlockedError carries every blocker the pre-flight check found, so the
// caller gets one checklist instead of discovering blockers one retry at
// a time.
type BlockedError struct {
OrgID string
Blockers []Blocker
}

func (e *BlockedError) Error() string {
msgs := make([]string, 0, len(e.Blockers))
for _, b := range e.Blockers {
msgs = append(msgs, b.Message)
}
return "organization cannot be deleted yet: " + strings.Join(msgs, "; ")
}
205 changes: 190 additions & 15 deletions core/deleter/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"log/slog"
"slices"
"strconv"

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

Expand All @@ -16,6 +18,10 @@ import (

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

"github.com/raystack/frontier/billing/plan"

"github.com/raystack/frontier/billing/subscription"

"github.com/raystack/frontier/core/organization"

"github.com/raystack/frontier/internal/bootstrap/schema"
Expand All @@ -34,10 +40,6 @@ import (
"github.com/raystack/frontier/core/serviceuser"
)

const (
DisableDeleteIfBilled = true
)

type ProjectService interface {
List(ctx context.Context, flt project.Filter) ([]project.Project, error)
DeleteModel(ctx context.Context, id string) error
Expand Down Expand Up @@ -98,11 +100,14 @@ type CustomerService interface {
}

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

type InvoiceService interface {
List(ctx context.Context, flt invoice.Filter) ([]invoice.Invoice, error)
SyncWithProvider(ctx context.Context, customr customer.Customer) error
DeleteByCustomer(ctx context.Context, customr customer.Customer) error
}

Expand All @@ -112,13 +117,18 @@ type CheckoutService interface {
}

type CreditService interface {
GetBalance(ctx context.Context, accountID string) (int64, error)
DeleteByAccountID(ctx context.Context, accountID string) error
}

type KycService interface {
DeleteKyc(ctx context.Context, orgID string) error
}

type PlanService interface {
GetByID(ctx context.Context, id string) (plan.Plan, error)
}

type Service struct {
projService ProjectService
orgService OrganizationService
Expand All @@ -137,6 +147,11 @@ type Service struct {
checkoutService CheckoutService
creditService CreditService
kycService KycService
planService PlanService
// defaultPlan names the plan a subscription may still be on when the org
// is deleted; subscriptions on any other plan block the delete until the
// caller downgrades them
defaultPlan string
}

func NewCascadeDeleter(orgService OrganizationService, projService ProjectService,
Expand All @@ -148,7 +163,8 @@ func NewCascadeDeleter(orgService OrganizationService, projService ProjectServic
serviceUserService ServiceUserService,
customerService CustomerService, subService SubscriptionService,
invoiceService InvoiceService, checkoutService CheckoutService,
creditService CreditService, kycService KycService) *Service {
creditService CreditService, kycService KycService,
planService PlanService, defaultPlan string) *Service {
return &Service{
projService: projService,
orgService: orgService,
Expand All @@ -167,6 +183,8 @@ func NewCascadeDeleter(orgService OrganizationService, projService ProjectServic
checkoutService: checkoutService,
creditService: creditService,
kycService: kycService,
planService: planService,
defaultPlan: defaultPlan,
}
}

Expand Down Expand Up @@ -217,9 +235,16 @@ func (d Service) DeleteGroup(ctx context.Context, id string) error {
// leaves the org owned and the delete can simply be run again. Every step
// treats already-deleted data as success for the same reason.
func (d Service) DeleteOrganization(ctx context.Context, id string) error {
// check if delete is allowed
if err := d.canDelete(ctx, id); err != nil {
return fmt.Errorf("%s: %w", err.Error(), ErrDeleteNotAllowed)
// an org that is already gone has nothing left to check or tear down;
// disabled orgs stay deletable
if _, err := d.orgService.Get(ctx, id); err != nil && !errors.Is(err, organization.ErrDisabled) {
return err
}

// clear what we can and collect what still blocks the delete, before
// touching any data
if err := d.preflight(ctx, id); err != nil {
return err
}

// delete all billing accounts
Expand Down Expand Up @@ -364,6 +389,22 @@ func (d Service) DeleteCustomers(ctx context.Context, id string) error {
slog.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.BillingCheckoutDeletedEvent, "checkout_id", ch.ID)
}
}
// tokens still on the account are forfeited by this delete, so
// record the amount before the transactions are removed
balance, err := d.creditService.GetBalance(ctx, c.ID)
if err != nil {
return fmt.Errorf("failed to delete org while checking balance of billing account[%s]: %w", c.ID, err)
}
if balance > 0 {
if err := auditLogger.LogWithAttrs(audit.BillingTokensForfeitedEvent, audit.Target{
ID: c.ID,
Type: "billing_account",
}, map[string]string{
"amount": strconv.FormatInt(balance, 10),
}); err != nil {
slog.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.BillingTokensForfeitedEvent, "customer_id", c.ID)
}
}
if err := d.creditService.DeleteByAccountID(ctx, c.ID); err != nil {
return fmt.Errorf("failed to delete org while deleting a billing account transactions[%s]: %w", c.ID, err)
}
Expand Down Expand Up @@ -412,23 +453,157 @@ func (d Service) DeleteUser(ctx context.Context, userID string) error {
return d.userService.Delete(ctx, userID)
}

func (d Service) canDelete(ctx context.Context, id string) error {
// check if any invoice is present for customer
// preflight collects everything that blocks deleting the organization and
// returns it all as one BlockedError, so the caller gets a full checklist
// instead of discovering blockers one retry at a time.
//
// A running subscription on a paid plan blocks: the caller downgrades it to
// the standard (default) plan first. A subscription on the standard plan
// does not block — once nothing else does, preflight cancels it itself,
// before any deletion starts. The cancel is immediate and invoices unbilled
// usage on the spot, so the invoice check runs again after it — a final
// invoice coming out of the cancellation still blocks the delete until it
// is paid.
//
// Unused tokens do not block either: the delete forfeits them. The client
// gets the caller's confirmation before sending the delete, and the
// forfeited amount is written to an audit record during teardown.
//
// Accounts without a billing provider are only checked for token balances:
// their subscription and invoice rows have nothing behind them the caller
// could cancel or pay.
func (d Service) preflight(ctx context.Context, id string) error {
customers, err := d.customerService.List(ctx, customer.Filter{
OrgID: id,
})
if err != nil {
return err
}

// resolve the one plan a running subscription may still be on; any other
// active plan must be downgraded by the caller first
standardPlanID, err := d.standardPlanID(ctx, customers)
if err != nil {
return err
}

var blockers []Blocker
for _, c := range customers {
if !c.IsOffline() {
subs, err := d.subService.List(ctx, subscription.Filter{CustomerID: c.ID})
if err != nil {
return fmt.Errorf("failed to check subscriptions for billing account[%s]: %w", c.ID, err)
}
for _, sub := range subs {
if sub.IsActive() && sub.PlanID != standardPlanID {
blockers = append(blockers, Blocker{
Type: BlockerActiveSubscription,
Subject: sub.ID,
Message: fmt.Sprintf("subscription[%s] is %s on a paid plan: downgrade to the standard plan, then retry the delete", sub.ID, sub.State),
})
}
}

bs, err := d.invoiceBlockers(ctx, c)
if err != nil {
return err
}
blockers = append(blockers, bs...)
}

balance, err := d.creditService.GetBalance(ctx, c.ID)
if err != nil {
return fmt.Errorf("failed to check token balance of billing account[%s]: %w", c.ID, err)
}
if balance < 0 {
blockers = append(blockers, Blocker{
Type: BlockerNegativeTokenBalance,
Subject: c.ID,
Message: fmt.Sprintf("billing account[%s] owes %d tokens: contact support to settle the balance, then retry the delete", c.ID, -balance),
})
}
}
if len(blockers) > 0 {
return &BlockedError{OrgID: id, Blockers: blockers}
}

// nothing blocks the delete; the only subscriptions still running are on
// the standard plan — cancel them and re-check the invoices the cancel
// may have created. This happens only once every blocker is clear, so a
// delete that stays blocked does not cost the caller their subscription
for _, c := range customers {
if invoices, err := d.invoiceService.List(ctx, invoice.Filter{CustomerID: c.ID}); err != nil {
return fmt.Errorf("failed to check invoices for billing account[%s]: %w", c.ID, err)
} else if len(invoices) > 0 {
if DisableDeleteIfBilled {
return fmt.Errorf("cannot delete organization with billing account[%s]", c.ID)
if c.IsOffline() {
continue
}
subs, err := d.subService.List(ctx, subscription.Filter{CustomerID: c.ID})
if err != nil {
return fmt.Errorf("failed to check subscriptions for billing account[%s]: %w", c.ID, err)
}
canceled := false
for _, sub := range subs {
if !sub.IsActive() {
continue
}
if _, err := d.subService.Cancel(ctx, sub.ID, true); err != nil {
return fmt.Errorf("failed to cancel subscription[%s] of billing account[%s]: %w", sub.ID, c.ID, err)
}
canceled = true
}
if canceled {
bs, err := d.invoiceBlockers(ctx, c)
if err != nil {
return err
}
blockers = append(blockers, bs...)
}
}
if len(blockers) > 0 {
return &BlockedError{OrgID: id, Blockers: blockers}
}
return nil
}

// standardPlanID resolves the configured default plan to its id. Without a
// configured default plan every active subscription blocks the delete. The
// lookup is skipped when no billing account talks to a provider.
func (d Service) standardPlanID(ctx context.Context, customers []customer.Customer) (string, error) {
if d.defaultPlan == "" {
return "", nil
}
if !slices.ContainsFunc(customers, func(c customer.Customer) bool { return !c.IsOffline() }) {
return "", nil
}
standardPlan, err := d.planService.GetByID(ctx, d.defaultPlan)
if err != nil {
return "", fmt.Errorf("failed to resolve the default plan[%s]: %w", d.defaultPlan, err)
}
return standardPlan.ID, nil
}

// invoiceBlockers returns a blocker for every invoice of the account the
// caller can still pay; paid, void, and draft invoices don't block. The
// billing provider keeps its own permanent copy of every invoice, so
// deleting our rows later loses nothing.
func (d Service) invoiceBlockers(ctx context.Context, c customer.Customer) ([]Blocker, error) {
// the local invoice rows sync from the provider on a timer, so pull
// them fresh first: a just-paid invoice must not block, a just-created
// one must
if err := d.invoiceService.SyncWithProvider(ctx, c); err != nil {
return nil, fmt.Errorf("failed to sync invoices for billing account[%s]: %w", c.ID, err)
}
invoices, err := d.invoiceService.List(ctx, invoice.Filter{CustomerID: c.ID, NonZeroOnly: true})
if err != nil {
return nil, fmt.Errorf("failed to check invoices for billing account[%s]: %w", c.ID, err)
}
var blockers []Blocker
for _, inv := range invoices {
if inv.State == invoice.OpenState || inv.State == invoice.UncollectibleState {
blockers = append(blockers, Blocker{
Type: BlockerUnpaidInvoice,
Subject: inv.ID,
Message: fmt.Sprintf("invoice[%s] is unpaid: pay it via its hosted payment page, then retry the delete", inv.ID),
})
}
}
return blockers, nil
}
Loading
Loading