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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ TAG := $(shell git rev-list --tags --max-count=1)
VERSION := $(shell git describe --tags ${TAG})
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto admin-app compose-up-dev
.DEFAULT_GOAL := build
PROTON_COMMIT := "91eaffcdc8435ee129f9f93b43ad957c32efee62"
PROTON_COMMIT := "0b8bdb2d7c0fe04b865b30e4b9bfc794f1b48a05"

admin-app:
@echo " > generating admin build"
Expand Down
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: 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"
BlockerUnusedTokens = "UNUSED_TOKENS"
)

// Blocker is one reason an organization cannot be deleted right now. The
// message names the fix, and every fix is something the caller can do
// through the API themselves.
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, "; ")
}
57 changes: 57 additions & 0 deletions core/deleter/mocks/credit_service.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 60 additions & 0 deletions core/deleter/mocks/subscription_service.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

110 changes: 95 additions & 15 deletions core/deleter/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
"strconv"

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

Expand All @@ -16,6 +17,8 @@ import (

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

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

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

"github.com/raystack/frontier/internal/bootstrap/schema"
Expand All @@ -34,10 +37,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,6 +97,7 @@ type CustomerService interface {
}

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

Expand All @@ -112,6 +112,7 @@ type CheckoutService interface {
}

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

Expand Down Expand Up @@ -216,10 +217,14 @@ func (d Service) DeleteGroup(ctx context.Context, id string) error {
// org policies (the org owners) near the end. This way a failure at any step
// 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)
//
// ackTokenForfeit is the caller's consent to forfeit any unused tokens left
// on the org's billing accounts; without it a positive token balance blocks
// the delete.
func (d Service) DeleteOrganization(ctx context.Context, id string, ackTokenForfeit bool) error {
// collect everything that blocks the delete before touching any data
if err := d.preflight(ctx, id, ackTokenForfeit); err != nil {
return err
}

// delete all billing accounts
Expand Down Expand Up @@ -364,6 +369,24 @@ 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; the
// pre-flight only lets a positive balance through once the caller has
// acknowledged the forfeit, 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 +435,80 @@ 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. It runs before any
// deletion starts, so a blocked delete changes nothing.
//
// 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, ackTokenForfeit bool) error {
customers, err := d.customerService.List(ctx, customer.Filter{
OrgID: id,
})
if err != nil {
return err
}

var blockers []Blocker
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() {
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() {
blockers = append(blockers, Blocker{
Type: BlockerActiveSubscription,
Subject: sub.ID,
Message: fmt.Sprintf("subscription[%s] is %s: cancel it, then retry the delete", sub.ID, sub.State),
})
}
}

// only invoices the caller can still pay block the delete; paid,
// void, and draft invoices don't. The billing provider keeps its
// own permanent copy of every invoice, so deleting our rows loses
// nothing.
invoices, err := d.invoiceService.List(ctx, invoice.Filter{CustomerID: c.ID, NonZeroOnly: true})
if err != nil {
return fmt.Errorf("failed to check invoices for billing account[%s]: %w", c.ID, err)
}
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),
})
}
}
}

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)
}
switch {
case balance < 0:
blockers = append(blockers, Blocker{
Type: BlockerNegativeTokenBalance,
Subject: c.ID,
Message: fmt.Sprintf("billing account[%s] owes %d tokens: buy tokens to clear the debt, then retry the delete", c.ID, -balance),
})
case balance > 0 && !ackTokenForfeit:
blockers = append(blockers, Blocker{
Type: BlockerUnusedTokens,
Subject: c.ID,
Message: fmt.Sprintf("billing account[%s] has %d unused tokens that deleting the organization forfeits: retry the delete with acknowledge_token_forfeit set to proceed", c.ID, balance),
})
}
}

if len(blockers) > 0 {
return &BlockedError{OrgID: id, Blockers: blockers}
}
return nil
}
Loading
Loading