Skip to content

Commit

Permalink
feat: allow limiting number of seats in plan
Browse files Browse the repository at this point in the history
- seats limit can be configured via Behaviour Configuration
in products.
- 2 breaking changes are introduced
  - product credit amount is moved under config of product
  - to specify free credits awarded while subscribing to plan
    a new attribute on_start_credits is added
- products table has a breaking change in db to get rid of credit
amounts and replace it via config

Signed-off-by: Kush Sharma <[email protected]>
  • Loading branch information
kushsharma committed Jan 19, 2024
1 parent fadb5cc commit d6bc6f8
Show file tree
Hide file tree
Showing 52 changed files with 6,955 additions and 6,219 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ jobs:
steps:
- uses: actions/setup-go@v4
with:
go-version: '1.21.3'
go-version: '1.21.6'
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.54.2
version: v1.55.2
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.21.3"
go-version: "1.21.6"
- name: Login to DockerHub
uses: docker/login-action@v3
with:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.21.3"
go-version: "1.21.6"
- name: Login to DockerHub
uses: docker/login-action@v3
with:
Expand All @@ -41,7 +41,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.21.3"
go-version: "1.21.6"
- name: Login to DockerHub
uses: docker/login-action@v1
with:
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21.3'
go-version: '1.21.6'
- name: install dependencies
run: go mod tidy
- name: run unit tests
Expand All @@ -42,7 +42,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21.3'
go-version: '1.21.6'
- name: install dependencies
run: go mod tidy
- name: install spicedb binary
Expand All @@ -65,7 +65,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21.3'
go-version: '1.21.6'
- name: install dependencies
run: go mod tidy
- name: run regression tests
Expand Down
12 changes: 12 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ linters:
- gofmt
- whitespace
- misspell
# - gosimple
# - govet
# - ineffassign
# - bodyclose
# - gocritic
# - goconst
# - gosec
# - importas
# - perfsprint
# - prealloc
# - protogetter
# - wastedassign
linters-settings:
revive:
ignore-generated-header: true
Expand Down
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
.DEFAULT_GOAL := build
PROTON_COMMIT := "70c01935bc75115a794eedcad102c77e57d4cbf9"
PROTON_COMMIT := "c09248ad02032c76dc030db192420a4f354a186a"

