Skip to content

Commit

Permalink
feat: set billing customer credit min limit (#781)
Browse files Browse the repository at this point in the history
- If a billing account credit min limit is set less then 0,
it works as overdraft. Default is 0.
- If a org billing account is in overdraft state, it's not
allowed to create new billing account unless the dues are
settled.
- Setting this limit more then 0 works as min purchase requirement
for the account.
- Only platform admins can change the account limits.
- raystack/proton#370

Signed-off-by: Kush Sharma <[email protected]>
  • Loading branch information
kushsharma authored Oct 4, 2024
1 parent 689f0a3 commit bbebf12
Show file tree
Hide file tree
Showing 24 changed files with 1,911 additions and 744 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 := "37eef9d41df218eb19d07a3b7f75d089c328c575"
PROTON_COMMIT := "145667ee53b037d636c09df0a529c351069132dc"

ui:
@echo " > generating ui build"
Expand Down
13 changes: 2 additions & 11 deletions billing/credit/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,6 @@ func (s Service) Deduct(ctx context.Context, cred Credit) error {
return errors.New("credit amount is negative")
}

// check balance, if enough, sub credits
currentBalance, err := s.GetBalance(ctx, cred.CustomerID)
if err != nil {
return fmt.Errorf("failed to apply transaction: %w", err)
}
// TODO(kushsharma): this is prone to timing attacks and better we do this
// in a transaction
if currentBalance < cred.Amount {
return ErrInsufficientCredits
}

txSource := "system"
if cred.Source != "" {
txSource = cred.Source
Expand All @@ -110,6 +99,8 @@ func (s Service) Deduct(ctx context.Context, cred Credit) error {
}); err != nil {
if errors.Is(err, ErrAlreadyApplied) {
return ErrAlreadyApplied
} else if errors.Is(err, ErrInsufficientCredits) {
return ErrInsufficientCredits
}
return fmt.Errorf("failed to deduct credits: %w", err)
}
Expand Down
21 changes: 1 addition & 20 deletions billing/credit/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,27 +291,10 @@ func TestService_Deduct(t *testing.T) {

setup: func() *credit.Service {
s, mockTransactionRepo := mockService(t)
mockTransactionRepo.EXPECT().GetBalance(ctx, "customer_id").Return(10, nil)
mockTransactionRepo.EXPECT().CreateEntry(ctx, mock.Anything, mock.Anything).Return(nil, credit.ErrAlreadyApplied)
return s
},
},
{
name: "should return an error if balance cannot be fetched",
args: args{
cred: credit.Credit{
ID: "12",
CustomerID: "customer_id",
Amount: 10,
},
},
want: errors.New(fmt.Sprintf("failed to apply transaction: %v", dummyError)),
setup: func() *credit.Service {
s, mockTransactionRepo := mockService(t)
mockTransactionRepo.EXPECT().GetBalance(ctx, "customer_id").Return(0, dummyError)
return s
},
},
{
name: "should return ErrInsufficientCredits error if customer's balance is less than transaction amount",
args: args{
Expand All @@ -324,7 +307,7 @@ func TestService_Deduct(t *testing.T) {
want: credit.ErrInsufficientCredits,
setup: func() *credit.Service {
s, mockTransactionRepo := mockService(t)
mockTransactionRepo.EXPECT().GetBalance(ctx, "customer_id").Return(5, nil)
mockTransactionRepo.EXPECT().CreateEntry(ctx, mock.Anything, mock.Anything).Return(nil, credit.ErrInsufficientCredits)
return s
},
},
Expand All @@ -340,7 +323,6 @@ func TestService_Deduct(t *testing.T) {
want: errors.New(fmt.Sprintf("failed to deduct credits: %v", dummyError)),
setup: func() *credit.Service {
s, mockTransactionRepo := mockService(t)
mockTransactionRepo.EXPECT().GetBalance(ctx, "customer_id").Return(20, nil)
mockTransactionRepo.EXPECT().CreateEntry(ctx, mock.Anything, mock.Anything).Return([]credit.Transaction{}, dummyError)
return s
},
Expand All @@ -360,7 +342,6 @@ func TestService_Deduct(t *testing.T) {
want: nil,
setup: func() *credit.Service {
s, mockTransactionRepo := mockService(t)
mockTransactionRepo.EXPECT().GetBalance(ctx, "customer_id").Return(20, nil)
mockTransactionRepo.EXPECT().CreateEntry(ctx, credit.Transaction{ID: "12", CustomerID: "customer_id", Type: credit.DebitType, Amount: 10, Source: "system", Metadata: metadata.Metadata{"a": "a"}}, credit.Transaction{Type: credit.CreditType, CustomerID: schema.PlatformOrgID.String(), Amount: 10, Source: "system", Metadata: metadata.Metadata{"a": "a"}}).Return([]credit.Transaction{}, nil)
return s
},
Expand Down
5 changes: 3 additions & 2 deletions billing/customer/customer.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ type Customer struct {
Address Address
TaxData []Tax
// Currency Three-letter ISO 4217 currency code in lower case
Currency string `default:"usd"`
Metadata metadata.Metadata
Currency string `default:"usd"`
Metadata metadata.Metadata
CreditMin int64

// Stripe specific fields
// StripeTestClockID is used for testing purposes only to simulate a subscription
Expand Down
15 changes: 8 additions & 7 deletions billing/customer/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import (
)

var (
ErrNotFound = errors.New("customer not found")
ErrInvalidUUID = errors.New("invalid syntax of uuid")
ErrInvalidID = errors.New("billing customer id is invalid")
ErrConflict = errors.New("customer already exist")
ErrActiveConflict = errors.New("an active account already exists for the organization")
ErrInvalidDetail = errors.New("invalid billing customer detail")
ErrDisabled = errors.New("billing customer is disabled")
ErrNotFound = errors.New("customer not found")
ErrInvalidUUID = errors.New("invalid syntax of uuid")
ErrInvalidID = errors.New("billing customer id is invalid")
ErrConflict = errors.New("customer already exist")
ErrActiveConflict = errors.New("an active account already exists for the organization")
ErrInvalidDetail = errors.New("invalid billing customer detail")
ErrDisabled = errors.New("billing customer is disabled")
ErrExistingAccountWithPendingDues = errors.New("existing account with pending dues found")
)
93 changes: 93 additions & 0 deletions billing/customer/mocks/credit_service.go

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

58 changes: 58 additions & 0 deletions billing/customer/mocks/repository.go

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

42 changes: 33 additions & 9 deletions billing/customer/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"sync"
"time"

"github.com/raystack/frontier/pkg/utils"

"github.com/robfig/cron/v3"
"github.com/stripe/stripe-go/v79"

Expand All @@ -28,23 +30,31 @@ type Repository interface {
Create(ctx context.Context, customer Customer) (Customer, error)
UpdateByID(ctx context.Context, customer Customer) (Customer, error)
Delete(ctx context.Context, id string) error
UpdateCreditMinByID(ctx context.Context, customerID string, limit int64) (Customer, error)
}

type CreditService interface {
GetBalance(ctx context.Context, id string) (int64, error)
}

type Service struct {
stripeClient *client.API
repository Repository
stripeClient *client.API
repository Repository
creditService CreditService

syncJob *cron.Cron
mu sync.Mutex
syncDelay time.Duration
}

func NewService(stripeClient *client.API, repository Repository, cfg billing.Config) *Service {
func NewService(stripeClient *client.API, repository Repository, cfg billing.Config,
creditService CreditService) *Service {
return &Service{
stripeClient: stripeClient,
repository: repository,
mu: sync.Mutex{},
syncDelay: cfg.RefreshInterval.Customer,
stripeClient: stripeClient,
repository: repository,
mu: sync.Mutex{},
syncDelay: cfg.RefreshInterval.Customer,
creditService: creditService,
}
}

Expand All @@ -57,15 +67,25 @@ func (s *Service) Create(ctx context.Context, customer Customer, offline bool) (
// do not allow creating a new customer account if there exists already an active billing account
existingAccounts, err := s.repository.List(ctx, Filter{
OrgID: customer.OrgID,
State: ActiveState,
})
if err != nil {
return Customer{}, err
}
if len(existingAccounts) > 0 {
activeAccounts := utils.Filter(existingAccounts, func(i Customer) bool {
return i.State == ActiveState
})
if len(activeAccounts) > 0 {
return Customer{}, ErrActiveConflict
}

// do not allow creating account if the balance of a previous account within org
// is less than 0
for _, account := range existingAccounts {
if balance, err := s.creditService.GetBalance(ctx, account.ID); err == nil && balance < 0 {
return Customer{}, ErrExistingAccountWithPendingDues
}
}

// offline mode, we don't need to create the customer in billing provider
if !offline {
stripeCustomer, err := s.RegisterToProvider(ctx, customer)
Expand Down Expand Up @@ -482,3 +502,7 @@ func (s *Service) TriggerSyncByProviderID(ctx context.Context, id string) error
}
return s.SyncWithProvider(ctx, customrs[0])
}

func (s *Service) UpdateCreditMinByID(ctx context.Context, customerID string, limit int64) (Customer, error) {
return s.repository.UpdateCreditMinByID(ctx, customerID, limit)
}
Loading

0 comments on commit bbebf12

Please sign in to comment.