From de13f71ed89a9a91d8ab9db153144bbdb84c00d6 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Thu, 13 Aug 2026 16:50:48 +0530 Subject: [PATCH] feat(deleter): email the org owners when a delete forfeits tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting an organization forfeits any unused tokens. The owners now get an email saying how many tokens were left and that support can transfer the amount. The subject and body come from the billing config (billing.token_forfeit_notice.subject/body) as Go templates with .Amount, .Org, and .User, falling back to plain built-in text — the same pattern the PAT expiry alerts use. The mail goes through the shared mailer dialer, only after the delete fully succeeded, and a send failure is logged, never returned. The owners and the amount are read before teardown (they are gone after). The org lookup at the start switched from Get to GetRaw so a disabled org keeps its title in the email and stays deletable without a special case. --- billing/config.go | 10 + cmd/serve.go | 1 + core/deleter/forfeit_notice.go | 245 +++++++++++++++++++++ core/deleter/mocks/membership_service.go | 61 +++++ core/deleter/mocks/organization_service.go | 22 +- core/deleter/mocks/role_service.go | 60 ++++- core/deleter/mocks/user_service.go | 60 +++++ core/deleter/service.go | 97 +++++--- core/deleter/service_test.go | 73 ++++-- 9 files changed, 569 insertions(+), 60 deletions(-) create mode 100644 core/deleter/forfeit_notice.go diff --git a/billing/config.go b/billing/config.go index c2b6b172f1..555a614aaf 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 2f7547f128..a2a4572660 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 0000000000..ac51b76288 --- /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 ad519d11cb..0ff75d628f 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 ea1e53564a..4e3c2f3390 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 8a24e5e826..a7ebc4e70a 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 06a9b6bc4b..b9e8ad97f8 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 2acebbf11f..c2f11b0fdf 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 800b6ba881..ffd8fda7f9 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)