Skip to content

Commit

Permalink
feat: offline billing accounts in org
Browse files Browse the repository at this point in the history
- Offline billing accounts are not registered in billing provider
like Stripe by default.
- Only online b/a can use CheckoutAPI so when requesting to create
new Checkout, it will automatically try to register the billing
account to provider if it is offline
- DelegatedCheckout with virtual credits can be used with offline
customers
- Offline customers will not interact with billing provider at all
and can't support subscriptions as well
- A offline customer can be migrated to online using RegisterBillingAccount
API
- raystack/proton#360
- A change in frontier env/yaml configs are introduced to handle
offline accounts better
```yaml
billing:
  # default currency to be used for billing if not provided by the user
  # e.g. usd, inr, eur
  default_currency: "inr"
  # billing customer account configuration
  customer:
    # automatically create a default customer account when an org is created
    auto_create_with_org: true
    # name of the plan that should be used subscribed automatically when the org is created
    # it also automatically creates an empty billing account under the org
    default_plan: ""
    # default offline status for the customer account, if true the customer account
    # will not be registered in billing provider
    default_offline: false
    # free credits to be added to the customer account when created as a part of the org
    onboard_credits_with_org: 0
```

Signed-off-by: Kush Sharma <thekushsharma@gmail.com>
  • Loading branch information
kushsharma committed Jun 7, 2024
1 parent 9c159db commit 54b62aa
Show file tree
Hide file tree
Showing 31 changed files with 15,816 additions and 12,241 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ TAG := $(shell git rev-list --tags --max-count=1)
VERSION := $(shell git describe --tags ${TAG})
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto ui compose-up-dev
.DEFAULT_GOAL := build
PROTON_COMMIT := "0aa36e5747c07cb06eef76f7b53136f1945ef971"
PROTON_COMMIT := "1a90d5b88ce930d2062b2d8fb2ed0ce3c799eddb"

