Skip to content
Draft
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
11 changes: 11 additions & 0 deletions core/deleter/deleter.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,23 @@ const (
BlockerNegativeTokenBalance = "NEGATIVE_TOKEN_BALANCE"
)

// Kinds of entity a Blocker's Subject id refers to. They match the naming
// the audit records use for billing entities.
const (
SubjectSubscription = "billing_subscription"
SubjectInvoice = "billing_invoice"
SubjectBillingAccount = "billing_account"
)

// Blocker is one reason an organization cannot be deleted right now.
type Blocker struct {
// Type is one of the Blocker* constants.
Type string
// Subject is the id of the entity behind the reason.
Subject string
// SubjectType is one of the Subject* constants and says what kind of
// entity the Subject id refers to.
SubjectType string
// Message says what blocks the delete and how to clear it.
Message string
}
Expand Down
144 changes: 97 additions & 47 deletions core/deleter/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -517,41 +517,10 @@ func (d Service) ensureDeletable(ctx context.Context, id string) error {
return err
}

// each plan resolves at most once per call, and only when a running
// subscription actually references it
paidPlans := map[string]bool{}

var blockers []Blocker
for _, c := range customers {
if !c.IsOffline() {
bs, err := d.subscriptionBlockers(ctx, c, paidPlans)
if err != nil {
return err
}
blockers = append(blockers, bs...)

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)
}
// the balance goes below zero when the account has an overdraft
// floor (credit_min under zero, the postpaid setup) and tokens were
// spent on credit. That debt is money owed, so it must be settled
// before the org can go
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),
})
}
// the delete is about to happen, so judge invoices on fresh provider data
blockers, err := d.collectBlockers(ctx, customers, true)
if err != nil {
return err
}
if len(blockers) > 0 {
return &BlockedError{OrgID: id, Blockers: blockers}
Expand All @@ -563,6 +532,7 @@ func (d Service) ensureDeletable(ctx context.Context, id string) error {
// runs only after every blocker is clear and never touches a paid
// subscription, so a blocked delete never costs the caller a plan they
// pay for
paidPlans := map[string]bool{}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collectBlockers now keeps its own paidPlans map and this cancel loop starts a fresh one, so each free-plan subscription's plan is resolved twice per delete: once while collecting blockers, once here. It is minor, and partly the cost of making collectBlockers self-contained for the check path, but threading one cache through both would avoid the extra GetByID calls.

for _, c := range customers {
if c.IsOffline() {
continue
Expand Down Expand Up @@ -594,7 +564,7 @@ func (d Service) ensureDeletable(ctx context.Context, id string) error {
canceled = true
}
if canceled {
bs, err := d.invoiceBlockers(ctx, c)
bs, err := d.invoiceBlockers(ctx, c, true)
if err != nil {
return err
}
Expand All @@ -607,6 +577,69 @@ func (d Service) ensureDeletable(ctx context.Context, id string) error {
return nil
}

// CheckOrganizationDelete reports everything that currently blocks deleting
// the organization, without changing anything: no subscription gets canceled
// and the invoice rows are read as they are (they sync from the provider on
// a timer). The answer is advisory — DeleteOrganization re-checks against
// fresh provider data before actually deleting.
func (d Service) CheckOrganizationDelete(ctx context.Context, id string) ([]Blocker, error) {
if _, err := d.orgService.GetRaw(ctx, id); err != nil {
return nil, err
}
customers, err := d.customerService.List(ctx, customer.Filter{
OrgID: id,
})
if err != nil {
return nil, err
}
return d.collectBlockers(ctx, customers, false)
}

// collectBlockers gathers the blockers across the org's billing accounts:
// running subscriptions on a paid plan, invoices that still ask for money,
// and token debt. fromProvider judges the invoices on data read straight
// from the billing provider instead of the local rows.
func (d Service) collectBlockers(ctx context.Context, customers []customer.Customer, fromProvider bool) ([]Blocker, error) {
// each plan resolves at most once per call, and only when a running
// subscription actually references it
paidPlans := map[string]bool{}

var blockers []Blocker
for _, c := range customers {
if !c.IsOffline() {
bs, err := d.subscriptionBlockers(ctx, c, paidPlans)
if err != nil {
return nil, err
}
blockers = append(blockers, bs...)

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

balance, err := d.creditService.GetBalance(ctx, c.ID)
if err != nil {
return nil, fmt.Errorf("failed to check token balance of billing account[%s]: %w", c.ID, err)
}
// the balance goes below zero when the account has an overdraft
// floor (credit_min under zero, the postpaid setup) and tokens were
// spent on credit. That debt is money owed, so it must be settled
// before the org can go
if balance < 0 {
blockers = append(blockers, Blocker{
Type: BlockerNegativeTokenBalance,
Subject: c.ID,
SubjectType: SubjectBillingAccount,
Message: fmt.Sprintf("billing account[%s] owes %d tokens: contact support to settle the balance, then retry the delete", c.ID, -balance),
})
}
}
return blockers, nil
}

// subscriptionBlockers returns a blocker for every running subscription on a
// paid plan; the caller downgrades those to the standard plan. Running
// free-plan subscriptions are not blockers, the delete cancels them itself.
Expand All @@ -633,9 +666,10 @@ func (d Service) subscriptionBlockers(ctx context.Context, c customer.Customer,

func paidSubscriptionBlocker(sub subscription.Subscription) Blocker {
return 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),
Type: BlockerActiveSubscription,
Subject: sub.ID,
SubjectType: SubjectSubscription,
Message: fmt.Sprintf("subscription[%s] is %s on a paid plan: downgrade to the standard plan, then retry the delete", sub.ID, sub.State),
}
}

Expand All @@ -661,14 +695,29 @@ func (d Service) isPaidPlan(ctx context.Context, planID string, cache map[string
// still asks for money. Open and uncollectible invoices the caller can pay.
// A draft is money the provider is still preparing to charge — the provider
// finalizes it shortly — and deleting before that would silently lose the
// charge, so it blocks too. The answer comes straight from the billing
// provider, so a just-paid invoice does not block and a just-created one
// does.
func (d Service) invoiceBlockers(ctx context.Context, c customer.Customer) ([]Blocker, error) {
invoices, err := d.invoiceService.ListPayableOnProvider(ctx, c)
// charge, so it blocks too.
//
// fromProvider reads the invoices straight from the billing provider, so a
// just-paid invoice does not block a delete and a just-created one does.
// Without it the local rows answer, cheap enough for every check call.
func (d Service) invoiceBlockers(ctx context.Context, c customer.Customer, fromProvider bool) ([]Blocker, error) {
var invoices []invoice.Invoice
var err error
if fromProvider {
invoices, err = d.invoiceService.ListPayableOnProvider(ctx, c)
} else {
var all []invoice.Invoice
all, err = d.invoiceService.List(ctx, invoice.Filter{CustomerID: c.ID, NonZeroOnly: true})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check path reads local rows with NonZeroOnly (amount > 0), while the delete path (ListPayableOnProvider) keeps any invoice whose Total is not 0. So the two disagree on a negative-total invoice: the check drops it and reports can_delete, but the real delete keeps it and blocks. The client greys nothing, the user clicks delete, and the delete is refused, which is the opposite of what the check promised. The root filter is in #1857 (Total == 0). Keying both off the amount actually due would keep the check and the delete in step.

for _, inv := range all {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ranges over all before the err from List is checked a few lines down. On the usual (nil result, err) contract it is a harmless no-op, but if List ever returns partial rows alongside an error, those rows get filtered before the error surfaces. Move the err check above the loop, like the provider branch does.

if inv.State == invoice.DraftState || inv.State == invoice.OpenState || inv.State == invoice.UncollectibleState {
invoices = append(invoices, inv)
}
}
}
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 {
// an invoice the sync has not stored yet carries no local id
Expand All @@ -681,9 +730,10 @@ func (d Service) invoiceBlockers(ctx context.Context, c customer.Customer) ([]Bl
message = fmt.Sprintf("invoice[%s] is still being prepared by the billing provider: retry the delete once it finalizes, then pay it", subject)
}
blockers = append(blockers, Blocker{
Type: BlockerUnpaidInvoice,
Subject: subject,
Message: message,
Type: BlockerUnpaidInvoice,
Subject: subject,
SubjectType: SubjectInvoice,
Message: message,
})
}
return blockers, nil
Expand Down
70 changes: 70 additions & 0 deletions core/deleter/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,76 @@ func TestDeleteOrganization(t *testing.T) {
})
}

func TestCheckOrganizationDelete(t *testing.T) {
t.Run("reports every blocker without changing anything", func(t *testing.T) {
m := newMocks(t)

c := customer.Customer{ID: "cust-1", ProviderID: "stripe-1"}
m.orgSvc.EXPECT().GetRaw(mock.Anything, "org-1").
Return(organization.Organization{ID: "org-1"}, nil)
m.custSvc.EXPECT().List(mock.Anything, customer.Filter{OrgID: "org-1"}).
Return([]customer.Customer{c}, nil)
m.subSvc.EXPECT().List(mock.Anything, subscription.Filter{CustomerID: "cust-1"}).
Return([]subscription.Subscription{{ID: "sub-1", State: "active", PlanID: "plan-paid"}}, nil)
m.invocSvc.EXPECT().List(mock.Anything, invoice.Filter{CustomerID: "cust-1", NonZeroOnly: true}).
Return([]invoice.Invoice{{ID: "inv-1", State: invoice.OpenState}}, nil)
m.creditSvc.EXPECT().GetBalance(mock.Anything, "cust-1").Return(-50, nil)
// strict mocks: the check must not sync invoices, cancel
// subscriptions, or delete anything

blockers, err := m.build().CheckOrganizationDelete(context.Background(), "org-1")
assert.NoError(t, err)

types := make([]string, 0, len(blockers))
subjectTypes := make([]string, 0, len(blockers))
for _, b := range blockers {
types = append(types, b.Type)
subjectTypes = append(subjectTypes, b.SubjectType)
}
assert.Equal(t, []string{
deleter.BlockerActiveSubscription,
deleter.BlockerUnpaidInvoice,
deleter.BlockerNegativeTokenBalance,
}, types)
assert.Equal(t, []string{
deleter.SubjectSubscription,
deleter.SubjectInvoice,
deleter.SubjectBillingAccount,
}, subjectTypes)
})

t.Run("returns empty when there are no blockers", func(t *testing.T) {
m := newMocks(t)

c := customer.Customer{ID: "cust-1", ProviderID: "stripe-1"}
m.orgSvc.EXPECT().GetRaw(mock.Anything, "org-1").
Return(organization.Organization{ID: "org-1"}, nil)
m.custSvc.EXPECT().List(mock.Anything, customer.Filter{OrgID: "org-1"}).
Return([]customer.Customer{c}, nil)
m.subSvc.EXPECT().List(mock.Anything, subscription.Filter{CustomerID: "cust-1"}).
Return([]subscription.Subscription{{ID: "sub-1", State: "active", PlanID: "plan-free"}}, nil)
m.invocSvc.EXPECT().List(mock.Anything, invoice.Filter{CustomerID: "cust-1", NonZeroOnly: true}).
Return([]invoice.Invoice{}, nil)
m.creditSvc.EXPECT().GetBalance(mock.Anything, "cust-1").Return(100, nil)
// a standard-plan subscription and unused tokens don't block, and the
// check must not cancel the subscription

blockers, err := m.build().CheckOrganizationDelete(context.Background(), "org-1")
assert.NoError(t, err)
assert.Empty(t, blockers)
})

t.Run("missing org surfaces not found", func(t *testing.T) {
m := newMocks(t)

m.orgSvc.EXPECT().GetRaw(mock.Anything, "org-1").
Return(organization.Organization{}, organization.ErrNotExist)

_, err := m.build().CheckOrganizationDelete(context.Background(), "org-1")
assert.ErrorIs(t, err, organization.ErrNotExist)
})
}

func TestDeleteCustomers(t *testing.T) {
t.Run("deletes subscriptions invoices checkouts transactions and customer", func(t *testing.T) {
m := newMocks(t)
Expand Down
25 changes: 25 additions & 0 deletions internal/api/v1beta1connect/deleter.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,31 @@ func (h *ConnectHandler) DeleteOrganization(ctx context.Context, request *connec
return connect.NewResponse(&frontierv1beta1.DeleteOrganizationResponse{}), nil
}

// CheckOrganizationDelete reports what currently blocks deleting the org
// without changing anything, so a client can disable its delete control and
// say why before the user ever tries.
func (h *ConnectHandler) CheckOrganizationDelete(ctx context.Context, request *connect.Request[frontierv1beta1.CheckOrganizationDeleteRequest]) (*connect.Response[frontierv1beta1.CheckOrganizationDeleteResponse], error) {
blockers, err := h.deleterService.CheckOrganizationDelete(ctx, request.Msg.GetId())
if err != nil {
if errors.Is(err, organization.ErrNotExist) || errors.Is(err, organization.ErrInvalidUUID) || errors.Is(err, organization.ErrInvalidID) {
return nil, connect.NewError(connect.CodeNotFound, organization.ErrNotExist)
}
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CheckOrganizationDelete: organization_id=%s: %w", request.Msg.GetId(), err))
}
resp := &frontierv1beta1.CheckOrganizationDeleteResponse{
CanDelete: len(blockers) == 0,
}
for _, b := range blockers {
resp.Blockers = append(resp.Blockers, &frontierv1beta1.CheckOrganizationDeleteResponse_Blocker{
Type: b.Type,
Subject: b.Subject,
SubjectType: b.SubjectType,
Message: b.Message,
})
}
return connect.NewResponse(resp), nil
}

// deleteBlockedError turns the delete's blockers into a failed_precondition
// error carrying one PreconditionFailure violation per blocker, so a caller
// sees everything to fix in a single response.
Expand Down
48 changes: 48 additions & 0 deletions internal/api/v1beta1connect/deleter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,51 @@ func TestHandler_DeleteOrganization(t *testing.T) {
assert.Equal(t, "cust-1", failure.GetViolations()[1].GetSubject())
})
}

func TestHandler_CheckOrganizationDelete(t *testing.T) {
t.Run("should report the blockers with can_delete false", func(t *testing.T) {
mockDel := new(mocks.CascadeDeleter)
mockDel.EXPECT().CheckOrganizationDelete(mock.Anything, "some-id").Return([]deleter.Blocker{
{Type: deleter.BlockerActiveSubscription, Subject: "sub-1", SubjectType: deleter.SubjectSubscription, Message: "subscription[sub-1] is active on a paid plan: downgrade to the standard plan, then retry the delete"},
{Type: deleter.BlockerUnpaidInvoice, Subject: "inv-1", SubjectType: deleter.SubjectInvoice, Message: "invoice[inv-1] is unpaid: pay it via its hosted payment page, then retry the delete"},
}, nil)
mockDep := &ConnectHandler{deleterService: mockDel}

resp, err := mockDep.CheckOrganizationDelete(context.Background(), connect.NewRequest(&frontierv1beta1.CheckOrganizationDeleteRequest{
Id: "some-id",
}))
assert.NoError(t, err)
assert.False(t, resp.Msg.GetCanDelete())
assert.Len(t, resp.Msg.GetBlockers(), 2)
assert.Equal(t, deleter.BlockerActiveSubscription, resp.Msg.GetBlockers()[0].GetType())
assert.Equal(t, "sub-1", resp.Msg.GetBlockers()[0].GetSubject())
assert.Equal(t, deleter.SubjectSubscription, resp.Msg.GetBlockers()[0].GetSubjectType())
assert.Equal(t, deleter.BlockerUnpaidInvoice, resp.Msg.GetBlockers()[1].GetType())
assert.Equal(t, deleter.SubjectInvoice, resp.Msg.GetBlockers()[1].GetSubjectType())
})

t.Run("should report can_delete true when nothing blocks", func(t *testing.T) {
mockDel := new(mocks.CascadeDeleter)
mockDel.EXPECT().CheckOrganizationDelete(mock.Anything, "some-id").Return(nil, nil)
mockDep := &ConnectHandler{deleterService: mockDel}

resp, err := mockDep.CheckOrganizationDelete(context.Background(), connect.NewRequest(&frontierv1beta1.CheckOrganizationDeleteRequest{
Id: "some-id",
}))
assert.NoError(t, err)
assert.True(t, resp.Msg.GetCanDelete())
assert.Empty(t, resp.Msg.GetBlockers())
})

t.Run("should return not found for a missing org", func(t *testing.T) {
mockDel := new(mocks.CascadeDeleter)
mockDel.EXPECT().CheckOrganizationDelete(mock.Anything, "some-id").Return(nil, organization.ErrNotExist)
mockDep := &ConnectHandler{deleterService: mockDel}

resp, err := mockDep.CheckOrganizationDelete(context.Background(), connect.NewRequest(&frontierv1beta1.CheckOrganizationDeleteRequest{
Id: "some-id",
}))
assert.Nil(t, resp)
assert.Equal(t, connect.NewError(connect.CodeNotFound, organization.ErrNotExist), err)
})
}
2 changes: 2 additions & 0 deletions internal/api/v1beta1connect/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/raystack/frontier/core/auditrecord"
"github.com/raystack/frontier/core/authenticate"
frontiersession "github.com/raystack/frontier/core/authenticate/session"
"github.com/raystack/frontier/core/deleter"
"github.com/raystack/frontier/core/domain"
"github.com/raystack/frontier/core/event"
"github.com/raystack/frontier/core/group"
Expand Down Expand Up @@ -388,6 +389,7 @@ type NamespaceService interface {
type CascadeDeleter interface {
DeleteProject(ctx context.Context, id string) error
DeleteOrganization(ctx context.Context, id string) error
CheckOrganizationDelete(ctx context.Context, id string) ([]deleter.Blocker, error)
DeleteGroup(ctx context.Context, id string) error
DeleteUser(ctx context.Context, userID string) error
}
Expand Down
Loading
Loading