ui:
@echo " > generating ui build"
Expand Down
73 changes: 38 additions & 35 deletions billing/checkout/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import (

"github.com/robfig/cron/v3"

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

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

"github.com/google/uuid"
Expand Down Expand Up @@ -177,11 +175,22 @@ func (s *Service) Create(ctx context.Context, ch Checkout) (Checkout, error) {
var subsItems []*stripe.CheckoutSessionLineItemParams

for _, planFeature := range plan.Products {
// if it's credit, skip, they are handled separately
// if it's credit, skip
if planFeature.Behavior == product.CreditBehavior {
continue
}

// if per seat, check if there is a limit of seats, if it breaches limit, fail
if planFeature.Behavior == product.PerSeatBehavior {
count, err := s.orgService.MemberCount(ctx, billingCustomer.OrgID)
if err != nil {
return Checkout{}, fmt.Errorf("failed to get member count: %w", err)
}
if planFeature.Config.SeatLimit > 0 && count > planFeature.Config.SeatLimit {
return Checkout{}, fmt.Errorf("member count exceeds allowed limit of the plan: %w", product.ErrPerSeatLimitReached)
}
}

for _, productPrice := range planFeature.Prices {
// only work with plan interval prices
if productPrice.Interval != plan.Interval {
Expand All @@ -194,7 +203,7 @@ func (s *Service) Create(ctx context.Context, ch Checkout) (Checkout, error) {
if productPrice.UsageType == product.PriceUsageTypeLicensed {
itemParams.Quantity = stripe.Int64(1)

if planFeature.Behavior == product.UserCountBehavior {
if planFeature.Behavior == product.PerSeatBehavior {
count, err := s.orgService.MemberCount(ctx, billingCustomer.OrgID)
if err != nil {
return Checkout{}, fmt.Errorf("failed to get member count: %w", err)
Expand Down Expand Up @@ -262,16 +271,16 @@ func (s *Service) Create(ctx context.Context, ch Checkout) (Checkout, error) {
}

if ch.ProductID != "" {
chFeature, err := s.productService.GetByID(ctx, ch.ProductID)
chProduct, err := s.productService.GetByID(ctx, ch.ProductID)
if err != nil {
return Checkout{}, fmt.Errorf("failed to get product: %w", err)
}
if len(chFeature.Prices) == 0 {
if len(chProduct.Prices) == 0 {
return Checkout{}, fmt.Errorf("invalid product, no prices found")
}

var subsItems []*stripe.CheckoutSessionLineItemParams
for _, productPrice := range chFeature.Prices {
for _, productPrice := range chProduct.Prices {
itemParams := &stripe.CheckoutSessionLineItemParams{
Price: stripe.String(productPrice.ProviderID),
}
Expand All @@ -295,8 +304,8 @@ func (s *Service) Create(ctx context.Context, ch Checkout) (Checkout, error) {
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
Metadata: map[string]string{
"org_id": billingCustomer.OrgID,
"product_name": chFeature.Name,
"credit_amount": fmt.Sprintf("%d", chFeature.CreditAmount),
"product_name": chProduct.Name,
"credit_amount": fmt.Sprintf("%d", chProduct.Config.CreditAmount),
"checkout_id": checkoutID,
"managed_by": "frontier",
},
Expand All @@ -312,14 +321,14 @@ func (s *Service) Create(ctx context.Context, ch Checkout) (Checkout, error) {
ID: checkoutID,
ProviderID: stripeCheckout.ID,
CustomerID: billingCustomer.ID,
ProductID: chFeature.ID,
ProductID: chProduct.ID,
CancelUrl: ch.CancelUrl,
SuccessUrl: ch.SuccessUrl,
CheckoutUrl: stripeCheckout.URL,
State: string(stripeCheckout.Status),
PaymentStatus: string(stripeCheckout.PaymentStatus),
Metadata: map[string]any{
"product_name": chFeature.Name,
"product_name": chProduct.Name,
},
ExpireAt: time.Unix(stripeCheckout.ExpiresAt, 0),
})
Expand Down Expand Up @@ -430,8 +439,8 @@ func (s *Service) SyncWithProvider(ctx context.Context, customerID string) error
}
} else if ch.ProductID != "" {
// if the checkout was created for product
if err := s.ensureCreditsForFeature(ctx, ch); err != nil {
return fmt.Errorf("ensureCreditsForFeature: %w", err)
if err := s.ensureCreditsForProduct(ctx, ch); err != nil {
return fmt.Errorf("ensureCreditsForProduct: %w", err)
}
}
}
Expand All @@ -440,21 +449,21 @@ func (s *Service) SyncWithProvider(ctx context.Context, customerID string) error
return nil
}

func (s *Service) ensureCreditsForFeature(ctx context.Context, ch Checkout) error {
chFeature, err := s.productService.GetByID(ctx, ch.ProductID)
func (s *Service) ensureCreditsForProduct(ctx context.Context, ch Checkout) error {
chProduct, err := s.productService.GetByID(ctx, ch.ProductID)
if err != nil {
return err
}
description := fmt.Sprintf("addition of %d credits for %s", chFeature.CreditAmount, chFeature.Title)
description := fmt.Sprintf("addition of %d credits for %s", chProduct.Config.CreditAmount, chProduct.Title)
if price, pok := ch.Metadata[AmountSubscriptionMetadataKey].(int64); pok {
if currency, cok := ch.Metadata[CurrencySubscriptionMetadataKey].(string); cok {
description = fmt.Sprintf("addition of %d credits for %s at %d[%s]", chFeature.CreditAmount, chFeature.Title, price, currency)
description = fmt.Sprintf("addition of %d credits for %s at %d[%s]", chProduct.Config.CreditAmount, chProduct.Title, price, currency)
}
}
if err := s.creditService.Add(ctx, credit.Credit{
ID: ch.ID,
AccountID: ch.CustomerID,
Amount: chFeature.CreditAmount,
Amount: chProduct.Config.CreditAmount,
Metadata: ch.Metadata,
Description: description,
}); err != nil && !errors.Is(err, credit.ErrAlreadyApplied) {
Expand All @@ -469,26 +478,20 @@ func (s *Service) ensureCreditsForPlan(ctx context.Context, ch Checkout) error {
return err
}

// find product with credits
creditFeatures := utils.Filter(chPlan.Products, func(f product.Product) bool {
return f.CreditAmount > 0
})
if len(creditFeatures) == 0 {
if chPlan.OnStartCredits == 0 {
// no such product
return nil
}

for _, chFeature := range creditFeatures {
description := fmt.Sprintf("addition of %d credits for %s", chFeature.CreditAmount, chFeature.Title)
if err := s.creditService.Add(ctx, credit.Credit{
ID: ch.ID,
AccountID: ch.CustomerID,
Amount: chFeature.CreditAmount,
Metadata: ch.Metadata,
Description: description,
}); err != nil && !errors.Is(err, credit.ErrAlreadyApplied) {
return err
}
description := fmt.Sprintf("addition of %d credits for %s", chPlan.OnStartCredits, chPlan.Title)
if err := s.creditService.Add(ctx, credit.Credit{
ID: ch.ID,
AccountID: ch.CustomerID,
Amount: chPlan.OnStartCredits,
Metadata: ch.Metadata,
Description: description,
}); err != nil && !errors.Is(err, credit.ErrAlreadyApplied) {
return err
}
return nil
}
Expand Down Expand Up @@ -647,7 +650,7 @@ func (s *Service) Apply(ctx context.Context, ch Checkout) (*subscription.Subscri
if productPrice.UsageType == product.PriceUsageTypeLicensed {
itemParams.Quantity = stripe.Int64(1)

if planFeature.Behavior == product.UserCountBehavior {
if planFeature.Behavior == product.PerSeatBehavior {
count, err := s.orgService.MemberCount(ctx, billingCustomer.OrgID)
if err != nil {
return nil, nil, fmt.Errorf("failed to get member count: %w", err)
Expand Down
7 changes: 7 additions & 0 deletions billing/entitlement/error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package entitlement

import "fmt"

var (
ErrPlanEntitlementFailed = fmt.Errorf("plan entitlement failed")
)
51 changes: 50 additions & 1 deletion billing/entitlement/check.go → billing/entitlement/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"errors"

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

"golang.org/x/exp/slices"

"github.com/raystack/frontier/billing/product"
Expand All @@ -20,16 +22,29 @@ type ProductService interface {
GetByID(ctx context.Context, id string) (product.Product, error)
}

type PlanService interface {
GetByID(ctx context.Context, id string) (plan.Plan, error)
}

type OrganizationService interface {
MemberCount(ctx context.Context, orgID string) (int64, error)
}

type Service struct {
subscriptionService SubscriptionService
productService ProductService
planService PlanService
organizationService OrganizationService
}

func NewEntitlementService(subscriptionService SubscriptionService,
featureService ProductService) *Service {
featureService ProductService, planService PlanService,
organizationService OrganizationService) *Service {
return &Service{
subscriptionService: subscriptionService,
productService: featureService,
planService: planService,
organizationService: organizationService,
}
}

Expand Down Expand Up @@ -73,3 +88,37 @@ func (s *Service) Check(ctx context.Context, customerID, featureOrProductID stri
}
return false, nil
}

func (s *Service) CheckPlanEligibility(ctx context.Context, customerID string) error {
// get all subscriptions for the customer
subs, err := s.subscriptionService.List(ctx, subscription.Filter{
CustomerID: customerID,
State: subscription.StateActive.String(),
})
if err != nil {
return err
}

for _, sub := range subs {
planOb, err := s.planService.GetByID(ctx, sub.PlanID)
if err != nil {
return err
}

// check if the product has seat based limits
for _, prod := range planOb.Products {
if prod.Behavior == product.PerSeatBehavior {
count, err := s.organizationService.MemberCount(ctx, customerID)
if err != nil {
return err
}
if prod.Config.SeatLimit > 0 && count > prod.Config.SeatLimit {
return product.ErrPerSeatLimitReached
}
}
}
}

// default to true
return nil
}
5 changes: 4 additions & 1 deletion billing/plan/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ type Plan struct {
// e.g. day, week, month, year
Interval string `json:"interval" yaml:"interval"`

// OnStartCredits is the number of credits that are awarded when a subscription is started
OnStartCredits int64 `json:"on_start_credits" yaml:"on_start_credits"`

// Products for the plan, return only, should not be set when creating a plan
Products []product.Product `json:"products" yaml:"products"`

Expand All @@ -42,7 +45,7 @@ type Plan struct {

func (p Plan) GetUserCountProduct() (product.Product, bool) {
for _, f := range p.Products {
if f.Behavior == product.UserCountBehavior {
if f.Behavior == product.PerSeatBehavior {
return f, true
}
}
Expand Down
Loading

0 comments on commit d6bc6f8

Please sign in to comment.