Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions billing/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
88 changes: 41 additions & 47 deletions core/deleter/forfeit_notice.go → core/deleter/delete_notice.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,19 @@ 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}}<br><br>Your organization <b>{{if .Org.Title}}{{.Org.Title}}{{else}}{{.Org.Name}}{{end}}</b> was deleted{{if .DeletedBy}} by <b>{{.DeletedBy}}</b>{{end}} with <b>{{.Amount}}</b> unused tokens remaining{{if and .Purchased (lt .Purchased .Amount)}}, of which <b>{{.Purchased}}</b> 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}}<br><br>Your organization <b>{{if .Org.Title}}{{.Org.Title}}{{else}}{{.Org.Name}}{{end}}</b> was deleted{{if .DeletedBy}} by <b>{{.DeletedBy}}</b>{{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.
User user.User
// Org is the deleted organization.
Org organization.Organization
// Purchased is the share of Amount that came from purchases; only this
// part is transferable.
// part is settled with the customer.
Purchased int64
// DeletedBy identifies who ran the delete; empty when the caller is
// not known.
Expand All @@ -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
Expand All @@ -62,62 +62,58 @@ 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.
Comment thread
whoAbhishekSah marked this conversation as resolved.
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{
PrincipalType: schema.UserPrincipal,
RoleIDs: []string{ownerRole.ID},
})
if err != nil {
slog.WarnContext(ctx, "failed to list the organization owners for the forfeit notice", "org_id", orgID, "error", err)
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))
Expand All @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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,
Expand All @@ -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
}

Expand All @@ -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)
}
}

Expand Down
68 changes: 35 additions & 33 deletions core/deleter/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// delete all billing accounts
if err := d.deleteCustomers(ctx, id, customers, notice.Accounts); err != nil {
return err
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading