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",