Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion core/aggregates/orgpats/mocks/project_service.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions core/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
whoAbhishekSah marked this conversation as resolved.
Outdated
)

var systemEvents = []EventName{
Expand All @@ -115,6 +116,7 @@ var systemEvents = []EventName{
OrgDisabledEvent,
BillingCheckoutDeletedEvent,
BillingTokensForfeitedEvent,
OrgDeleteNoticeRecipientsEvent,
}

func IsSystemEvent(event EventName) bool {
Expand Down
130 changes: 88 additions & 42 deletions core/deleter/forfeit_notice.go → core/deleter/delete_notice.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
htmltemplate "html/template"
"log/slog"
"strconv"
"strings"
texttemplate "text/template"

"github.com/raystack/frontier/billing/credit"
Expand All @@ -23,11 +24,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}}<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.
Expand All @@ -48,11 +49,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 +63,62 @@ 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 deleteNotice{Accounts: accounts, Balances: balances}, nil
Comment thread
whoAbhishekSah marked this conversation as resolved.
Outdated
}

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,20 +127,68 @@ 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
}

// 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) {
Comment thread
whoAbhishekSah marked this conversation as resolved.
Outdated
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
// newest record per account counts (a retried teardown can write the same
// forfeit twice), and an account that still holds a live balance is already
// counted by the collection pass, so its records are skipped. Best-effort:
// without a readable audit store the notice keeps only the live amounts.
func (d Service) recoverForfeitFromAudit(ctx context.Context, orgID string, notice *forfeitNotice) {
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 +216,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 @@ -222,41 +268,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 +311,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 +325,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
Loading
Loading