From dd97cc41465f5fbff5845cd345c2f7478b571a82 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Tue, 25 Aug 2026 12:42:06 +0530 Subject: [PATCH 1/4] feat(deleter): send the delete notice on every organization delete The owner email used to go out only when the delete forfeited tokens. Every deleted organization now notifies all of its owners, whether it held tokens or not. The owners are resolved after the blocker check passes and before teardown removes their policies, still best-effort so the delete never depends on the policy machinery. Because the mail is no longer about forfeits, the config key moves from billing.token_forfeit_notice to billing.org_delete_notice (nothing has deployed the old key), and the built-in default becomes a plain deletion notice: it names no token amounts and only mentions that purchased tokens will be settled when some existed. The template still receives .Amount and .Purchased for deployments that want them, and the forfeit audit records keep the exact per-account numbers, which is where support settles from. --- billing/config.go | 10 +-- cmd/serve.go | 2 +- .../{forfeit_notice.go => delete_notice.go} | 81 +++++++++---------- core/deleter/service.go | 68 ++++++++-------- core/deleter/service_test.go | 52 +++++++++++- 5 files changed, 129 insertions(+), 84 deletions(-) rename core/deleter/{forfeit_notice.go => delete_notice.go} (69%) 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 69% rename from core/deleter/forfeit_notice.go rename to core/deleter/delete_notice.go index 7be613226..ebb373563 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. @@ -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,28 +62,28 @@ 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 @@ -91,25 +91,25 @@ func (d Service) collectForfeitNotice(ctx context.Context, org organization.Orga } } if total == 0 { - return forfeitNotice{Accounts: accounts, Balances: balances}, nil + return deleteNotice{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 +117,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 +126,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 +139,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 +167,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, @@ -222,41 +219,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 +262,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 +276,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..e39446b87 100644 --- a/core/deleter/service_test.go +++ b/core/deleter/service_test.go @@ -95,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.dialer, billing.TokenForfeitNoticeConfig{}) + m.planSvc, m.dialer, billing.OrgDeleteNoticeConfig{}) } func TestDeleteProject(t *testing.T) { @@ -220,6 +220,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) }) @@ -361,8 +381,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 +457,10 @@ func TestDeleteOrganization(t *testing.T) { Return([]role.Role{}, nil) m.orgSvc.EXPECT().DeleteModel(mock.Anything, "org-1").Return(nil) + // 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.NoError(t, err) }) @@ -534,6 +560,10 @@ func TestDeleteOrganization(t *testing.T) { Return([]role.Role{}, nil) m.orgSvc.EXPECT().DeleteModel(mock.Anything, "org-1").Return(nil) + // 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.NoError(t, err) }) @@ -631,6 +661,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 +686,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 +708,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 +731,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") From e1ffe6990de73650fdab867f6cb14c9e7a257448 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Tue, 25 Aug 2026 13:57:56 +0530 Subject: [PATCH 2/4] fix(deleter): remember the notice recipients across retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teardown deletes the owner policies before the roles and the org row. A delete failing in that window left the retry unable to resolve the owners, so the completing retry emailed no one. The owner ids now go into an audit record before teardown starts, and a retry that finds no resolvable owners loads them from there — the users themselves outlive the org, so ids are all it needs. Best-effort on both sides, like the notice itself: without a readable audit store the send path logs that no owner could be notified. --- .../orgpats/mocks/project_service.go | 2 +- core/audit/audit.go | 2 + core/deleter/delete_notice.go | 49 +++++++++++++++++++ core/deleter/service.go | 10 +++- 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/core/aggregates/orgpats/mocks/project_service.go b/core/aggregates/orgpats/mocks/project_service.go index 3dd430db6..59dd48b65 100644 --- a/core/aggregates/orgpats/mocks/project_service.go +++ b/core/aggregates/orgpats/mocks/project_service.go @@ -94,4 +94,4 @@ func NewProjectService(t interface { t.Cleanup(func() { mock.AssertExpectations(t) }) return mock -} \ No newline at end of file +} diff --git a/core/audit/audit.go b/core/audit/audit.go index c68b896b1..98d2a20df 100644 --- a/core/audit/audit.go +++ b/core/audit/audit.go @@ -98,6 +98,7 @@ const ( BillingAccountDetailsUpdatedEvent EventName = "app.billing.account.details.updated" BillingCheckoutDeletedEvent EventName = "app.billing.checkout.deleted" BillingTokensForfeitedEvent EventName = "app.billing.tokens.forfeited" + OrgDeleteNoticeRecipientsEvent EventName = "app.organization.delete.recipients" ) var systemEvents = []EventName{ @@ -115,6 +116,7 @@ var systemEvents = []EventName{ OrgDisabledEvent, BillingCheckoutDeletedEvent, BillingTokensForfeitedEvent, + OrgDeleteNoticeRecipientsEvent, } func IsSystemEvent(event EventName) bool { diff --git a/core/deleter/delete_notice.go b/core/deleter/delete_notice.go index ebb373563..b3799c7b8 100644 --- a/core/deleter/delete_notice.go +++ b/core/deleter/delete_notice.go @@ -7,6 +7,7 @@ import ( htmltemplate "html/template" "log/slog" "strconv" + "strings" texttemplate "text/template" "github.com/raystack/frontier/billing/credit" @@ -132,6 +133,54 @@ func (d Service) resolveOwners(ctx context.Context, orgID string) []user.User { return owners } +// recordNoticeRecipients writes the owner ids to an audit record before +// teardown starts. Teardown deletes the owner policies before the org row, +// so a retry that begins after that point cannot resolve the owners any +// more; the record is what it recovers them from. The users themselves +// outlive the org, so ids are enough. Best-effort, like the notice itself. +func (d Service) recordNoticeRecipients(ctx context.Context, orgID string, owners []user.User) { + ids := make([]string, 0, len(owners)) + for _, owner := range owners { + ids = append(ids, owner.ID) + } + if err := audit.GetAuditor(ctx, orgID).LogWithAttrs(audit.OrgDeleteNoticeRecipientsEvent, audit.Target{ + ID: orgID, + Type: "organization", + }, map[string]string{ + "owner_ids": strings.Join(ids, ","), + }); err != nil { + slog.WarnContext(ctx, "failed to record the delete notice recipients", "org_id", orgID, "error", err) + } +} + +// recoverRecipientsFromAudit loads the owner ids a failed earlier attempt +// recorded, for a retry that starts after the owner policies were already +// deleted. Best-effort: without a readable audit store there is nothing to +// recover and the notice goes unsent, which the send path logs. +func (d Service) recoverRecipientsFromAudit(ctx context.Context, orgID string) []user.User { + logs, err := audit.GetService(ctx).List(ctx, audit.Filter{ + OrgID: orgID, + Action: string(audit.OrgDeleteNoticeRecipientsEvent), + }) + if err != nil { + slog.WarnContext(ctx, "failed to check audit records for the delete notice recipients", "org_id", orgID, "error", err) + return nil + } + for _, l := range logs { + raw := l.Metadata["owner_ids"] + if raw == "" { + continue + } + owners, err := d.userService.GetByIDs(ctx, strings.Split(raw, ",")) + if err != nil { + slog.WarnContext(ctx, "failed to fetch the recorded delete notice recipients", "org_id", orgID, "error", err) + return nil + } + return owners + } + return nil +} + // 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 diff --git a/core/deleter/service.go b/core/deleter/service.go index 8ef5ca4fb..a2649a594 100644 --- a/core/deleter/service.go +++ b/core/deleter/service.go @@ -282,8 +282,16 @@ func (d Service) DeleteOrganization(ctx context.Context, id string) error { } // the delete is going ahead: find who to notify while the owner - // policies still exist. Best-effort — the delete never depends on it + // policies still exist. Best-effort — the delete never depends on it. + // The owner ids are also written to an audit record: teardown deletes + // the owner policies before the org row, so a retry that starts after + // that point can only learn the recipients from the record notice.Owners = d.resolveOwners(ctx, id) + if len(notice.Owners) == 0 { + notice.Owners = d.recoverRecipientsFromAudit(ctx, id) + } else { + d.recordNoticeRecipients(ctx, id, notice.Owners) + } // delete all billing accounts if err := d.deleteCustomers(ctx, id, customers, notice.Accounts); err != nil { From 56231aa395ea552467dfbcd13ca369ad6cb35dbf Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Tue, 25 Aug 2026 15:12:17 +0530 Subject: [PATCH 3/4] Revert "fix(deleter): remember the notice recipients across retries" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recipients record turned internal retry state into an audit event, and every audit log fans out to the registered webhooks — pushing owner user ids to third-party endpoints for a window that spans two local statements at the very tail of teardown. The audit log stays what it was meant to be here: the record of the forfeited amounts. A delete that fails in that narrow window and completes on retry logs that no owner could be notified, which is the visible, honest outcome. --- .../orgpats/mocks/project_service.go | 2 +- core/audit/audit.go | 2 - core/deleter/delete_notice.go | 49 ------------------- core/deleter/service.go | 10 +--- 4 files changed, 2 insertions(+), 61 deletions(-) diff --git a/core/aggregates/orgpats/mocks/project_service.go b/core/aggregates/orgpats/mocks/project_service.go index 59dd48b65..3dd430db6 100644 --- a/core/aggregates/orgpats/mocks/project_service.go +++ b/core/aggregates/orgpats/mocks/project_service.go @@ -94,4 +94,4 @@ func NewProjectService(t interface { t.Cleanup(func() { mock.AssertExpectations(t) }) return mock -} +} \ No newline at end of file diff --git a/core/audit/audit.go b/core/audit/audit.go index 98d2a20df..c68b896b1 100644 --- a/core/audit/audit.go +++ b/core/audit/audit.go @@ -98,7 +98,6 @@ const ( BillingAccountDetailsUpdatedEvent EventName = "app.billing.account.details.updated" BillingCheckoutDeletedEvent EventName = "app.billing.checkout.deleted" BillingTokensForfeitedEvent EventName = "app.billing.tokens.forfeited" - OrgDeleteNoticeRecipientsEvent EventName = "app.organization.delete.recipients" ) var systemEvents = []EventName{ @@ -116,7 +115,6 @@ var systemEvents = []EventName{ OrgDisabledEvent, BillingCheckoutDeletedEvent, BillingTokensForfeitedEvent, - OrgDeleteNoticeRecipientsEvent, } func IsSystemEvent(event EventName) bool { diff --git a/core/deleter/delete_notice.go b/core/deleter/delete_notice.go index b3799c7b8..ebb373563 100644 --- a/core/deleter/delete_notice.go +++ b/core/deleter/delete_notice.go @@ -7,7 +7,6 @@ import ( htmltemplate "html/template" "log/slog" "strconv" - "strings" texttemplate "text/template" "github.com/raystack/frontier/billing/credit" @@ -133,54 +132,6 @@ func (d Service) resolveOwners(ctx context.Context, orgID string) []user.User { return owners } -// recordNoticeRecipients writes the owner ids to an audit record before -// teardown starts. Teardown deletes the owner policies before the org row, -// so a retry that begins after that point cannot resolve the owners any -// more; the record is what it recovers them from. The users themselves -// outlive the org, so ids are enough. Best-effort, like the notice itself. -func (d Service) recordNoticeRecipients(ctx context.Context, orgID string, owners []user.User) { - ids := make([]string, 0, len(owners)) - for _, owner := range owners { - ids = append(ids, owner.ID) - } - if err := audit.GetAuditor(ctx, orgID).LogWithAttrs(audit.OrgDeleteNoticeRecipientsEvent, audit.Target{ - ID: orgID, - Type: "organization", - }, map[string]string{ - "owner_ids": strings.Join(ids, ","), - }); err != nil { - slog.WarnContext(ctx, "failed to record the delete notice recipients", "org_id", orgID, "error", err) - } -} - -// recoverRecipientsFromAudit loads the owner ids a failed earlier attempt -// recorded, for a retry that starts after the owner policies were already -// deleted. Best-effort: without a readable audit store there is nothing to -// recover and the notice goes unsent, which the send path logs. -func (d Service) recoverRecipientsFromAudit(ctx context.Context, orgID string) []user.User { - logs, err := audit.GetService(ctx).List(ctx, audit.Filter{ - OrgID: orgID, - Action: string(audit.OrgDeleteNoticeRecipientsEvent), - }) - if err != nil { - slog.WarnContext(ctx, "failed to check audit records for the delete notice recipients", "org_id", orgID, "error", err) - return nil - } - for _, l := range logs { - raw := l.Metadata["owner_ids"] - if raw == "" { - continue - } - owners, err := d.userService.GetByIDs(ctx, strings.Split(raw, ",")) - if err != nil { - slog.WarnContext(ctx, "failed to fetch the recorded delete notice recipients", "org_id", orgID, "error", err) - return nil - } - return owners - } - return nil -} - // 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 diff --git a/core/deleter/service.go b/core/deleter/service.go index a2649a594..8ef5ca4fb 100644 --- a/core/deleter/service.go +++ b/core/deleter/service.go @@ -282,16 +282,8 @@ func (d Service) DeleteOrganization(ctx context.Context, id string) error { } // the delete is going ahead: find who to notify while the owner - // policies still exist. Best-effort — the delete never depends on it. - // The owner ids are also written to an audit record: teardown deletes - // the owner policies before the org row, so a retry that starts after - // that point can only learn the recipients from the record + // policies still exist. Best-effort — the delete never depends on it notice.Owners = d.resolveOwners(ctx, id) - if len(notice.Owners) == 0 { - notice.Owners = d.recoverRecipientsFromAudit(ctx, id) - } else { - d.recordNoticeRecipients(ctx, id, notice.Owners) - } // delete all billing accounts if err := d.deleteCustomers(ctx, id, customers, notice.Accounts); err != nil { From 3e3e87658d60f49c060197368cfe332ca78ffb91 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Tue, 25 Aug 2026 15:16:54 +0530 Subject: [PATCH 4/4] fix(deleter): review follow-ups on the delete notice The forfeit record and its retry recovery now have real coverage: the tests put an audit service with an in-memory repository into the context, assert the record carries the amount and the purchased share, and assert a retry that finds no billing accounts rebuilds the notice from the record. The two successful-delete subscription tests assert the owner mail instead of silencing it through a failed owner lookup. The dead zero-total return in collectDeleteNotice is gone, the last "transferable" comments say "settled" like the mail copy, and the webhook event list plus its docs page now name app.billing.tokens.forfeited and app.billing.checkout.deleted, which audit publishing already sends to subscribers. --- core/deleter/delete_notice.go | 9 +- core/deleter/service_test.go | 181 +++++++++++++++++++++++- docs/content/docs/reference/webhook.mdx | 2 + web/sdk/admin/utils/webhook-events.ts | 2 + 4 files changed, 181 insertions(+), 13 deletions(-) diff --git a/core/deleter/delete_notice.go b/core/deleter/delete_notice.go index ebb373563..6b0822324 100644 --- a/core/deleter/delete_notice.go +++ b/core/deleter/delete_notice.go @@ -35,7 +35,7 @@ type deleteNoticeData 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. @@ -90,10 +90,6 @@ func (d Service) collectDeleteNotice(ctx context.Context, customers []customer.C accounts[c.ID] = accountTokens{Balance: balance, Purchased: bought} } } - if total == 0 { - return deleteNotice{Accounts: accounts, Balances: balances}, nil - } - return deleteNotice{ Amount: total, Purchased: purchased, @@ -209,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 } } diff --git a/core/deleter/service_test.go b/core/deleter/service_test.go index e39446b87..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" @@ -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) @@ -360,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{ @@ -457,9 +474,7 @@ func TestDeleteOrganization(t *testing.T) { Return([]role.Role{}, nil) m.orgSvc.EXPECT().DeleteModel(mock.Anything, "org-1").Return(nil) - // 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")) + expectOwnerNotified(m) err := m.build().DeleteOrganization(context.Background(), "org-1") assert.NoError(t, err) @@ -560,9 +575,7 @@ func TestDeleteOrganization(t *testing.T) { Return([]role.Role{}, nil) m.orgSvc.EXPECT().DeleteModel(mock.Anything, "org-1").Return(nil) - // 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")) + expectOwnerNotified(m) err := m.build().DeleteOrganization(context.Background(), "org-1") assert.NoError(t, err) @@ -811,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",