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..7be613226
--- /dev/null
+++ b/core/deleter/forfeit_notice.go
@@ -0,0 +1,303 @@
+package deleter
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ htmltemplate "html/template"
+ "log/slog"
+ "strconv"
+ texttemplate "text/template"
+
+ "github.com/raystack/frontier/billing/credit"
+
+ "github.com/raystack/frontier/billing/customer"
+ "github.com/raystack/frontier/core/audit"
+ "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 and .Purchased (lt .Purchased .Amount)}}, of which {{.Purchased}} came from purchases{{end}}. {{if .Purchased}}Contact support to get the purchased amount transferred to your bank account.{{else}}These were complimentary tokens, so there is no amount to transfer.{{end}}`
+)
+
+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
+ // Balances holds every account's balance, positive or not, so the
+ // blocker check can reuse the reads
+ Balances map[string]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.
+//
+// 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, customers []customer.Customer) (forfeitNotice, error) {
+ var total, purchased int64
+ accounts := make(map[string]accountTokens, len(customers))
+ balances := make(map[string]int64, 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)
+ }
+ balances[c.ID] = balance
+ 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, Balances: balances}, nil
+ }
+
+ return forfeitNotice{
+ Amount: total,
+ Purchased: purchased,
+ Accounts: accounts,
+ Balances: balances,
+ Owners: d.resolveOwners(ctx, org.ID),
+ }, nil
+}
+
+// resolveOwners finds the users holding the org owner role. It is
+// best-effort: the notice email must not make the delete depend on the
+// policy machinery, so a failed lookup logs and returns no owners.
+func (d Service) resolveOwners(ctx context.Context, orgID string) []user.User {
+ ownerRole, err := d.roleService.Get(ctx, schema.RoleOrganizationOwner)
+ if err != nil {
+ slog.WarnContext(ctx, "failed to resolve the organization owner role for the forfeit notice", "org_id", orgID, "error", err)
+ return nil
+ }
+ members, err := d.membershipService.ListPrincipalsByResource(ctx, orgID, schema.OrganizationNamespace, membership.MemberFilter{
+ PrincipalType: schema.UserPrincipal,
+ RoleIDs: []string{ownerRole.ID},
+ })
+ if err != nil {
+ slog.WarnContext(ctx, "failed to list the organization owners for the forfeit notice", "org_id", orgID, "error", err)
+ return nil
+ }
+ 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 {
+ slog.WarnContext(ctx, "failed to fetch the organization owners for the forfeit notice", "org_id", orgID, "error", err)
+ return nil
+ }
+ return owners
+}
+
+// recoverForfeitFromAudit adds the forfeits a failed earlier teardown wrote
+// audit records for, so the retry that completes the delete still reports
+// them in the owner notice. It reconciles per billing account: only the
+// newest record per account counts (a retried teardown can write the same
+// forfeit twice), and an account that still holds a live balance is already
+// counted by the collection pass, so its records are skipped. Best-effort:
+// without a readable audit store the notice keeps only the live amounts.
+func (d Service) recoverForfeitFromAudit(ctx context.Context, orgID string, notice *forfeitNotice) {
+ logs, err := audit.GetService(ctx).List(ctx, audit.Filter{
+ OrgID: orgID,
+ Action: string(audit.BillingTokensForfeitedEvent),
+ })
+ if err != nil {
+ slog.WarnContext(ctx, "failed to check audit records for forfeited tokens", "org_id", orgID, "error", err)
+ return
+ }
+ // the list is newest first, so the first record per account wins
+ seen := map[string]struct{}{}
+ for _, l := range logs {
+ accountID := l.Target.ID
+ if accountID == "" {
+ continue
+ }
+ if _, ok := seen[accountID]; ok {
+ continue
+ }
+ seen[accountID] = struct{}{}
+ if _, live := notice.Accounts[accountID]; live {
+ continue
+ }
+ amount, _ := strconv.ParseInt(l.Metadata["amount"], 10, 64)
+ purchased, _ := strconv.ParseInt(l.Metadata["purchased"], 10, 64)
+ notice.Amount += amount
+ notice.Purchased += purchased
+ }
+ if notice.Amount > 0 && len(notice.Owners) == 0 {
+ notice.Owners = d.resolveOwners(ctx, orgID)
+ }
+}
+
+// resolveAccountTokens returns one account's balance and purchased share,
+// from the caller's already-collected amounts when given, otherwise read
+// fresh.
+func (d Service) resolveAccountTokens(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.Source != credit.SourceSystemBuyEvent {
+ continue
+ }
+ switch t.Type {
+ case credit.CreditType:
+ bought += t.Amount
+ case credit.DebitType:
+ // a debit recorded against the buy source takes purchased
+ // tokens back (a refund); it must not count as transferable
+ bought -= t.Amount
+ }
+ }
+ if bought < 0 {
+ bought = 0
+ }
+ 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
+ }
+ if len(notice.Owners) == 0 {
+ slog.WarnContext(ctx, "tokens were forfeited but no owner could be notified", "org_id", org.ID, "amount", notice.Amount, "purchased", notice.Purchased)
+ return
+ }
+ subjectTpl := d.forfeitNoticeConfig.Subject
+ if subjectTpl == "" {
+ subjectTpl = defaultForfeitNoticeSubject
+ }
+ bodyTpl := d.forfeitNoticeConfig.Body
+ if bodyTpl == "" {
+ bodyTpl = defaultForfeitNoticeBody
+ }
+ // the templates are the same for every owner; parse them once
+ subjectTmpl, err := texttemplate.New("subject").Parse(subjectTpl)
+ if err != nil {
+ slog.WarnContext(ctx, "failed to parse token forfeit notice subject template", "org_id", org.ID, "error", err)
+ return
+ }
+ bodyTmpl, err := htmltemplate.New("body").Parse(bodyTpl)
+ if err != nil {
+ slog.WarnContext(ctx, "failed to parse token forfeit notice body template", "org_id", org.ID, "error", err)
+ return
+ }
+
+ deletedBy := deletedByFromContext(ctx)
+ for _, owner := range notice.Owners {
+ data := forfeitNoticeData{
+ Amount: notice.Amount,
+ Purchased: notice.Purchased,
+ User: owner,
+ Org: org,
+ DeletedBy: deletedBy,
+ }
+ var subject, body bytes.Buffer
+ if err := subjectTmpl.Execute(&subject, data); err != nil {
+ slog.WarnContext(ctx, "failed to render token forfeit notice subject", "org_id", org.ID, "user_email", owner.Email, "error", err)
+ continue
+ }
+ if err := bodyTmpl.Execute(&body, data); err != nil {
+ slog.WarnContext(ctx, "failed to render token forfeit notice body", "org_id", org.ID, "user_email", owner.Email, "error", err)
+ continue
+ }
+
+ msg := mail.NewMessage()
+ msg.SetHeader("From", d.mailDialer.FromHeader())
+ msg.SetHeader("To", owner.Email)
+ msg.SetHeader("Subject", subject.String())
+ msg.SetBody("text/html", body.String())
+ 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
+}
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 50e6e4312..ada605bf6 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,
}
}
@@ -233,8 +250,9 @@ func (d Service) DeleteGroup(ctx context.Context, id string) error {
// repeatable one (and a mistyped org id from a success).
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
}
@@ -245,14 +263,26 @@ 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; its balance reads are reused by the
+ // blocker check and the teardown audit below
+ notice, err := d.collectForfeitNotice(ctx, org, customers)
+ if err != nil {
+ return err
+ }
+ // a retry whose earlier attempt already tore down some accounts finds
+ // their balances gone; their forfeits are recovered per account from the
+ // audit records that attempt wrote
+ d.recoverForfeitFromAudit(ctx, id, ¬ice)
+
// clear what we can and collect what still blocks the delete, before
// touching any data
- if err := d.ensureDeletable(ctx, id, customers); err != nil {
+ if err := d.ensureDeletable(ctx, id, customers, notice.Balances); err != nil {
return err
}
// delete all billing accounts
- if err := d.deleteCustomers(ctx, id, customers); err != nil {
+ if err := d.deleteCustomers(ctx, id, customers, notice.Accounts); err != nil {
return err
}
@@ -342,11 +372,17 @@ 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 lists the org's billing accounts itself; DeleteOrganization
-// goes through deleteCustomers with the accounts it already listed.
+// DeleteCustomers lists the accounts and reads the balances itself;
+// DeleteOrganization goes through deleteCustomers with what it already
+// collected.
func (d Service) DeleteCustomers(ctx context.Context, id string) error {
customers, err := d.customerService.List(ctx, customer.Filter{
OrgID: id,
@@ -354,10 +390,13 @@ func (d Service) DeleteCustomers(ctx context.Context, id string) error {
if err != nil {
return err
}
- return d.deleteCustomers(ctx, id, customers)
+ return d.deleteCustomers(ctx, id, customers, nil)
}
-func (d Service) deleteCustomers(ctx context.Context, id string, customers []customer.Customer) error {
+// 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, customers []customer.Customer, amounts map[string]accountTokens) error {
for _, c := range customers {
// cancels active subscriptions on the billing provider and removes local records
if err := d.subService.DeleteByCustomer(ctx, c); err != nil {
@@ -400,17 +439,21 @@ func (d Service) deleteCustomers(ctx context.Context, id string, customers []cus
}
}
// 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.resolveAccountTokens(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)
}
@@ -482,7 +525,7 @@ func (d Service) DeleteUser(ctx context.Context, userID string) error {
// Accounts without a billing provider are only checked for token balances:
// their subscription and invoice rows have nothing behind them the caller
// could cancel or pay.
-func (d Service) ensureDeletable(ctx context.Context, id string, customers []customer.Customer) error {
+func (d Service) ensureDeletable(ctx context.Context, id string, customers []customer.Customer, balances map[string]int64) error {
// each plan resolves at most once per call, and only when a running
// subscription actually references it
paidPlans := map[string]bool{}
@@ -503,10 +546,7 @@ func (d Service) ensureDeletable(ctx context.Context, id string, customers []cus
blockers = append(blockers, bs...)
}
- balance, err := d.creditService.GetBalance(ctx, c.ID)
- if err != nil {
- return fmt.Errorf("failed to check token balance of billing account[%s]: %w", c.ID, err)
- }
+ balance := balances[c.ID]
// the balance goes below zero when the account has an overdraft
// floor (credit_min under zero, the postpaid setup) and tokens were
// spent on credit. That debt is money owed, so it must be settled
diff --git a/core/deleter/service_test.go b/core/deleter/service_test.go
index 1a22ba0f1..4950549a7 100644
--- a/core/deleter/service_test.go
+++ b/core/deleter/service_test.go
@@ -1,12 +1,16 @@
package deleter_test
import (
+ "bytes"
"context"
"errors"
+ "strings"
"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,15 +20,19 @@ 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"
+ "gopkg.in/mail.v2"
)
type deleterMocks struct {
@@ -46,6 +54,7 @@ type deleterMocks struct {
creditSvc *mocks.CreditService
kycSvc *mocks.KycService
planSvc *mocks.PlanService
+ dialer *mailermocks.Dialer
}
func newMocks(t *testing.T) deleterMocks {
@@ -69,6 +78,7 @@ func newMocks(t *testing.T) deleterMocks {
creditSvc: mocks.NewCreditService(t),
kycSvc: mocks.NewKycService(t),
planSvc: mocks.NewPlanService(t),
+ dialer: mailermocks.NewDialer(t),
}
// plans resolve lazily by the subscription's plan id; stub a paid and a
// free one for every test
@@ -85,7 +95,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) {
@@ -146,7 +156,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
@@ -217,7 +227,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
@@ -229,7 +239,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 +274,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)
@@ -288,7 +298,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)
@@ -307,20 +317,54 @@ 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 remaining tokens were bought, 20 of those were refunded
+ // back to the buyer, 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.DebitType, Source: credit.SourceSystemBuyEvent, Amount: 20},
+ {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; the body carries the
+ // purchased share with the refunded part taken out: 60 bought - 20
+ // refunded = 40 of the 100 remaining
+ m.dialer.EXPECT().FromHeader().Return("no-reply@frontier.test")
+ m.dialer.EXPECT().DialAndSend(mock.Anything).Run(func(msg *mail.Message) {
+ var raw bytes.Buffer
+ _, err := msg.WriteTo(&raw)
+ assert.NoError(t, err)
+ // undo the quoted-printable soft line breaks before matching
+ body := strings.ReplaceAll(raw.String(), "=\r\n", "")
+ assert.Contains(t, body, "of which 40 came from purchases")
+ assert.Contains(t, body, "Contact support")
+ }).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"}).
@@ -352,7 +396,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)
@@ -399,7 +443,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)
@@ -428,7 +472,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)
@@ -454,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)
@@ -498,7 +542,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)
@@ -526,7 +570,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)
@@ -552,7 +596,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)
@@ -571,8 +615,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"}).
@@ -595,7 +639,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)
@@ -615,7 +659,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)
@@ -633,7 +677,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)
@@ -667,6 +711,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)