ui:
@echo " > generating ui build"
Expand Down
9 changes: 5 additions & 4 deletions billing/checkout/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ type Repository interface {
type CustomerService interface {
GetByID(ctx context.Context, id string) (customer.Customer, error)
List(ctx context.Context, filter customer.Filter) ([]customer.Customer, error)
RegisterToProviderIfRequired(ctx context.Context, customerID string) (customer.Customer, error)
}

type PlanService interface {
Expand Down Expand Up @@ -168,7 +169,7 @@ func (s *Service) backgroundSync(ctx context.Context) {
}

for _, customer := range customers {
if customer.DeletedAt != nil || customer.ProviderID == "" {
if customer.DeletedAt != nil || customer.IsOffline() {
continue
}
if err := s.SyncWithProvider(ctx, customer.ID); err != nil {
Expand All @@ -179,8 +180,8 @@ func (s *Service) backgroundSync(ctx context.Context) {
}

func (s *Service) Create(ctx context.Context, ch Checkout) (Checkout, error) {
// get billing
billingCustomer, err := s.customerService.GetByID(ctx, ch.CustomerID)
// need to make it register itself to provider first if needed
billingCustomer, err := s.customerService.RegisterToProviderIfRequired(ctx, ch.CustomerID)
if err != nil {
return Checkout{}, err
}
Expand Down Expand Up @@ -773,7 +774,7 @@ func (s *Service) Apply(ctx context.Context, ch Checkout) (*subscription.Subscri
}

// checkout could be for a plan or a product
if ch.PlanID != "" {
if ch.PlanID != "" && !billingCustomer.IsOffline() {
plan, err := s.planService.GetByID(ctx, ch.PlanID)
if err != nil {
return nil, nil, err
Expand Down
9 changes: 8 additions & 1 deletion billing/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@ type Config struct {
StripeWebhookSecrets []string `yaml:"stripe_webhook_secrets" mapstructure:"stripe_webhook_secrets"`
// PlansPath is a directory path where plans are defined
PlansPath string `yaml:"plans_path" mapstructure:"plans_path"`
DefaultPlan string `yaml:"default_plan" mapstructure:"default_plan"`
DefaultCurrency string `yaml:"default_currency" mapstructure:"default_currency"`

AccountConfig AccountConfig `yaml:"customer" mapstructure:"customer"`
PlanChangeConfig PlanChangeConfig `yaml:"plan_change" mapstructure:"plan_change"`
SubscriptionConfig SubscriptionConfig `yaml:"subscription" mapstructure:"subscription"`
ProductConfig ProductConfig `yaml:"product" mapstructure:"product"`
}

type AccountConfig struct {
AutoCreateWithOrg bool `yaml:"auto_create_with_org" mapstructure:"auto_create_with_org"`
DefaultPlan string `yaml:"default_plan" mapstructure:"default_plan"`
DefaultOffline bool `yaml:"default_offline" mapstructure:"default_offline"`
OnboardCreditsWithOrg int64 `yaml:"onboard_credits_with_org" mapstructure:"onboard_credits_with_org"`
}

type PlanChangeConfig struct {
// ProrationBehavior is the behavior of proration when a plan is changed
// possible values: create_prorations, none, always_invoice
Expand Down
12 changes: 9 additions & 3 deletions billing/customer/customer.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ const (
)

type Customer struct {
ID string
OrgID string
ProviderID string // identifier set by the billing engine provider
ID string
OrgID string
// Provider id identifier set by the billing engine provider
// could be empty if the customer is created as offline
ProviderID string

Name string
Email string
Expand All @@ -50,6 +52,10 @@ type Customer struct {
DeletedAt *time.Time
}

func (c Customer) IsOffline() bool {
return c.ProviderID == ""
}

type Address struct {
City string `json:"city"`
Country string `json:"country"`
Expand Down
71 changes: 63 additions & 8 deletions billing/customer/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,23 @@ func NewService(stripeClient *client.API, repository Repository) *Service {
}
}

func (s *Service) Create(ctx context.Context, customer Customer) (Customer, error) {
func (s *Service) Create(ctx context.Context, customer Customer, offline bool) (Customer, error) {
// set defaults
customer.State = ActiveState

// offline mode, we don't need to create the customer in billing provider
if !offline {
stripeCustomer, err := s.RegisterToProvider(ctx, customer)
if err != nil {
return Customer{}, err
}
customer.ProviderID = stripeCustomer.ID
}
return s.repository.Create(ctx, customer)
}

func (s *Service) RegisterToProvider(ctx context.Context, customer Customer) (*stripe.Customer, error) {
// create a new customer in stripe
var customerTaxes []*stripe.CustomerTaxIDDataParams = nil
for _, tax := range customer.TaxData {
customerTaxes = append(customerTaxes, &stripe.CustomerTaxIDDataParams{
Expand Down Expand Up @@ -82,16 +98,29 @@ func (s *Service) Create(ctx context.Context, customer Customer) (Customer, erro
switch stripeErr.Code {
case stripe.ErrorCodeParameterMissing:
// stripe error
return Customer{}, fmt.Errorf("missing parameter while registering to biller: %s", stripeErr.Error())
return nil, fmt.Errorf("missing parameter while registering to biller: %s", stripeErr.Error())
}
}
return Customer{}, fmt.Errorf("failed to register in billing provider: %w", err)
return nil, fmt.Errorf("failed to register in billing provider: %w", err)
}
customer.ProviderID = stripeCustomer.ID
if !stripeCustomer.Deleted {
customer.State = ActiveState

return stripeCustomer, nil
}

func (s *Service) RegisterToProviderIfRequired(ctx context.Context, customerID string) (Customer, error) {
custmr, err := s.repository.GetByID(ctx, customerID)
if err != nil {
return Customer{}, err
}
return s.repository.Create(ctx, customer)
if custmr.IsOffline() {
stripeCustomer, err := s.RegisterToProvider(ctx, custmr)
if err != nil {
return Customer{}, err
}
custmr.ProviderID = stripeCustomer.ID
return s.repository.UpdateByID(ctx, custmr)
}
return custmr, nil
}

func (s *Service) Update(ctx context.Context, customer Customer) (Customer, error) {
Expand Down Expand Up @@ -160,6 +189,32 @@ func (s *Service) GetByOrgID(ctx context.Context, orgID string) (Customer, error
return custs[0], nil
}

func (s *Service) Enable(ctx context.Context, id string) error {
customer, err := s.repository.GetByID(ctx, id)
if err != nil {
return err
}
if customer.State == ActiveState {
return nil
}
customer.State = ActiveState
_, err = s.repository.UpdateByID(ctx, customer)
return err
}

func (s *Service) Disable(ctx context.Context, id string) error {
customer, err := s.repository.GetByID(ctx, id)
if err != nil {
return err
}
if customer.State == DisabledState {
return nil
}
customer.State = DisabledState
_, err = s.repository.UpdateByID(ctx, customer)
return err
}

func (s *Service) Delete(ctx context.Context, id string) error {
customer, err := s.repository.GetByID(ctx, id)
if err != nil {
Expand Down Expand Up @@ -273,7 +328,7 @@ func (s *Service) backgroundSync(ctx context.Context) {
}

for _, customer := range customers {
if customer.DeletedAt != nil || customer.ProviderID == "" {
if customer.DeletedAt != nil || customer.IsOffline() {
continue
}
if err := s.SyncWithProvider(ctx, customer); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion billing/subscription/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func (s *Service) backgroundSync(ctx context.Context) {
}

for _, customer := range customers {
if customer.DeletedAt != nil || customer.ProviderID == "" {
if customer.DeletedAt != nil || customer.IsOffline() {
continue
}
if err := s.SyncWithProvider(ctx, customer); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ func buildAPIDependencies(
auditRepository = audit.NewNoopRepository()
}
eventProcessor := event.NewService(cfg.Billing, organizationService, checkoutService, customerService,
planService, userService, subscriptionService)
planService, userService, subscriptionService, creditService)
eventChannel := make(chan audit.Log, 0)
logPublisher := event.NewChanPublisher(eventChannel)
logListener := event.NewChanListener(eventChannel, eventProcessor)
Expand Down
15 changes: 12 additions & 3 deletions config/sample.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,21 @@ billing:
# path to plans spec file that will be used to create plans in billing engine
# e.g. file:///tmp/plans
plans_path: ""
# name of the plan that should be used subscribed automatically when the org is created
# it also automatically creates an empty billing account under the org
default_plan: ""
# default currency to be used for billing if not provided by the user
# e.g. usd, inr, eur
default_currency: ""
# billing customer account configuration
customer:
# automatically create a default customer account when an org is created
auto_create_with_org: true
# name of the plan that should be used subscribed automatically when the org is created
# it also automatically creates an empty billing account under the org
default_plan: ""
# default offline status for the customer account, if true the customer account
# will not be registered in billing provider
default_offline: false
# free credits to be added to the customer account when created as a part of the org
onboard_credits_with_org: 0
# plan change configuration applied when a user changes their subscription plan
plan_change:
# proration_behavior can be one of "create_prorations", "none", "always_invoice"
Expand Down
83 changes: 62 additions & 21 deletions core/event/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import (
"fmt"
"time"

"github.com/google/uuid"

"github.com/raystack/frontier/billing/credit"

grpczap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"github.com/raystack/frontier/billing/plan"
"github.com/raystack/frontier/core/user"
Expand All @@ -28,8 +32,9 @@ type CheckoutService interface {
}

type CustomerService interface {
Create(ctx context.Context, customer customer.Customer) (customer.Customer, error)
Create(ctx context.Context, customer customer.Customer, offline bool) (customer.Customer, error)
TriggerSyncByProviderID(ctx context.Context, id string) error
List(ctx context.Context, flt customer.Filter) ([]customer.Customer, error)
}

type SubscriptionService interface {
Expand All @@ -48,6 +53,10 @@ type UserService interface {
ListByOrg(ctx context.Context, orgID string, roleFilter string) ([]user.User, error)
}

type CreditService interface {
Add(ctx context.Context, ch credit.Credit) error
}

type Service struct {
billingConf billing.Config
checkoutService CheckoutService
Expand All @@ -56,14 +65,15 @@ type Service struct {
planService PlanService
userService UserService
subsService SubscriptionService
creditService CreditService

sf singleflight.Group
}

func NewService(billingConf billing.Config, organizationService OrganizationService,
checkoutService CheckoutService, customerService CustomerService,
planService PlanService, userService UserService,
subsService SubscriptionService) *Service {
subsService SubscriptionService, creditService CreditService) *Service {
return &Service{
billingConf: billingConf,
orgService: organizationService,
Expand All @@ -72,32 +82,30 @@ func NewService(billingConf billing.Config, organizationService OrganizationServ
planService: planService,
userService: userService,
subsService: subsService,
creditService: creditService,

sf: singleflight.Group{},
}
}

// EnsureDefaultPlan create a new customer account and subscribe to the default plan if configured
func (p *Service) EnsureDefaultPlan(ctx context.Context, orgID string) error {
if p.billingConf.DefaultPlan != "" && p.billingConf.DefaultCurrency != "" {
// validate the plan requested is free
defaultPlan, err := p.planService.GetByID(ctx, p.billingConf.DefaultPlan)
if p.billingConf.DefaultCurrency != "" && p.billingConf.AccountConfig.AutoCreateWithOrg {
// only create if there is no customer account already
customers, err := p.customerService.List(ctx, customer.Filter{
OrgID: orgID,
})
if err != nil {
return fmt.Errorf("failed to get default plan: %w", err)
return fmt.Errorf("failed to list customers: %w", err)
}
for _, prod := range defaultPlan.Products {
for _, price := range prod.Prices {
if price.Amount > 0 {
return fmt.Errorf("default plan is not free")
}
}
if len(customers) > 0 {
return nil
}

org, err := p.orgService.GetRaw(ctx, orgID)
if err != nil {
return fmt.Errorf("failed to get organization: %w", err)
}

users, err := p.userService.ListByOrg(ctx, org.ID, organization.AdminRole)
if err != nil {
return fmt.Errorf("failed to list users: %w", err)
Expand All @@ -114,17 +122,50 @@ func (p *Service) EnsureDefaultPlan(ctx context.Context, orgID string) error {
Metadata: map[string]any{
"auto_created": "true",
},
})
}, p.billingConf.AccountConfig.DefaultOffline)
if err != nil {
return fmt.Errorf("failed to create customer: %w", err)
}
_, _, err = p.checkoutService.Apply(ctx, checkout.Checkout{
CustomerID: customr.ID,
PlanID: defaultPlan.ID,
SkipTrial: true,
})
if err != nil {
return fmt.Errorf("failed to apply default plan: %w", err)

if p.billingConf.AccountConfig.DefaultPlan != "" {
// validate the plan requested is free
defaultPlan, err := p.planService.GetByID(ctx, p.billingConf.AccountConfig.DefaultPlan)
if err != nil {
return fmt.Errorf("failed to get default plan: %w", err)
}

for _, prod := range defaultPlan.Products {
for _, price := range prod.Prices {
if price.Amount > 0 {
return fmt.Errorf("default plan is not free")
}
}
}
_, _, err = p.checkoutService.Apply(ctx, checkout.Checkout{
CustomerID: customr.ID,
PlanID: defaultPlan.ID,
SkipTrial: true,
})
if err != nil {
return fmt.Errorf("failed to apply default plan: %w", err)
}
}

if amount := p.billingConf.AccountConfig.OnboardCreditsWithOrg; amount > 0 {
txID := uuid.NewSHA1(credit.TxNamespaceUUID, []byte(fmt.Sprintf("%s", customr.OrgID))).String()
if err := p.creditService.Add(ctx, credit.Credit{
ID: txID,
CustomerID: customr.ID,
Amount: p.billingConf.AccountConfig.OnboardCreditsWithOrg,
Metadata: map[string]any{"auto_created": "true"},
Source: credit.SourceSystemAwardedEvent,
Description: fmt.Sprintf("Awarded %d credits for onboarding", amount),
}); err != nil {
// credit is only awarded once for an org
if !errors.Is(err, credit.ErrAlreadyApplied) {
return err
}
}
}
}
return nil
Expand Down
Loading

0 comments on commit 54b62aa

Please sign in to comment.