From 81dcec94d8caa8289c124c4319fe84d965b13f49 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 | 164 +++++++++++++++++++++ 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 | 72 ++++++--- core/deleter/service_test.go | 56 ++++--- 8 files changed, 392 insertions(+), 53 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 a092b9c600..9486430d74 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 0000000000..7be09b8f42 --- /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 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 8d00300d35..59f27f3064 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 959702fef6..504afdd054 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)