Skip to content
Draft
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: 10 additions & 0 deletions billing/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,19 @@ 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"`

RefreshInterval RefreshInterval `yaml:"refresh_interval" mapstructure:"refresh_interval"`
}

type TokenForfeitNoticeConfig struct {
Subject string `yaml:"subject" mapstructure:"subject"`
Body string `yaml:"body" mapstructure:"body"`
}

type RefreshInterval struct {
Customer time.Duration `yaml:"customer" mapstructure:"customer" default:"1m"`
Subscription time.Duration `yaml:"subscription" mapstructure:"subscription" default:"1m"`
Expand Down
1 change: 1 addition & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,7 @@ func buildAPIDependencies(
groupService, membershipService, policyService, roleService, invitationService, userService, userPATService,
serviceUserService, customerService, subscriptionService, invoiceService, checkoutService,
creditService, orgKycService, planService, cfg.Billing.AccountConfig.DefaultPlan,
mailDialer, cfg.Billing.TokenForfeitNotice,
)

// we should default it with a stdout logger repository as postgres can start to bloat really fast
Expand Down
164 changes: 164 additions & 0 deletions core/deleter/forfeit_notice.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package deleter

import (
"bytes"
"context"
"fmt"
htmltemplate "html/template"
"log/slog"
texttemplate "text/template"

"github.com/raystack/frontier/billing/customer"
"github.com/raystack/frontier/core/organization"
"github.com/raystack/frontier/core/policy"
"github.com/raystack/frontier/core/user"
"github.com/raystack/frontier/internal/bootstrap/schema"
"gopkg.in/mail.v2"
)

// 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 with <b>{{.Amount}}</b> unused tokens remaining. Contact support to get the amount transferred to your bank account.`
)

type forfeitNoticeData 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
}

// forfeitNotice is everything sendForfeitNotices needs once the org is gone.
// It has to be collected before teardown removes the owners and the token
// balances.
type forfeitNotice struct {
Amount int64
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.
func (d Service) collectForfeitNotice(ctx context.Context, org organization.Organization) (forfeitNotice, error) {
customers, err := d.customerService.List(ctx, customer.Filter{
OrgID: org.ID,
})
if err != nil {
return forfeitNotice{}, err
}

var total int64
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)
}
if balance > 0 {
total += balance
}
}
if total == 0 {
return forfeitNotice{}, nil
}

ownerRole, err := d.roleService.Get(ctx, schema.RoleOrganizationOwner)
if err != nil {
return forfeitNotice{}, fmt.Errorf("failed to resolve the organization owner role: %w", err)
}
policies, err := d.policyService.List(ctx, policy.Filter{
OrgID: org.ID,
RoleID: ownerRole.ID,
PrincipalType: schema.UserPrincipal,
})
if err != nil {
return forfeitNotice{}, fmt.Errorf("failed to list the organization owners: %w", err)
}
ownerIDs := make([]string, 0, len(policies))
seen := make(map[string]struct{}, len(policies))
for _, p := range policies {
if _, ok := seen[p.PrincipalID]; ok {
continue
}
seen[p.PrincipalID] = struct{}{}
ownerIDs = append(ownerIDs, p.PrincipalID)
}
owners, err := d.userService.GetByIDs(ctx, ownerIDs)
if err != nil {
return forfeitNotice{}, fmt.Errorf("failed to fetch the organization owners: %w", err)
}
return forfeitNotice{Amount: total, Owners: owners}, 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) {
if d.mailDialer == nil {
slog.WarnContext(ctx, "no mail dialer configured, skipping token forfeit notices", "org_id", org.ID)
return
}
subjectTpl := d.forfeitNoticeConfig.Subject
if subjectTpl == "" {
subjectTpl = defaultForfeitNoticeSubject
}
bodyTpl := d.forfeitNoticeConfig.Body
if bodyTpl == "" {
bodyTpl = defaultForfeitNoticeBody
}

for _, owner := range notice.Owners {
data := forfeitNoticeData{
Amount: notice.Amount,
User: owner,
Org: org,
}
subject, err := renderForfeitSubject(subjectTpl, data)
if err != nil {
slog.WarnContext(ctx, "failed to render token forfeit notice subject", "org_id", org.ID, "error", err)
return
}
body, err := renderForfeitBody(bodyTpl, data)
if err != nil {
slog.WarnContext(ctx, "failed to render token forfeit notice body", "org_id", org.ID, "error", err)
return
}

msg := mail.NewMessage()
msg.SetHeader("From", d.mailDialer.FromHeader())
msg.SetHeader("To", owner.Email)
msg.SetHeader("Subject", subject)
msg.SetBody("text/html", body)
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)
continue
}
slog.InfoContext(ctx, "sent token forfeit notice", "org_id", org.ID, "user_email", owner.Email, "amount", notice.Amount)
}
}

func renderForfeitSubject(tpl string, data forfeitNoticeData) (string, error) {
t, err := texttemplate.New("subject").Parse(tpl)
if err != nil {
return "", err
}
var out bytes.Buffer
if err := t.Execute(&out, data); err != nil {
return "", err
}
return out.String(), nil
}

func renderForfeitBody(tpl string, data forfeitNoticeData) (string, error) {
t, err := htmltemplate.New("body").Parse(tpl)
if err != nil {
return "", err
}
var out bytes.Buffer
if err := t.Execute(&out, data); err != nil {
return "", err
}
return out.String(), nil
}
22 changes: 11 additions & 11 deletions core/deleter/mocks/organization_service.go

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

60 changes: 58 additions & 2 deletions core/deleter/mocks/role_service.go

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

60 changes: 60 additions & 0 deletions core/deleter/mocks/user_service.go

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

Loading
Loading