diff --git a/billing/config.go b/billing/config.go
index c2b6b172f..555a614aa 100644
--- a/billing/config.go
+++ b/billing/config.go
@@ -14,9 +14,19 @@ type Config struct {
SubscriptionConfig SubscriptionConfig `yaml:"subscription" mapstructure:"subscription"`
ProductConfig ProductConfig `yaml:"product" mapstructure:"product"`
+ // TokenForfeitNotice is the email sent to the organization owners when
+ // deleting their organization forfeited unused tokens. Subject and Body
+ // are Go templates; empty values fall back to plain built-in text.
+ TokenForfeitNotice TokenForfeitNoticeConfig `yaml:"token_forfeit_notice" mapstructure:"token_forfeit_notice"`
+
RefreshInterval RefreshInterval `yaml:"refresh_interval" mapstructure:"refresh_interval"`
}
+type TokenForfeitNoticeConfig struct {
+ Subject string `yaml:"subject" mapstructure:"subject"`
+ Body string `yaml:"body" mapstructure:"body"`
+}
+
type RefreshInterval struct {
Customer time.Duration `yaml:"customer" mapstructure:"customer" default:"1m"`
Subscription time.Duration `yaml:"subscription" mapstructure:"subscription" default:"1m"`
diff --git a/cmd/serve.go b/cmd/serve.go
index 2f7547f12..a2a457266 100644
--- a/cmd/serve.go
+++ b/cmd/serve.go
@@ -588,6 +588,7 @@ func buildAPIDependencies(
groupService, membershipService, policyService, roleService, invitationService, userService, userPATService,
serviceUserService, customerService, subscriptionService, invoiceService, checkoutService,
creditService, orgKycService, planService,
+ mailDialer, cfg.Billing.TokenForfeitNotice,
)
// we should default it with a stdout logger repository as postgres can start to bloat really fast
diff --git a/core/deleter/forfeit_notice.go b/core/deleter/forfeit_notice.go
new file mode 100644
index 000000000..ac51b7628
--- /dev/null
+++ b/core/deleter/forfeit_notice.go
@@ -0,0 +1,245 @@
+package deleter
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ htmltemplate "html/template"
+ "log/slog"
+ texttemplate "text/template"
+
+ "github.com/raystack/frontier/billing/credit"
+
+ "github.com/raystack/frontier/billing/customer"
+ "github.com/raystack/frontier/core/authenticate"
+ "github.com/raystack/frontier/core/membership"
+ "github.com/raystack/frontier/core/organization"
+ "github.com/raystack/frontier/core/user"
+ "github.com/raystack/frontier/internal/bootstrap/schema"
+ "gopkg.in/mail.v2"
+)
+
+// plain fallbacks used when the config leaves the templates empty
+const (
+ defaultForfeitNoticeSubject = "Unused tokens from your deleted organization"
+ defaultForfeitNoticeBody = `{{if .User.Title}}Hi {{.User.Title}},{{else}}Hi,{{end}}
Your organization {{if .Org.Title}}{{.Org.Title}}{{else}}{{.Org.Name}}{{end}} was deleted{{if .DeletedBy}} by {{.DeletedBy}}{{end}} with {{.Amount}} unused tokens remaining{{if lt .Purchased .Amount}}, of which {{.Purchased}} came from purchases{{end}}. Contact support to get the purchased amount transferred to your bank account.`
+)
+
+type forfeitNoticeData struct {
+ // Amount is the total number of tokens the delete forfeited.
+ Amount int64
+ // User is the owner receiving this mail.
+ User user.User
+ // Org is the deleted organization.
+ Org organization.Organization
+ // Purchased is the share of Amount that came from purchases; only this
+ // part is transferable.
+ Purchased int64
+ // DeletedBy identifies who ran the delete; empty when the caller is
+ // not known.
+ DeletedBy string
+}
+
+// accountTokens is what one billing account holds at delete time.
+type accountTokens struct {
+ Balance int64
+ Purchased int64
+}
+
+// forfeitNotice is everything sendForfeitNotices needs once the org is gone.
+// It has to be collected before teardown removes the owners and the token
+// balances. Accounts keeps the per-account numbers so the teardown can audit
+// them without reading the balances a second time.
+type forfeitNotice struct {
+ Amount int64
+ Purchased int64
+ Accounts map[string]accountTokens
+ Owners []user.User
+}
+
+// collectForfeitNotice sums the unused tokens the delete is about to forfeit
+// and resolves the org owners to notify. It only reads; a failure here aborts
+// the delete before anything is torn down.
+//
+// The amount is the whole remaining balance. Purchased is the share of it
+// that came from purchases (source system.buy), with complimentary tokens
+// (plan starter grants and awards) counted as spent first. Only the
+// purchased share is transferable.
+func (d Service) collectForfeitNotice(ctx context.Context, org organization.Organization) (forfeitNotice, error) {
+ customers, err := d.customerService.List(ctx, customer.Filter{
+ OrgID: org.ID,
+ })
+ if err != nil {
+ return forfeitNotice{}, err
+ }
+
+ var total, purchased int64
+ accounts := make(map[string]accountTokens, len(customers))
+ for _, c := range customers {
+ balance, err := d.creditService.GetBalance(ctx, c.ID)
+ if err != nil {
+ return forfeitNotice{}, fmt.Errorf("failed to check token balance of billing account[%s]: %w", c.ID, err)
+ }
+ if balance > 0 {
+ bought, err := d.purchasedTokens(ctx, c.ID, balance)
+ if err != nil {
+ return forfeitNotice{}, err
+ }
+ total += balance
+ purchased += bought
+ accounts[c.ID] = accountTokens{Balance: balance, Purchased: bought}
+ }
+ }
+ if total == 0 {
+ return forfeitNotice{Accounts: accounts}, nil
+ }
+
+ ownerRole, err := d.roleService.Get(ctx, schema.RoleOrganizationOwner)
+ if err != nil {
+ return forfeitNotice{}, fmt.Errorf("failed to resolve the organization owner role: %w", err)
+ }
+ members, err := d.membershipService.ListPrincipalsByResource(ctx, org.ID, schema.OrganizationNamespace, membership.MemberFilter{
+ PrincipalType: schema.UserPrincipal,
+ RoleIDs: []string{ownerRole.ID},
+ })
+ if err != nil {
+ return forfeitNotice{}, fmt.Errorf("failed to list the organization owners: %w", err)
+ }
+ ownerIDs := make([]string, 0, len(members))
+ for _, m := range members {
+ ownerIDs = append(ownerIDs, m.PrincipalID)
+ }
+ owners, err := d.userService.GetByIDs(ctx, ownerIDs)
+ if err != nil {
+ return forfeitNotice{}, fmt.Errorf("failed to fetch the organization owners: %w", err)
+ }
+ return forfeitNotice{Amount: total, Purchased: purchased, Accounts: accounts, Owners: owners}, nil
+}
+
+// accountTokens returns one account's balance and purchased share, from the
+// caller's already-collected amounts when given, otherwise read fresh.
+func (d Service) accountTokens(ctx context.Context, accountID string, amounts map[string]accountTokens) (accountTokens, error) {
+ if amounts != nil {
+ return amounts[accountID], nil
+ }
+ balance, err := d.creditService.GetBalance(ctx, accountID)
+ if err != nil {
+ return accountTokens{}, err
+ }
+ if balance <= 0 {
+ return accountTokens{Balance: balance}, nil
+ }
+ bought, err := d.purchasedTokens(ctx, accountID, balance)
+ if err != nil {
+ return accountTokens{}, err
+ }
+ return accountTokens{Balance: balance, Purchased: bought}, nil
+}
+
+// purchasedTokens returns how many of the account's remaining tokens came
+// from purchases. Complimentary tokens (plan starter grants and awards) are
+// counted as spent first, so the purchased share is the smaller of the
+// balance and everything ever bought.
+func (d Service) purchasedTokens(ctx context.Context, accountID string, balance int64) (int64, error) {
+ txns, err := d.creditService.List(ctx, credit.Filter{CustomerID: accountID})
+ if err != nil {
+ return 0, fmt.Errorf("failed to list token transactions of billing account[%s]: %w", accountID, err)
+ }
+ var bought int64
+ for _, t := range txns {
+ if t.Type == credit.CreditType && t.Source == credit.SourceSystemBuyEvent {
+ bought += t.Amount
+ }
+ }
+ return min(bought, balance), nil
+}
+
+// sendForfeitNotices emails every org owner that the delete forfeited unused
+// tokens and that support can transfer the amount. The org is already gone at
+// this point, so failures are logged and never returned.
+func (d Service) sendForfeitNotices(ctx context.Context, org organization.Organization, notice forfeitNotice) {
+ if d.mailDialer == nil {
+ slog.WarnContext(ctx, "no mail dialer configured, skipping token forfeit notices", "org_id", org.ID)
+ return
+ }
+ subjectTpl := d.forfeitNoticeConfig.Subject
+ if subjectTpl == "" {
+ subjectTpl = defaultForfeitNoticeSubject
+ }
+ bodyTpl := d.forfeitNoticeConfig.Body
+ if bodyTpl == "" {
+ bodyTpl = defaultForfeitNoticeBody
+ }
+
+ deletedBy := deletedByFromContext(ctx)
+ for _, owner := range notice.Owners {
+ data := forfeitNoticeData{
+ Amount: notice.Amount,
+ Purchased: notice.Purchased,
+ User: owner,
+ Org: org,
+ DeletedBy: deletedBy,
+ }
+ subject, err := renderForfeitSubject(subjectTpl, data)
+ if err != nil {
+ slog.WarnContext(ctx, "failed to render token forfeit notice subject", "org_id", org.ID, "error", err)
+ return
+ }
+ body, err := renderForfeitBody(bodyTpl, data)
+ if err != nil {
+ slog.WarnContext(ctx, "failed to render token forfeit notice body", "org_id", org.ID, "error", err)
+ return
+ }
+
+ msg := mail.NewMessage()
+ msg.SetHeader("From", d.mailDialer.FromHeader())
+ msg.SetHeader("To", owner.Email)
+ msg.SetHeader("Subject", subject)
+ msg.SetBody("text/html", body)
+ if err := d.mailDialer.DialAndSend(msg); err != nil {
+ slog.WarnContext(ctx, "failed to send token forfeit notice", "org_id", org.ID, "user_email", owner.Email, "error", err)
+ continue
+ }
+ slog.InfoContext(ctx, "sent token forfeit notice", "org_id", org.ID, "user_email", owner.Email, "amount", notice.Amount)
+ }
+}
+
+// deletedByFromContext names the caller who ran the delete, when the
+// context carries one.
+func deletedByFromContext(ctx context.Context) string {
+ principal, ok := authenticate.GetPrincipalFromContext(ctx)
+ if !ok || principal == nil {
+ return ""
+ }
+ if principal.User != nil && principal.User.Email != "" {
+ return principal.User.Email
+ }
+ if principal.ServiceUser != nil && principal.ServiceUser.Title != "" {
+ return principal.ServiceUser.Title
+ }
+ return principal.ID
+}
+
+func renderForfeitSubject(tpl string, data forfeitNoticeData) (string, error) {
+ t, err := texttemplate.New("subject").Parse(tpl)
+ if err != nil {
+ return "", err
+ }
+ var out bytes.Buffer
+ if err := t.Execute(&out, data); err != nil {
+ return "", err
+ }
+ return out.String(), nil
+}
+
+func renderForfeitBody(tpl string, data forfeitNoticeData) (string, error) {
+ t, err := htmltemplate.New("body").Parse(tpl)
+ if err != nil {
+ return "", err
+ }
+ var out bytes.Buffer
+ if err := t.Execute(&out, data); err != nil {
+ return "", err
+ }
+ return out.String(), nil
+}
diff --git a/core/deleter/mocks/membership_service.go b/core/deleter/mocks/membership_service.go
index ad519d11c..0ff75d628 100644
--- a/core/deleter/mocks/membership_service.go
+++ b/core/deleter/mocks/membership_service.go
@@ -73,6 +73,67 @@ func (_c *MembershipService_ForceRemoveOrganizationMember_Call) RunAndReturn(run
return _c
}
+// ListPrincipalsByResource provides a mock function with given fields: ctx, resourceID, resourceType, filter
+func (_m *MembershipService) ListPrincipalsByResource(ctx context.Context, resourceID string, resourceType string, filter membership.MemberFilter) ([]membership.Member, error) {
+ ret := _m.Called(ctx, resourceID, resourceType, filter)
+
+ if len(ret) == 0 {
+ panic("no return value specified for ListPrincipalsByResource")
+ }
+
+ var r0 []membership.Member
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, string, string, membership.MemberFilter) ([]membership.Member, error)); ok {
+ return rf(ctx, resourceID, resourceType, filter)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, string, string, membership.MemberFilter) []membership.Member); ok {
+ r0 = rf(ctx, resourceID, resourceType, filter)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]membership.Member)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, string, string, membership.MemberFilter) error); ok {
+ r1 = rf(ctx, resourceID, resourceType, filter)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// MembershipService_ListPrincipalsByResource_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListPrincipalsByResource'
+type MembershipService_ListPrincipalsByResource_Call struct {
+ *mock.Call
+}
+
+// ListPrincipalsByResource is a helper method to define mock.On call
+// - ctx context.Context
+// - resourceID string
+// - resourceType string
+// - filter membership.MemberFilter
+func (_e *MembershipService_Expecter) ListPrincipalsByResource(ctx interface{}, resourceID interface{}, resourceType interface{}, filter interface{}) *MembershipService_ListPrincipalsByResource_Call {
+ return &MembershipService_ListPrincipalsByResource_Call{Call: _e.mock.On("ListPrincipalsByResource", ctx, resourceID, resourceType, filter)}
+}
+
+func (_c *MembershipService_ListPrincipalsByResource_Call) Run(run func(ctx context.Context, resourceID string, resourceType string, filter membership.MemberFilter)) *MembershipService_ListPrincipalsByResource_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(membership.MemberFilter))
+ })
+ return _c
+}
+
+func (_c *MembershipService_ListPrincipalsByResource_Call) Return(_a0 []membership.Member, _a1 error) *MembershipService_ListPrincipalsByResource_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *MembershipService_ListPrincipalsByResource_Call) RunAndReturn(run func(context.Context, string, string, membership.MemberFilter) ([]membership.Member, error)) *MembershipService_ListPrincipalsByResource_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
// ListResourcesByPrincipal provides a mock function with given fields: ctx, principal, resourceType, filter
func (_m *MembershipService) ListResourcesByPrincipal(ctx context.Context, principal authenticate.Principal, resourceType string, filter membership.ResourceFilter) ([]string, error) {
ret := _m.Called(ctx, principal, resourceType, filter)
diff --git a/core/deleter/mocks/organization_service.go b/core/deleter/mocks/organization_service.go
index ea1e53564..4e3c2f339 100644
--- a/core/deleter/mocks/organization_service.go
+++ b/core/deleter/mocks/organization_service.go
@@ -69,12 +69,12 @@ func (_c *OrganizationService_DeleteModel_Call) RunAndReturn(run func(context.Co
return _c
}
-// Get provides a mock function with given fields: ctx, id
-func (_m *OrganizationService) Get(ctx context.Context, id string) (organization.Organization, error) {
+// GetRaw provides a mock function with given fields: ctx, id
+func (_m *OrganizationService) GetRaw(ctx context.Context, id string) (organization.Organization, error) {
ret := _m.Called(ctx, id)
if len(ret) == 0 {
- panic("no return value specified for Get")
+ panic("no return value specified for GetRaw")
}
var r0 organization.Organization
@@ -97,31 +97,31 @@ func (_m *OrganizationService) Get(ctx context.Context, id string) (organization
return r0, r1
}
-// OrganizationService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
-type OrganizationService_Get_Call struct {
+// OrganizationService_GetRaw_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRaw'
+type OrganizationService_GetRaw_Call struct {
*mock.Call
}
-// Get is a helper method to define mock.On call
+// GetRaw is a helper method to define mock.On call
// - ctx context.Context
// - id string
-func (_e *OrganizationService_Expecter) Get(ctx interface{}, id interface{}) *OrganizationService_Get_Call {
- return &OrganizationService_Get_Call{Call: _e.mock.On("Get", ctx, id)}
+func (_e *OrganizationService_Expecter) GetRaw(ctx interface{}, id interface{}) *OrganizationService_GetRaw_Call {
+ return &OrganizationService_GetRaw_Call{Call: _e.mock.On("GetRaw", ctx, id)}
}
-func (_c *OrganizationService_Get_Call) Run(run func(ctx context.Context, id string)) *OrganizationService_Get_Call {
+func (_c *OrganizationService_GetRaw_Call) Run(run func(ctx context.Context, id string)) *OrganizationService_GetRaw_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
-func (_c *OrganizationService_Get_Call) Return(_a0 organization.Organization, _a1 error) *OrganizationService_Get_Call {
+func (_c *OrganizationService_GetRaw_Call) Return(_a0 organization.Organization, _a1 error) *OrganizationService_GetRaw_Call {
_c.Call.Return(_a0, _a1)
return _c
}
-func (_c *OrganizationService_Get_Call) RunAndReturn(run func(context.Context, string) (organization.Organization, error)) *OrganizationService_Get_Call {
+func (_c *OrganizationService_GetRaw_Call) RunAndReturn(run func(context.Context, string) (organization.Organization, error)) *OrganizationService_GetRaw_Call {
_c.Call.Return(run)
return _c
}
diff --git a/core/deleter/mocks/role_service.go b/core/deleter/mocks/role_service.go
index 8a24e5e82..a7ebc4e70 100644
--- a/core/deleter/mocks/role_service.go
+++ b/core/deleter/mocks/role_service.go
@@ -5,9 +5,8 @@ package mocks
import (
context "context"
- mock "github.com/stretchr/testify/mock"
-
role "github.com/raystack/frontier/core/role"
+ mock "github.com/stretchr/testify/mock"
)
// RoleService is an autogenerated mock type for the RoleService type
@@ -70,6 +69,63 @@ func (_c *RoleService_Delete_Call) RunAndReturn(run func(context.Context, string
return _c
}
+// Get provides a mock function with given fields: ctx, id
+func (_m *RoleService) Get(ctx context.Context, id string) (role.Role, error) {
+ ret := _m.Called(ctx, id)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Get")
+ }
+
+ var r0 role.Role
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, string) (role.Role, error)); ok {
+ return rf(ctx, id)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, string) role.Role); ok {
+ r0 = rf(ctx, id)
+ } else {
+ r0 = ret.Get(0).(role.Role)
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
+ r1 = rf(ctx, id)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// RoleService_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
+type RoleService_Get_Call struct {
+ *mock.Call
+}
+
+// Get is a helper method to define mock.On call
+// - ctx context.Context
+// - id string
+func (_e *RoleService_Expecter) Get(ctx interface{}, id interface{}) *RoleService_Get_Call {
+ return &RoleService_Get_Call{Call: _e.mock.On("Get", ctx, id)}
+}
+
+func (_c *RoleService_Get_Call) Run(run func(ctx context.Context, id string)) *RoleService_Get_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(context.Context), args[1].(string))
+ })
+ return _c
+}
+
+func (_c *RoleService_Get_Call) Return(_a0 role.Role, _a1 error) *RoleService_Get_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *RoleService_Get_Call) RunAndReturn(run func(context.Context, string) (role.Role, error)) *RoleService_Get_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
// List provides a mock function with given fields: ctx, flt
func (_m *RoleService) List(ctx context.Context, flt role.Filter) ([]role.Role, error) {
ret := _m.Called(ctx, flt)
diff --git a/core/deleter/mocks/user_service.go b/core/deleter/mocks/user_service.go
index 06a9b6bc4..b9e8ad97f 100644
--- a/core/deleter/mocks/user_service.go
+++ b/core/deleter/mocks/user_service.go
@@ -5,6 +5,7 @@ package mocks
import (
context "context"
+ user "github.com/raystack/frontier/core/user"
mock "github.com/stretchr/testify/mock"
)
@@ -68,6 +69,65 @@ func (_c *UserService_Delete_Call) RunAndReturn(run func(context.Context, string
return _c
}
+// GetByIDs provides a mock function with given fields: ctx, userIDs
+func (_m *UserService) GetByIDs(ctx context.Context, userIDs []string) ([]user.User, error) {
+ ret := _m.Called(ctx, userIDs)
+
+ if len(ret) == 0 {
+ panic("no return value specified for GetByIDs")
+ }
+
+ var r0 []user.User
+ var r1 error
+ if rf, ok := ret.Get(0).(func(context.Context, []string) ([]user.User, error)); ok {
+ return rf(ctx, userIDs)
+ }
+ if rf, ok := ret.Get(0).(func(context.Context, []string) []user.User); ok {
+ r0 = rf(ctx, userIDs)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]user.User)
+ }
+ }
+
+ if rf, ok := ret.Get(1).(func(context.Context, []string) error); ok {
+ r1 = rf(ctx, userIDs)
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// UserService_GetByIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByIDs'
+type UserService_GetByIDs_Call struct {
+ *mock.Call
+}
+
+// GetByIDs is a helper method to define mock.On call
+// - ctx context.Context
+// - userIDs []string
+func (_e *UserService_Expecter) GetByIDs(ctx interface{}, userIDs interface{}) *UserService_GetByIDs_Call {
+ return &UserService_GetByIDs_Call{Call: _e.mock.On("GetByIDs", ctx, userIDs)}
+}
+
+func (_c *UserService_GetByIDs_Call) Run(run func(ctx context.Context, userIDs []string)) *UserService_GetByIDs_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ run(args[0].(context.Context), args[1].([]string))
+ })
+ return _c
+}
+
+func (_c *UserService_GetByIDs_Call) Return(_a0 []user.User, _a1 error) *UserService_GetByIDs_Call {
+ _c.Call.Return(_a0, _a1)
+ return _c
+}
+
+func (_c *UserService_GetByIDs_Call) RunAndReturn(run func(context.Context, []string) ([]user.User, error)) *UserService_GetByIDs_Call {
+ _c.Call.Return(run)
+ return _c
+}
+
// NewUserService creates a new instance of UserService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewUserService(t interface {
diff --git a/core/deleter/service.go b/core/deleter/service.go
index 2acebbf11..c2f11b0fd 100644
--- a/core/deleter/service.go
+++ b/core/deleter/service.go
@@ -11,8 +11,12 @@ import (
"github.com/raystack/frontier/core/authenticate"
+ "github.com/raystack/frontier/billing"
+
"github.com/raystack/frontier/billing/checkout"
+ "github.com/raystack/frontier/billing/credit"
+
"github.com/raystack/frontier/billing/invoice"
"github.com/raystack/frontier/billing/customer"
@@ -37,6 +41,8 @@ import (
"github.com/raystack/frontier/core/project"
"github.com/raystack/frontier/core/resource"
"github.com/raystack/frontier/core/serviceuser"
+ "github.com/raystack/frontier/core/user"
+ "github.com/raystack/frontier/pkg/mailer"
)
type ProjectService interface {
@@ -45,11 +51,12 @@ type ProjectService interface {
}
type OrganizationService interface {
- Get(ctx context.Context, id string) (organization.Organization, error)
+ GetRaw(ctx context.Context, id string) (organization.Organization, error)
DeleteModel(ctx context.Context, id string) error
}
type RoleService interface {
+ Get(ctx context.Context, id string) (role.Role, error)
List(ctx context.Context, flt role.Filter) ([]role.Role, error)
Delete(ctx context.Context, id string) error
}
@@ -72,6 +79,7 @@ type GroupService interface {
type MembershipService interface {
OnGroupDeleted(ctx context.Context, groupID string) error
ListResourcesByPrincipal(ctx context.Context, principal authenticate.Principal, resourceType string, filter membership.ResourceFilter) ([]string, error)
+ ListPrincipalsByResource(ctx context.Context, resourceID, resourceType string, filter membership.MemberFilter) ([]membership.Member, error)
ForceRemoveOrganizationMember(ctx context.Context, orgID, principalID, principalType string) error
}
@@ -81,6 +89,7 @@ type InvitationService interface {
}
type UserService interface {
+ GetByIDs(ctx context.Context, userIDs []string) ([]user.User, error)
Delete(ctx context.Context, id string) error
}
@@ -117,6 +126,7 @@ type CheckoutService interface {
type CreditService interface {
GetBalance(ctx context.Context, accountID string) (int64, error)
+ List(ctx context.Context, flt credit.Filter) ([]credit.Transaction, error)
DeleteByAccountID(ctx context.Context, accountID string) error
}
@@ -147,6 +157,10 @@ type Service struct {
creditService CreditService
kycService KycService
planService PlanService
+ // mailDialer and forfeitNoticeConfig drive the email that tells the org
+ // owners about tokens forfeited by the delete
+ mailDialer mailer.Dialer
+ forfeitNoticeConfig billing.TokenForfeitNoticeConfig
}
func NewCascadeDeleter(orgService OrganizationService, projService ProjectService,
@@ -159,26 +173,29 @@ func NewCascadeDeleter(orgService OrganizationService, projService ProjectServic
customerService CustomerService, subService SubscriptionService,
invoiceService InvoiceService, checkoutService CheckoutService,
creditService CreditService, kycService KycService,
- planService PlanService) *Service {
+ planService PlanService,
+ mailDialer mailer.Dialer, forfeitNoticeConfig billing.TokenForfeitNoticeConfig) *Service {
return &Service{
- projService: projService,
- orgService: orgService,
- resService: resService,
- groupService: groupService,
- membershipService: membershipService,
- policyService: policyService,
- roleService: roleService,
- invitationService: invitationService,
- userService: userService,
- userPATService: userPATService,
- serviceUserService: serviceUserService,
- customerService: customerService,
- subService: subService,
- invoiceService: invoiceService,
- checkoutService: checkoutService,
- creditService: creditService,
- kycService: kycService,
- planService: planService,
+ projService: projService,
+ orgService: orgService,
+ resService: resService,
+ groupService: groupService,
+ membershipService: membershipService,
+ policyService: policyService,
+ roleService: roleService,
+ invitationService: invitationService,
+ userService: userService,
+ userPATService: userPATService,
+ serviceUserService: serviceUserService,
+ customerService: customerService,
+ subService: subService,
+ invoiceService: invoiceService,
+ checkoutService: checkoutService,
+ creditService: creditService,
+ kycService: kycService,
+ planService: planService,
+ mailDialer: mailDialer,
+ forfeitNoticeConfig: forfeitNoticeConfig,
}
}
@@ -230,8 +247,9 @@ func (d Service) DeleteGroup(ctx context.Context, id string) error {
// treats already-deleted data as success for the same reason.
func (d Service) DeleteOrganization(ctx context.Context, id string) error {
// 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) {
+ // GetRaw keeps disabled orgs deletable
+ org, err := d.orgService.GetRaw(ctx, id)
+ if err != nil {
return err
}
@@ -241,8 +259,15 @@ func (d Service) DeleteOrganization(ctx context.Context, id string) error {
return err
}
+ // the token forfeit notice reads owners and balances, so it has to be
+ // collected while they still exist
+ notice, err := d.collectForfeitNotice(ctx, org)
+ if err != nil {
+ return err
+ }
+
// delete all billing accounts
- if err := d.DeleteCustomers(ctx, id); err != nil {
+ if err := d.deleteCustomers(ctx, id, notice.Accounts); err != nil {
return err
}
@@ -332,10 +357,24 @@ func (d Service) DeleteOrganization(ctx context.Context, id string) error {
if err := audit.NewLogger(ctx, id).Log(audit.OrgDeletedEvent, audit.OrgTarget(id)); err != nil {
slog.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.OrgDeletedEvent)
}
+
+ // the org is gone; tell the owners about any tokens the delete forfeited
+ if notice.Amount > 0 {
+ d.sendForfeitNotices(ctx, org, notice)
+ }
return nil
}
+// DeleteCustomers reads the token balances itself; DeleteOrganization goes
+// through deleteCustomers with the amounts it already collected.
func (d Service) DeleteCustomers(ctx context.Context, id string) error {
+ return d.deleteCustomers(ctx, id, nil)
+}
+
+// deleteCustomers tears down the org's billing accounts. amounts carries the
+// per-account token numbers the caller already read; nil means read them
+// here.
+func (d Service) deleteCustomers(ctx context.Context, id string, amounts map[string]accountTokens) error {
customers, err := d.customerService.List(ctx, customer.Filter{
OrgID: id,
})
@@ -384,17 +423,21 @@ func (d Service) DeleteCustomers(ctx context.Context, id string) error {
}
}
// 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)
+ // record the amount before the transactions are removed. The
+ // purchased share goes on the record too: the transaction rows are
+ // deleted right after, and support settles a transfer from this
+ // number later
+ account, err := d.accountTokens(ctx, c.ID, amounts)
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 account.Balance > 0 {
if err := auditLogger.LogWithAttrs(audit.BillingTokensForfeitedEvent, audit.Target{
ID: c.ID,
Type: "billing_account",
}, map[string]string{
- "amount": strconv.FormatInt(balance, 10),
+ "amount": strconv.FormatInt(account.Balance, 10),
+ "purchased": strconv.FormatInt(account.Purchased, 10),
}); err != nil {
slog.WarnContext(ctx, "failed to write audit log", "error", err, "event", audit.BillingTokensForfeitedEvent, "customer_id", c.ID)
}
diff --git a/core/deleter/service_test.go b/core/deleter/service_test.go
index 800b6ba88..ffd8fda7f 100644
--- a/core/deleter/service_test.go
+++ b/core/deleter/service_test.go
@@ -6,7 +6,9 @@ import (
"testing"
"github.com/google/uuid"
+ "github.com/raystack/frontier/billing"
"github.com/raystack/frontier/billing/checkout"
+ "github.com/raystack/frontier/billing/credit"
"github.com/raystack/frontier/billing/customer"
"github.com/raystack/frontier/billing/invoice"
"github.com/raystack/frontier/billing/plan"
@@ -16,13 +18,16 @@ import (
"github.com/raystack/frontier/core/deleter/mocks"
"github.com/raystack/frontier/core/group"
"github.com/raystack/frontier/core/invitation"
+ "github.com/raystack/frontier/core/membership"
"github.com/raystack/frontier/core/organization"
"github.com/raystack/frontier/core/policy"
"github.com/raystack/frontier/core/project"
"github.com/raystack/frontier/core/resource"
"github.com/raystack/frontier/core/role"
"github.com/raystack/frontier/core/serviceuser"
+ "github.com/raystack/frontier/core/user"
"github.com/raystack/frontier/internal/bootstrap/schema"
+ mailermocks "github.com/raystack/frontier/pkg/mailer/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
@@ -46,6 +51,7 @@ type deleterMocks struct {
creditSvc *mocks.CreditService
kycSvc *mocks.KycService
planSvc *mocks.PlanService
+ dialer *mailermocks.Dialer
}
func newMocks(t *testing.T) deleterMocks {
@@ -69,6 +75,7 @@ func newMocks(t *testing.T) deleterMocks {
creditSvc: mocks.NewCreditService(t),
kycSvc: mocks.NewKycService(t),
planSvc: mocks.NewPlanService(t),
+ dialer: mailermocks.NewDialer(t),
}
// the standard plan resolves on any org with provider-backed billing;
// stub the lookup once for every test
@@ -87,7 +94,7 @@ func (m deleterMocks) build() *deleter.Service {
return deleter.NewCascadeDeleter(m.orgSvc, m.projSvc, m.resSvc, m.grpSvc, m.mbrSvc,
m.polSvc, m.roleSvc, m.invSvc, m.usrSvc, m.patSvc, m.suSvc,
m.custSvc, m.subSvc, m.invocSvc, m.checkoutSvc, m.creditSvc, m.kycSvc,
- m.planSvc)
+ m.planSvc, m.dialer, billing.TokenForfeitNoticeConfig{})
}
func TestDeleteProject(t *testing.T) {
@@ -148,7 +155,7 @@ func TestDeleteOrganization(t *testing.T) {
t.Run("full cascade delete", func(t *testing.T) {
m := newMocks(t)
- m.orgSvc.EXPECT().Get(mock.Anything, "org-1").
+ m.orgSvc.EXPECT().GetRaw(mock.Anything, "org-1").
Return(organization.Organization{ID: "org-1"}, nil)
// the up-front check and DeleteCustomers both list customers
@@ -219,7 +226,7 @@ func TestDeleteOrganization(t *testing.T) {
t.Run("already deleted org returns not found without touching anything", func(t *testing.T) {
m := newMocks(t)
- m.orgSvc.EXPECT().Get(mock.Anything, "org-1").
+ m.orgSvc.EXPECT().GetRaw(mock.Anything, "org-1").
Return(organization.Organization{}, organization.ErrNotExist)
// strict mocks: no other service may be called
@@ -231,7 +238,7 @@ func TestDeleteOrganization(t *testing.T) {
m := newMocks(t)
c := customer.Customer{ID: "cust-1", ProviderID: "stripe-1"}
- m.orgSvc.EXPECT().Get(mock.Anything, "org-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)
@@ -266,7 +273,7 @@ func TestDeleteOrganization(t *testing.T) {
m := newMocks(t)
c := customer.Customer{ID: "cust-1", ProviderID: "stripe-1"}
- m.orgSvc.EXPECT().Get(mock.Anything, "org-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)
@@ -290,7 +297,7 @@ func TestDeleteOrganization(t *testing.T) {
m := newMocks(t)
c := customer.Customer{ID: "cust-1", ProviderID: "stripe-1"}
- m.orgSvc.EXPECT().Get(mock.Anything, "org-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)
@@ -309,20 +316,42 @@ func TestDeleteOrganization(t *testing.T) {
assert.Contains(t, blocked.Blockers[0].Message, "contact support")
})
- t.Run("unused tokens do not block the delete", func(t *testing.T) {
+ t.Run("unused tokens do not block the delete and the owners get an email", func(t *testing.T) {
m := newMocks(t)
c := customer.Customer{ID: "cust-1", ProviderID: "stripe-1"}
- m.orgSvc.EXPECT().Get(mock.Anything, "org-1").
- Return(organization.Organization{ID: "org-1"}, nil)
+ m.orgSvc.EXPECT().GetRaw(mock.Anything, "org-1").
+ Return(organization.Organization{ID: "org-1", Title: "Org One"}, nil)
m.custSvc.EXPECT().List(mock.Anything, customer.Filter{OrgID: "org-1"}).
Return([]customer.Customer{c}, nil)
m.invocSvc.EXPECT().ListPayableOnProvider(mock.Anything, c).
Return([]invoice.Invoice{}, nil)
m.creditSvc.EXPECT().GetBalance(mock.Anything, "cust-1").Return(100, nil)
+ // 60 of the 100 remaining tokens were bought, the rest were granted
+ m.creditSvc.EXPECT().List(mock.Anything, credit.Filter{CustomerID: "cust-1"}).
+ Return([]credit.Transaction{
+ {Type: credit.CreditType, Source: credit.SourceSystemBuyEvent, Amount: 60},
+ {Type: credit.CreditType, Source: credit.SourceSystemOnboardEvent, Amount: 90},
+ {Type: credit.DebitType, Source: "app.usage", Amount: 50},
+ }, nil)
m.subSvc.EXPECT().List(mock.Anything, subscription.Filter{CustomerID: "cust-1"}).
Return([]subscription.Subscription{}, nil)
+ // the positive balance makes the delete collect the owners up front
+ m.roleSvc.EXPECT().Get(mock.Anything, schema.RoleOrganizationOwner).
+ Return(role.Role{ID: "owner-role-id"}, nil)
+ m.mbrSvc.EXPECT().ListPrincipalsByResource(mock.Anything, "org-1", schema.OrganizationNamespace, membership.MemberFilter{
+ PrincipalType: schema.UserPrincipal,
+ RoleIDs: []string{"owner-role-id"},
+ }).Return([]membership.Member{
+ {PrincipalID: "user-1", PrincipalType: schema.UserPrincipal},
+ }, nil)
+ m.usrSvc.EXPECT().GetByIDs(mock.Anything, []string{"user-1"}).
+ Return([]user.User{{ID: "user-1", Email: "owner@acme.test", Title: "Owner"}}, nil)
+ // ...and mail each owner once the org is gone
+ m.dialer.EXPECT().FromHeader().Return("no-reply@frontier.test")
+ m.dialer.EXPECT().DialAndSend(mock.Anything).Return(nil)
+
m.subSvc.EXPECT().DeleteByCustomer(mock.Anything, c).Return(nil)
m.invocSvc.EXPECT().DeleteByCustomer(mock.Anything, c).Return(nil)
m.checkoutSvc.EXPECT().List(mock.Anything, checkout.Filter{CustomerID: "cust-1"}).
@@ -354,7 +383,7 @@ func TestDeleteOrganization(t *testing.T) {
m := newMocks(t)
c := customer.Customer{ID: "cust-1", ProviderID: "stripe-1"}
- m.orgSvc.EXPECT().Get(mock.Anything, "org-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)
@@ -401,7 +430,7 @@ func TestDeleteOrganization(t *testing.T) {
m := newMocks(t)
c := customer.Customer{ID: "cust-1", ProviderID: "stripe-1"}
- m.orgSvc.EXPECT().Get(mock.Anything, "org-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)
@@ -430,7 +459,7 @@ func TestDeleteOrganization(t *testing.T) {
m := newMocks(t)
c := customer.Customer{ID: "cust-1", ProviderID: "stripe-1"}
- m.orgSvc.EXPECT().Get(mock.Anything, "org-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)
@@ -456,7 +485,7 @@ func TestDeleteOrganization(t *testing.T) {
m := newMocks(t)
c := customer.Customer{ID: "cust-1", ProviderID: "stripe-1"}
- m.orgSvc.EXPECT().Get(mock.Anything, "org-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)
@@ -500,7 +529,7 @@ func TestDeleteOrganization(t *testing.T) {
m := newMocks(t)
c := customer.Customer{ID: "cust-1", ProviderID: "stripe-1"}
- m.orgSvc.EXPECT().Get(mock.Anything, "org-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)
@@ -528,7 +557,7 @@ func TestDeleteOrganization(t *testing.T) {
m := newMocks(t)
c := customer.Customer{ID: "cust-offline", ProviderID: ""}
- m.orgSvc.EXPECT().Get(mock.Anything, "org-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)
@@ -547,8 +576,8 @@ func TestDeleteOrganization(t *testing.T) {
m := newMocks(t)
// a disabled org is still deletable
- m.orgSvc.EXPECT().Get(mock.Anything, "org-1").
- Return(organization.Organization{}, organization.ErrDisabled)
+ m.orgSvc.EXPECT().GetRaw(mock.Anything, "org-1").
+ Return(organization.Organization{ID: "org-1", State: organization.Disabled}, nil)
m.custSvc.EXPECT().List(mock.Anything, customer.Filter{OrgID: "org-1"}).
Return([]customer.Customer{}, nil)
m.projSvc.EXPECT().List(mock.Anything, project.Filter{OrgID: "org-1"}).
@@ -571,7 +600,7 @@ func TestDeleteOrganization(t *testing.T) {
m := newMocks(t)
c := customer.Customer{ID: "cust-1", ProviderID: "stripe-1"}
- m.orgSvc.EXPECT().Get(mock.Anything, "org-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)
@@ -591,7 +620,7 @@ func TestDeleteOrganization(t *testing.T) {
t.Run("propagates error when service user list fails", func(t *testing.T) {
m := newMocks(t)
- m.orgSvc.EXPECT().Get(mock.Anything, "org-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{}, nil)
@@ -609,7 +638,7 @@ func TestDeleteOrganization(t *testing.T) {
t.Run("propagates error when service user delete fails", func(t *testing.T) {
m := newMocks(t)
- m.orgSvc.EXPECT().Get(mock.Anything, "org-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{}, nil)
@@ -643,6 +672,10 @@ func TestDeleteCustomers(t *testing.T) {
}, nil)
m.checkoutSvc.EXPECT().DeleteByCustomer(mock.Anything, "cust-1").Return(nil)
m.creditSvc.EXPECT().GetBalance(mock.Anything, "cust-1").Return(100, nil)
+ m.creditSvc.EXPECT().List(mock.Anything, credit.Filter{CustomerID: "cust-1"}).
+ Return([]credit.Transaction{
+ {Type: credit.CreditType, Source: credit.SourceSystemBuyEvent, Amount: 40},
+ }, nil)
m.creditSvc.EXPECT().DeleteByAccountID(mock.Anything, "cust-1").Return(nil)
m.custSvc.EXPECT().Delete(mock.Anything, "cust-1").Return(nil)