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)