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 a092b9c60..9486430d7 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -582,6 +582,7 @@ func buildAPIDependencies( groupService, membershipService, policyService, roleService, invitationService, userService, userPATService, serviceUserService, customerService, subscriptionService, invoiceService, checkoutService, creditService, orgKycService, planService, cfg.Billing.AccountConfig.DefaultPlan, + 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..7be09b8f4 --- /dev/null +++ b/core/deleter/forfeit_notice.go @@ -0,0 +1,164 @@ +package deleter + +import ( + "bytes" + "context" + "fmt" + htmltemplate "html/template" + "log/slog" + texttemplate "text/template" + + "github.com/raystack/frontier/billing/customer" + "github.com/raystack/frontier/core/organization" + "github.com/raystack/frontier/core/policy" + "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 with {{.Amount}} unused tokens remaining. Contact support to get the 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 +} + +// forfeitNotice is everything sendForfeitNotices needs once the org is gone. +// It has to be collected before teardown removes the owners and the token +// balances. +type forfeitNotice struct { + Amount int64 + 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. +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 int64 + 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 { + total += balance + } + } + if total == 0 { + return forfeitNotice{}, 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) + } + policies, err := d.policyService.List(ctx, policy.Filter{ + OrgID: org.ID, + RoleID: ownerRole.ID, + PrincipalType: schema.UserPrincipal, + }) + if err != nil { + return forfeitNotice{}, fmt.Errorf("failed to list the organization owners: %w", err) + } + ownerIDs := make([]string, 0, len(policies)) + seen := make(map[string]struct{}, len(policies)) + for _, p := range policies { + if _, ok := seen[p.PrincipalID]; ok { + continue + } + seen[p.PrincipalID] = struct{}{} + ownerIDs = append(ownerIDs, p.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, Owners: owners}, 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 + } + + for _, owner := range notice.Owners { + data := forfeitNoticeData{ + Amount: notice.Amount, + User: owner, + Org: org, + } + 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) + } +} + +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/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 8d00300d3..59f27f306 100644 --- a/core/deleter/service.go +++ b/core/deleter/service.go @@ -12,6 +12,8 @@ import ( "github.com/raystack/frontier/core/authenticate" + "github.com/raystack/frontier/billing" + "github.com/raystack/frontier/billing/checkout" "github.com/raystack/frontier/billing/invoice" @@ -38,6 +40,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 { @@ -46,11 +50,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 } @@ -82,6 +87,7 @@ type InvitationService interface { } type UserService interface { + GetByIDs(ctx context.Context, userIDs []string) ([]user.User, error) Delete(ctx context.Context, id string) error } @@ -152,6 +158,10 @@ type Service struct { // is deleted; subscriptions on any other plan block the delete until the // caller downgrades them defaultPlan string + // 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, @@ -164,27 +174,30 @@ func NewCascadeDeleter(orgService OrganizationService, projService ProjectServic customerService CustomerService, subService SubscriptionService, invoiceService InvoiceService, checkoutService CheckoutService, creditService CreditService, kycService KycService, - planService PlanService, defaultPlan string) *Service { + planService PlanService, defaultPlan string, + 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, - defaultPlan: defaultPlan, + 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, + defaultPlan: defaultPlan, + mailDialer: mailDialer, + forfeitNoticeConfig: forfeitNoticeConfig, } } @@ -236,8 +249,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 } @@ -247,6 +261,13 @@ 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 { return err @@ -338,6 +359,11 @@ 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 } diff --git a/core/deleter/service_test.go b/core/deleter/service_test.go index 959702fef..504afdd05 100644 --- a/core/deleter/service_test.go +++ b/core/deleter/service_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/google/uuid" + "github.com/raystack/frontier/billing" "github.com/raystack/frontier/billing/checkout" "github.com/raystack/frontier/billing/customer" "github.com/raystack/frontier/billing/invoice" @@ -21,7 +22,9 @@ import ( "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" ) @@ -45,6 +48,7 @@ type deleterMocks struct { creditSvc *mocks.CreditService kycSvc *mocks.KycService planSvc *mocks.PlanService + dialer *mailermocks.Dialer } func newMocks(t *testing.T) deleterMocks { @@ -68,6 +72,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 @@ -80,7 +85,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, "standard") + m.planSvc, "standard", m.dialer, billing.TokenForfeitNoticeConfig{}) } func TestDeleteProject(t *testing.T) { @@ -141,7 +146,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 pre-flight and DeleteCustomers both list customers @@ -213,7 +218,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 @@ -225,7 +230,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) @@ -264,7 +269,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) @@ -289,7 +294,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,12 +314,12 @@ 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().SyncWithProvider(mock.Anything, c).Return(nil) @@ -324,6 +329,23 @@ func TestDeleteOrganization(t *testing.T) { 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.polSvc.EXPECT().List(mock.Anything, policy.Filter{ + OrgID: "org-1", + RoleID: "owner-role-id", + PrincipalType: schema.UserPrincipal, + }).Return([]policy.Policy{ + {ID: "pol-1", PrincipalID: "user-1"}, + {ID: "pol-2", PrincipalID: "user-1"}, + }, 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"}). @@ -355,7 +377,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) @@ -403,7 +425,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) @@ -433,7 +455,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) @@ -452,8 +474,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"}). @@ -476,7 +498,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) @@ -497,7 +519,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) @@ -515,7 +537,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)