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)