diff --git a/billing/config.go b/billing/config.go index 555a614aa..c9d483018 100644 --- a/billing/config.go +++ b/billing/config.go @@ -14,15 +14,15 @@ 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"` + // OrgDeleteNotice is the email sent to the organization owners when + // their organization is deleted. Subject and Body are Go templates; + // empty values fall back to plain built-in text. + OrgDeleteNotice OrgDeleteNoticeConfig `yaml:"org_delete_notice" mapstructure:"org_delete_notice"` RefreshInterval RefreshInterval `yaml:"refresh_interval" mapstructure:"refresh_interval"` } -type TokenForfeitNoticeConfig struct { +type OrgDeleteNoticeConfig struct { Subject string `yaml:"subject" mapstructure:"subject"` Body string `yaml:"body" mapstructure:"body"` } diff --git a/cmd/serve.go b/cmd/serve.go index a2a457266..2a4786a46 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -588,7 +588,7 @@ func buildAPIDependencies( groupService, membershipService, policyService, roleService, invitationService, userService, userPATService, serviceUserService, customerService, subscriptionService, invoiceService, checkoutService, creditService, orgKycService, planService, - mailDialer, cfg.Billing.TokenForfeitNotice, + mailDialer, cfg.Billing.OrgDeleteNotice, ) // 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/delete_notice.go similarity index 68% rename from core/deleter/forfeit_notice.go rename to core/deleter/delete_notice.go index 7be613226..6b0822324 100644 --- a/core/deleter/forfeit_notice.go +++ b/core/deleter/delete_notice.go @@ -23,11 +23,11 @@ import ( // 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}}` + defaultDeleteNoticeSubject = "Your organization was deleted" + defaultDeleteNoticeBody = `{{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}}.{{if .Purchased}} Unused purchased tokens will be settled by the support team.{{end}}` ) -type forfeitNoticeData struct { +type deleteNoticeData struct { // Amount is the total number of tokens the delete forfeited. Amount int64 // User is the owner receiving this mail. @@ -35,7 +35,7 @@ type forfeitNoticeData struct { // Org is the deleted organization. Org organization.Organization // Purchased is the share of Amount that came from purchases; only this - // part is transferable. + // part is settled with the customer. Purchased int64 // DeletedBy identifies who ran the delete; empty when the caller is // not known. @@ -48,11 +48,11 @@ type accountTokens struct { Purchased int64 } -// forfeitNotice is everything sendForfeitNotices needs once the org is gone. +// deleteNotice is everything sendDeleteNotices 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 { +type deleteNotice struct { Amount int64 Purchased int64 Accounts map[string]accountTokens @@ -62,54 +62,50 @@ type forfeitNotice struct { 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. +// collectDeleteNotice reads the token balances the delete is about to +// forfeit. 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) { +// purchased share is settled with the customer. +func (d Service) collectDeleteNotice(ctx context.Context, customers []customer.Customer) (deleteNotice, 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) + return deleteNotice{}, 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 + return deleteNotice{}, 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{ + return deleteNotice{ 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. +// resolveOwners finds the users holding the org owner role; every one of +// them gets the delete notice. 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) + slog.WarnContext(ctx, "failed to resolve the organization owner role for the delete notice", "org_id", orgID, "error", err) return nil } members, err := d.membershipService.ListPrincipalsByResource(ctx, orgID, schema.OrganizationNamespace, membership.MemberFilter{ @@ -117,7 +113,7 @@ func (d Service) resolveOwners(ctx context.Context, orgID string) []user.User { 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) + slog.WarnContext(ctx, "failed to list the organization owners for the delete notice", "org_id", orgID, "error", err) return nil } ownerIDs := make([]string, 0, len(members)) @@ -126,7 +122,7 @@ func (d Service) resolveOwners(ctx context.Context, orgID string) []user.User { } 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) + slog.WarnContext(ctx, "failed to fetch the organization owners for the delete notice", "org_id", orgID, "error", err) return nil } return owners @@ -139,7 +135,7 @@ func (d Service) resolveOwners(ctx context.Context, orgID string) []user.User { // 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) { +func (d Service) recoverForfeitFromAudit(ctx context.Context, orgID string, notice *deleteNotice) { logs, err := audit.GetService(ctx).List(ctx, audit.Filter{ OrgID: orgID, Action: string(audit.BillingTokensForfeitedEvent), @@ -167,9 +163,6 @@ func (d Service) recoverForfeitFromAudit(ctx context.Context, orgID string, noti 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, @@ -212,7 +205,8 @@ func (d Service) purchasedTokens(ctx context.Context, accountID string, balance 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 + // tokens back (a refund); it must not count toward the + // settled share bought -= t.Amount } } @@ -222,41 +216,41 @@ func (d Service) purchasedTokens(ctx context.Context, accountID string, balance 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) { +// sendDeleteNotices emails every org owner that the organization was +// deleted. The org is already gone at this point, so failures are logged +// and never returned. +func (d Service) sendDeleteNotices(ctx context.Context, org organization.Organization, notice deleteNotice) { if d.mailDialer == nil { - slog.WarnContext(ctx, "no mail dialer configured, skipping token forfeit notices", "org_id", org.ID) + slog.WarnContext(ctx, "no mail dialer configured, skipping the delete 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) + slog.WarnContext(ctx, "the organization was deleted but no owner could be notified", "org_id", org.ID, "amount", notice.Amount, "purchased", notice.Purchased) return } - subjectTpl := d.forfeitNoticeConfig.Subject + subjectTpl := d.deleteNoticeConfig.Subject if subjectTpl == "" { - subjectTpl = defaultForfeitNoticeSubject + subjectTpl = defaultDeleteNoticeSubject } - bodyTpl := d.forfeitNoticeConfig.Body + bodyTpl := d.deleteNoticeConfig.Body if bodyTpl == "" { - bodyTpl = defaultForfeitNoticeBody + bodyTpl = defaultDeleteNoticeBody } // 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) + slog.WarnContext(ctx, "failed to parse the delete 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) + slog.WarnContext(ctx, "failed to parse the delete notice body template", "org_id", org.ID, "error", err) return } deletedBy := deletedByFromContext(ctx) for _, owner := range notice.Owners { - data := forfeitNoticeData{ + data := deleteNoticeData{ Amount: notice.Amount, Purchased: notice.Purchased, User: owner, @@ -265,11 +259,11 @@ func (d Service) sendForfeitNotices(ctx context.Context, org organization.Organi } 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) + slog.WarnContext(ctx, "failed to render the delete 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) + slog.WarnContext(ctx, "failed to render the delete notice body", "org_id", org.ID, "user_email", owner.Email, "error", err) continue } @@ -279,10 +273,10 @@ func (d Service) sendForfeitNotices(ctx context.Context, org organization.Organi 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) + slog.WarnContext(ctx, "failed to send the delete 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) + slog.InfoContext(ctx, "sent the organization delete notice", "org_id", org.ID, "user_email", owner.Email, "amount", notice.Amount) } } diff --git a/core/deleter/service.go b/core/deleter/service.go index d27c9eebc..8ef5ca4fb 100644 --- a/core/deleter/service.go +++ b/core/deleter/service.go @@ -157,10 +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 + // mailDialer and deleteNoticeConfig drive the email that tells the org + // owners their organization was deleted + mailDialer mailer.Dialer + deleteNoticeConfig billing.OrgDeleteNoticeConfig } func NewCascadeDeleter(orgService OrganizationService, projService ProjectService, @@ -174,28 +174,28 @@ func NewCascadeDeleter(orgService OrganizationService, projService ProjectServic invoiceService InvoiceService, checkoutService CheckoutService, creditService CreditService, kycService KycService, planService PlanService, - mailDialer mailer.Dialer, forfeitNoticeConfig billing.TokenForfeitNoticeConfig) *Service { + mailDialer mailer.Dialer, deleteNoticeConfig billing.OrgDeleteNoticeConfig) *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, - mailDialer: mailDialer, - forfeitNoticeConfig: forfeitNoticeConfig, + 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, + deleteNoticeConfig: deleteNoticeConfig, } } @@ -263,10 +263,10 @@ 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) + // the delete notice reads the token 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.collectDeleteNotice(ctx, customers) if err != nil { return err } @@ -281,6 +281,10 @@ func (d Service) DeleteOrganization(ctx context.Context, id string) error { return err } + // the delete is going ahead: find who to notify while the owner + // policies still exist. Best-effort — the delete never depends on it + notice.Owners = d.resolveOwners(ctx, id) + // delete all billing accounts if err := d.deleteCustomers(ctx, id, customers, notice.Accounts); err != nil { return err @@ -373,10 +377,8 @@ func (d Service) DeleteOrganization(ctx context.Context, id string) error { 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) - } + // the org is gone; every owner gets the delete notice + d.sendDeleteNotices(ctx, org, notice) return nil } diff --git a/core/deleter/service_test.go b/core/deleter/service_test.go index dcdb02b78..f3955cb55 100644 --- a/core/deleter/service_test.go +++ b/core/deleter/service_test.go @@ -16,6 +16,7 @@ import ( "github.com/raystack/frontier/billing/plan" "github.com/raystack/frontier/billing/product" "github.com/raystack/frontier/billing/subscription" + "github.com/raystack/frontier/core/audit" "github.com/raystack/frontier/core/deleter" "github.com/raystack/frontier/core/deleter/mocks" "github.com/raystack/frontier/core/group" @@ -28,6 +29,7 @@ import ( "github.com/raystack/frontier/core/role" "github.com/raystack/frontier/core/serviceuser" "github.com/raystack/frontier/core/user" + "github.com/raystack/frontier/core/webhook" "github.com/raystack/frontier/internal/bootstrap/schema" mailermocks "github.com/raystack/frontier/pkg/mailer/mocks" "github.com/stretchr/testify/assert" @@ -95,7 +97,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.dialer, billing.TokenForfeitNoticeConfig{}) + m.planSvc, m.dialer, billing.OrgDeleteNoticeConfig{}) } func TestDeleteProject(t *testing.T) { @@ -152,6 +154,21 @@ func TestDeleteProject(t *testing.T) { }) } +// expectOwnerNotified wires the owner lookup and one delivered mail; a +// successful delete must always end in the notice being sent. +func expectOwnerNotified(m deleterMocks) { + 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"}}, nil) + m.dialer.EXPECT().FromHeader().Return("no-reply@frontier.test") + m.dialer.EXPECT().DialAndSend(mock.Anything).Return(nil) +} + func TestDeleteOrganization(t *testing.T) { t.Run("full cascade delete", func(t *testing.T) { m := newMocks(t) @@ -220,6 +237,26 @@ func TestDeleteOrganization(t *testing.T) { // org model m.orgSvc.EXPECT().DeleteModel(mock.Anything, "org-1").Return(nil) + // every delete notifies the owners, tokens or not + 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"}}, nil) + 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) + body := strings.ReplaceAll(raw.String(), "=\r\n", "") + assert.Contains(t, body, "was deleted") + // no tokens were on the org, so the settlement line is absent + assert.NotContains(t, body, "settled") + }).Return(nil) + err := m.build().DeleteOrganization(context.Background(), "org-1") assert.NoError(t, err) }) @@ -340,7 +377,7 @@ 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 + // every delete resolves the owners; this one also has a balance 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{ @@ -361,8 +398,10 @@ func TestDeleteOrganization(t *testing.T) { 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") + // the amounts stay out of the mail; the audit records carry them + assert.NotContains(t, body, "40") + assert.NotContains(t, body, "tokens remaining") + assert.Contains(t, body, "Unused purchased tokens will be settled by the support team") }).Return(nil) m.subSvc.EXPECT().DeleteByCustomer(mock.Anything, c).Return(nil) @@ -435,6 +474,8 @@ func TestDeleteOrganization(t *testing.T) { Return([]role.Role{}, nil) m.orgSvc.EXPECT().DeleteModel(mock.Anything, "org-1").Return(nil) + expectOwnerNotified(m) + err := m.build().DeleteOrganization(context.Background(), "org-1") assert.NoError(t, err) }) @@ -534,6 +575,8 @@ func TestDeleteOrganization(t *testing.T) { Return([]role.Role{}, nil) m.orgSvc.EXPECT().DeleteModel(mock.Anything, "org-1").Return(nil) + expectOwnerNotified(m) + err := m.build().DeleteOrganization(context.Background(), "org-1") assert.NoError(t, err) }) @@ -631,6 +674,10 @@ func TestDeleteOrganization(t *testing.T) { Return(errors.New("kyc delete failed")) // strict mocks: no org policy, role, or org model deletion may happen + // owner lookup is best-effort; failing it must not affect the delete + m.roleSvc.EXPECT().Get(mock.Anything, schema.RoleOrganizationOwner). + Return(role.Role{}, errors.New("no owners in this test")) + err := m.build().DeleteOrganization(context.Background(), "org-1") assert.ErrorContains(t, err, "kyc delete failed") }) @@ -652,6 +699,10 @@ func TestDeleteOrganization(t *testing.T) { Return(errors.New("provider is down")) // strict mocks: no policy, project, group, or org deletion may happen + // owner lookup is best-effort; failing it must not affect the delete + m.roleSvc.EXPECT().Get(mock.Anything, schema.RoleOrganizationOwner). + Return(role.Role{}, errors.New("no owners in this test")) + err := m.build().DeleteOrganization(context.Background(), "org-1") assert.ErrorContains(t, err, "provider is down") }) @@ -670,6 +721,10 @@ func TestDeleteOrganization(t *testing.T) { m.suSvc.EXPECT().List(mock.Anything, serviceuser.Filter{OrgID: "org-1"}). Return(nil, errors.New("su list failed")) + // owner lookup is best-effort; failing it must not affect the delete + m.roleSvc.EXPECT().Get(mock.Anything, schema.RoleOrganizationOwner). + Return(role.Role{}, errors.New("no owners in this test")) + err := m.build().DeleteOrganization(context.Background(), "org-1") assert.ErrorContains(t, err, "su list failed") }) @@ -689,6 +744,10 @@ func TestDeleteOrganization(t *testing.T) { Return([]serviceuser.ServiceUser{{ID: "su-1"}}, nil) m.suSvc.EXPECT().Delete(mock.Anything, "su-1").Return(errors.New("su delete failed")) + // owner lookup is best-effort; failing it must not affect the delete + m.roleSvc.EXPECT().Get(mock.Anything, schema.RoleOrganizationOwner). + Return(role.Role{}, errors.New("no owners in this test")) + err := m.build().DeleteOrganization(context.Background(), "org-1") assert.ErrorContains(t, err, "su delete failed") assert.ErrorContains(t, err, "su-1") @@ -765,6 +824,160 @@ func TestCheckOrganizationDelete(t *testing.T) { }) } +// fakeAuditRepository keeps the audit logs in memory so the tests can put a +// real audit service into the context. List returns newest first, matching +// the postgres repository. +type fakeAuditRepository struct { + logs []audit.Log +} + +func (f *fakeAuditRepository) Create(_ context.Context, l *audit.Log) error { + f.logs = append(f.logs, *l) + return nil +} + +func (f *fakeAuditRepository) List(_ context.Context, flt audit.Filter) ([]audit.Log, error) { + var out []audit.Log + for i := len(f.logs) - 1; i >= 0; i-- { + l := f.logs[i] + if flt.OrgID != "" && l.OrgID != flt.OrgID { + continue + } + if flt.Action != "" && l.Action != flt.Action { + continue + } + out = append(out, l) + } + return out, nil +} + +func (f *fakeAuditRepository) GetByID(context.Context, string) (audit.Log, error) { + return audit.Log{}, nil +} + +type noopWebhookService struct{} + +func (noopWebhookService) Publish(context.Context, webhook.Event) error { return nil } + +func auditContext(repo *fakeAuditRepository) context.Context { + return audit.SetContextWithService(context.Background(), audit.NewService("test", repo, noopWebhookService{})) +} + +func TestForfeitAuditRecord(t *testing.T) { + t.Run("the forfeited amounts land in the audit record", func(t *testing.T) { + m := newMocks(t) + repo := &fakeAuditRepository{} + + c := customer.Customer{ID: "cust-1", ProviderID: "stripe-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) + m.invocSvc.EXPECT().ListPayableOnProvider(mock.Anything, c). + Return([]invoice.Invoice{}, nil) + m.creditSvc.EXPECT().GetBalance(mock.Anything, "cust-1").Return(100, nil) + // 60 bought, 20 of those refunded: the record must say purchased 40 + 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}, + }, nil) + m.subSvc.EXPECT().List(mock.Anything, subscription.Filter{CustomerID: "cust-1"}). + Return([]subscription.Subscription{}, 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"}). + Return([]checkout.Checkout{}, nil) + m.checkoutSvc.EXPECT().DeleteByCustomer(mock.Anything, "cust-1").Return(nil) + m.creditSvc.EXPECT().DeleteByAccountID(mock.Anything, "cust-1").Return(nil) + m.custSvc.EXPECT().Delete(mock.Anything, "cust-1").Return(nil) + m.projSvc.EXPECT().List(mock.Anything, project.Filter{OrgID: "org-1"}). + Return([]project.Project{}, nil) + m.grpSvc.EXPECT().List(mock.Anything, group.Filter{OrganizationID: "org-1"}). + Return([]group.Group{}, nil) + m.suSvc.EXPECT().List(mock.Anything, serviceuser.Filter{OrgID: "org-1"}). + Return([]serviceuser.ServiceUser{}, nil) + m.invSvc.EXPECT().List(mock.Anything, invitation.Filter{OrgID: "org-1"}). + Return([]invitation.Invitation{}, nil) + m.kycSvc.EXPECT().DeleteKyc(mock.Anything, "org-1").Return(nil) + m.polSvc.EXPECT().List(mock.Anything, policy.Filter{OrgID: "org-1"}). + Return([]policy.Policy{}, nil) + m.roleSvc.EXPECT().List(mock.Anything, role.Filter{OrgID: "org-1"}). + Return([]role.Role{}, nil) + m.orgSvc.EXPECT().DeleteModel(mock.Anything, "org-1").Return(nil) + expectOwnerNotified(m) + + err := m.build().DeleteOrganization(auditContext(repo), "org-1") + assert.NoError(t, err) + + forfeits, listErr := repo.List(context.Background(), audit.Filter{ + OrgID: "org-1", + Action: string(audit.BillingTokensForfeitedEvent), + }) + assert.NoError(t, listErr) + assert.Len(t, forfeits, 1) + assert.Equal(t, "cust-1", forfeits[0].Target.ID) + assert.Equal(t, "100", forfeits[0].Metadata["amount"]) + assert.Equal(t, "40", forfeits[0].Metadata["purchased"]) + }) + + t.Run("a retry recovers the amounts from the audit records", func(t *testing.T) { + m := newMocks(t) + // the earlier attempt tore down billing and wrote the record before + // failing; this retry finds no billing accounts at all + repo := &fakeAuditRepository{logs: []audit.Log{{ + OrgID: "org-1", + Action: string(audit.BillingTokensForfeitedEvent), + Target: audit.Target{ID: "cust-1", Type: "billing_account"}, + Metadata: map[string]string{ + "amount": "750", + "purchased": "200", + }, + }}} + + 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{}, nil) + m.projSvc.EXPECT().List(mock.Anything, project.Filter{OrgID: "org-1"}). + Return([]project.Project{}, nil) + m.grpSvc.EXPECT().List(mock.Anything, group.Filter{OrganizationID: "org-1"}). + Return([]group.Group{}, nil) + m.suSvc.EXPECT().List(mock.Anything, serviceuser.Filter{OrgID: "org-1"}). + Return([]serviceuser.ServiceUser{}, nil) + m.invSvc.EXPECT().List(mock.Anything, invitation.Filter{OrgID: "org-1"}). + Return([]invitation.Invitation{}, nil) + m.kycSvc.EXPECT().DeleteKyc(mock.Anything, "org-1").Return(nil) + m.polSvc.EXPECT().List(mock.Anything, policy.Filter{OrgID: "org-1"}). + Return([]policy.Policy{}, nil) + m.roleSvc.EXPECT().List(mock.Anything, role.Filter{OrgID: "org-1"}). + Return([]role.Role{}, nil) + m.orgSvc.EXPECT().DeleteModel(mock.Anything, "org-1").Return(nil) + + 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"}}, nil) + 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) + body := strings.ReplaceAll(raw.String(), "=\r\n", "") + // the recovered purchased share puts the settlement line back + assert.Contains(t, body, "Unused purchased tokens will be settled by the support team") + }).Return(nil) + + err := m.build().DeleteOrganization(auditContext(repo), "org-1") + assert.NoError(t, err) + }) +} + func TestDeleteCustomers(t *testing.T) { t.Run("deletes subscriptions invoices checkouts transactions and customer", func(t *testing.T) { m := newMocks(t) diff --git a/docs/content/docs/reference/webhook.mdx b/docs/content/docs/reference/webhook.mdx index c4704f0eb..e16a130fe 100644 --- a/docs/content/docs/reference/webhook.mdx +++ b/docs/content/docs/reference/webhook.mdx @@ -53,6 +53,8 @@ app.permission.deleted app.permission.checked app.billing.entitlement.checked +app.billing.checkout.deleted +app.billing.tokens.forfeited app.policy.created app.policy.deleted diff --git a/web/sdk/admin/utils/webhook-events.ts b/web/sdk/admin/utils/webhook-events.ts index 4038b8b29..5ab8c9558 100644 --- a/web/sdk/admin/utils/webhook-events.ts +++ b/web/sdk/admin/utils/webhook-events.ts @@ -23,6 +23,8 @@ const events = [ "app.permission.checked", "app.billing.entitlement.checked", + "app.billing.checkout.deleted", + "app.billing.tokens.forfeited", "app.policy.created", "app.policy.deleted",