Skip to content

Commit

Permalink
feat: list all features and invoices
Browse files Browse the repository at this point in the history
- additional API to list all features
- additional API to list all invoices(superuser)
- additional details in subscription api related to cycle start/end

Signed-off-by: Kush Sharma <thekushsharma@gmail.com>
  • Loading branch information
kushsharma committed Feb 13, 2024
1 parent aebe398 commit e248439
Show file tree
Hide file tree
Showing 29 changed files with 9,583 additions and 7,717 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
.DEFAULT_GOAL := build
PROTON_COMMIT := "01c382704313b8a8f3aa1ab39f89d3fe2279af27"
PROTON_COMMIT := "80aeb39287c488aba3fa17b6b4d6450b97527588"

ui:
@echo " > generating ui build"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ docker pull raystack/frontier:latest
To pull a specific version:

```
docker pull raystack/frontier:0.8.19
docker pull raystack/frontier:0.8.26
```

## Usage
Expand Down
5 changes: 4 additions & 1 deletion billing/checkout/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,10 @@ func (s *Service) Apply(ctx context.Context, ch Checkout) (*subscription.Subscri
"delegated": "true",
"checkout_id": ch.ID,
},
TrialEndsAt: time.Unix(stripeSubscription.TrialEnd, 0),
TrialEndsAt: time.Unix(stripeSubscription.TrialEnd, 0),
BillingCycleAnchorAt: time.Unix(stripeSubscription.BillingCycleAnchor, 0),
CurrentPeriodStartAt: time.Unix(stripeSubscription.CurrentPeriodStart, 0),
CurrentPeriodEndAt: time.Unix(stripeSubscription.CurrentPeriodEnd, 0),
})
if err != nil {
return nil, nil, fmt.Errorf("failed to create subscription: %w", err)
Expand Down
28 changes: 18 additions & 10 deletions billing/invoice/invoice.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
package invoice

import (
"fmt"
"time"

"github.com/raystack/frontier/pkg/metadata"
)

var (
ErrNotFound = fmt.Errorf("invoice not found")
ErrInvalidDetail = fmt.Errorf("invalid invoice detail")
)

type Invoice struct {
ID string
CustomerID string
ProviderID string
State string
Currency string
Amount int64
HostedURL string
DueDate time.Time
EffectiveAt time.Time
CreatedAt time.Time
ID string
CustomerID string
ProviderID string
State string
Currency string
Amount int64
HostedURL string
DueAt time.Time
EffectiveAt time.Time
CreatedAt time.Time
PeriodStartAt time.Time
PeriodEndAt time.Time

Metadata metadata.Metadata
}
Expand Down
165 changes: 149 additions & 16 deletions billing/invoice/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,156 @@ import (
"context"
"errors"
"fmt"
"math/rand"
"sync"
"time"

"github.com/raystack/frontier/pkg/utils"
"github.com/robfig/cron/v3"
"go.uber.org/zap"

grpczap "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"github.com/raystack/frontier/billing/customer"
"github.com/raystack/frontier/pkg/metadata"
"github.com/stripe/stripe-go/v75"
"github.com/stripe/stripe-go/v75/client"
)

type Service struct {
stripeClient *client.API
customerService CustomerService
const (
SyncDelay = time.Second * 120
)

type Repository interface {
Create(ctx context.Context, invoice Invoice) (Invoice, error)
GetByID(ctx context.Context, id string) (Invoice, error)
List(ctx context.Context, filter Filter) ([]Invoice, error)
UpdateByID(ctx context.Context, invoice Invoice) (Invoice, error)
}

type CustomerService interface {
GetByID(ctx context.Context, id string) (customer.Customer, error)
List(ctx context.Context, filter customer.Filter) ([]customer.Customer, error)
}

func NewService(stripeClient *client.API, customerService CustomerService) *Service {
type Service struct {
stripeClient *client.API
repository Repository
customerService CustomerService

syncJob *cron.Cron
mu sync.Mutex
}

func NewService(stripeClient *client.API, invoiceRepository Repository,
customerService CustomerService) *Service {
return &Service{
stripeClient: stripeClient,
repository: invoiceRepository,
customerService: customerService,
}
}

func (s *Service) Init(ctx context.Context) {
if s.syncJob != nil {
s.syncJob.Stop()
}

s.syncJob = cron.New()
s.syncJob.AddFunc(fmt.Sprintf("@every %s", SyncDelay.String()), func() {
s.backgroundSync(ctx)
})
s.syncJob.Start()
}

func (s *Service) Close() error {
if s.syncJob != nil {
return s.syncJob.Stop().Err()
}
return nil
}

func (s *Service) backgroundSync(ctx context.Context) {
logger := grpczap.Extract(ctx)
customers, err := s.customerService.List(ctx, customer.Filter{})
if err != nil {
logger.Error("invoice.backgroundSync", zap.Error(err))
return
}
logger.Info("invoice.SyncWithProvider", zap.Int("customers", len(customers)))
for _, customer := range customers {
if customer.DeletedAt != nil || customer.ProviderID == "" {
continue
}
if err := s.SyncWithProvider(ctx, customer); err != nil {
logger.Error("invoice.SyncWithProvider", zap.Error(err))
}
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
}
}

func (s *Service) SyncWithProvider(ctx context.Context, customr customer.Customer) error {
s.mu.Lock()
defer s.mu.Unlock()

invoiceObs, err := s.repository.List(ctx, Filter{
CustomerID: customr.ID,
})
if err != nil {
return err
}

var errs []error
stripeInvoices := s.stripeClient.Invoices.List(&stripe.InvoiceListParams{
Customer: stripe.String(customr.ProviderID),
ListParams: stripe.ListParams{
Context: ctx,
},
})
for stripeInvoices.Next() {
stripeInvoice := stripeInvoices.Invoice()

// check if already present, if yes, update else create new
existingInvoice, ok := utils.FindFirst(invoiceObs, func(i Invoice) bool {
return i.ProviderID == stripeInvoice.ID
})
if ok {
// already present in our system, update it if needed
updateNeeded := false
if existingInvoice.State != string(stripeInvoice.Status) {
existingInvoice.State = string(stripeInvoice.Status)
updateNeeded = true
}

if updateNeeded {
if _, err := s.repository.UpdateByID(ctx, existingInvoice); err != nil {
errs = append(errs, fmt.Errorf("failed to update invoice %s: %w", existingInvoice.ID, err))
}
}
} else {
if _, err := s.repository.Create(ctx, stripeInvoiceToInvoice(customr.ID, stripeInvoice)); err != nil {
errs = append(errs, fmt.Errorf("failed to create invoice for customer %s: %w", customr.ID, err))
}
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
if err := stripeInvoices.Err(); err != nil {
return fmt.Errorf("failed to list invoices: %w", err)
}
return nil
}

// ListAll should only be called by admin users
func (s *Service) ListAll(ctx context.Context, filter Filter) ([]Invoice, error) {
return s.repository.List(ctx, filter)
}

// List currently queries stripe for invoices, but it should be refactored to query our own database
func (s *Service) List(ctx context.Context, filter Filter) ([]Invoice, error) {
if filter.CustomerID == "" {
return nil, errors.New("customer id is required")
}
custmr, err := s.customerService.GetByID(ctx, filter.CustomerID)
if err != nil {
return nil, fmt.Errorf("failed to find customer: %w", err)
Expand Down Expand Up @@ -93,18 +217,27 @@ func stripeInvoiceToInvoice(customerID string, stripeInvoice *stripe.Invoice) In
if stripeInvoice.Created != 0 {
createdAt = time.Unix(stripeInvoice.Created, 0)
}

var periodStartAt time.Time
if stripeInvoice.PeriodStart != 0 {
periodStartAt = time.Unix(stripeInvoice.PeriodStart, 0)
}
var periodEndAt time.Time
if stripeInvoice.PeriodEnd != 0 {
periodEndAt = time.Unix(stripeInvoice.PeriodEnd, 0)
}
return Invoice{
ID: "", // TODO: should we persist this?
ProviderID: stripeInvoice.ID,
CustomerID: customerID,
State: string(stripeInvoice.Status),
Currency: string(stripeInvoice.Currency),
Amount: stripeInvoice.Total,
HostedURL: stripeInvoice.HostedInvoiceURL,
Metadata: metadata.FromString(stripeInvoice.Metadata),
EffectiveAt: effectiveAt,
DueDate: dueDate,
CreatedAt: createdAt,
ID: "",
ProviderID: stripeInvoice.ID,
CustomerID: customerID,
State: string(stripeInvoice.Status),
Currency: string(stripeInvoice.Currency),
Amount: stripeInvoice.Total,
HostedURL: stripeInvoice.HostedInvoiceURL,
Metadata: metadata.FromString(stripeInvoice.Metadata),
EffectiveAt: effectiveAt,
DueAt: dueDate,
CreatedAt: createdAt,
PeriodStartAt: periodStartAt,
PeriodEndAt: periodEndAt,
}
}
22 changes: 17 additions & 5 deletions billing/subscription/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ func (s *Service) backgroundSync(ctx context.Context) {
logger := grpczap.Extract(ctx)
customers, err := s.customerService.List(ctx, customer.Filter{})
if err != nil {
logger.Error("checkout.backgroundSync", zap.Error(err))
logger.Error("subscription.backgroundSync", zap.Error(err))
return
}

Expand All @@ -129,7 +129,7 @@ func (s *Service) backgroundSync(ctx context.Context) {
continue
}
if err := s.SyncWithProvider(ctx, customer); err != nil {
logger.Error("checkout.SyncWithProvider", zap.Error(err))
logger.Error("subscription.SyncWithProvider", zap.Error(err))
}
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
}
Expand Down Expand Up @@ -165,18 +165,30 @@ func (s *Service) SyncWithProvider(ctx context.Context, customr customer.Custome
updateNeeded = true
sub.State = string(stripeSubscription.Status)
}
if stripeSubscription.CanceledAt > 0 && sub.CanceledAt.IsZero() {
if stripeSubscription.CanceledAt > 0 && sub.CanceledAt.Unix() != stripeSubscription.CanceledAt {
updateNeeded = true
sub.CanceledAt = time.Unix(stripeSubscription.CanceledAt, 0)
}
if stripeSubscription.EndedAt > 0 && sub.EndedAt.IsZero() {
if stripeSubscription.EndedAt > 0 && sub.EndedAt.Unix() != stripeSubscription.EndedAt {
updateNeeded = true
sub.EndedAt = time.Unix(stripeSubscription.EndedAt, 0)
}
if stripeSubscription.TrialEnd > 0 && sub.TrialEndsAt.IsZero() {
if stripeSubscription.TrialEnd > 0 && sub.TrialEndsAt.Unix() != stripeSubscription.TrialEnd {
updateNeeded = true
sub.TrialEndsAt = time.Unix(stripeSubscription.TrialEnd, 0)
}
if stripeSubscription.CurrentPeriodStart > 0 && sub.CurrentPeriodStartAt.Unix() != stripeSubscription.CurrentPeriodStart {
updateNeeded = true
sub.CurrentPeriodStartAt = time.Unix(stripeSubscription.CurrentPeriodStart, 0)
}
if stripeSubscription.CurrentPeriodEnd > 0 && sub.CurrentPeriodEndAt.Unix() != stripeSubscription.CurrentPeriodEnd {
updateNeeded = true
sub.CurrentPeriodEndAt = time.Unix(stripeSubscription.CurrentPeriodEnd, 0)
}
if stripeSubscription.BillingCycleAnchor > 0 && sub.BillingCycleAnchorAt.Unix() != stripeSubscription.BillingCycleAnchor {
updateNeeded = true
sub.BillingCycleAnchorAt = time.Unix(stripeSubscription.BillingCycleAnchor, 0)
}

// update plan id if it's changed
planByStripeSubscription, err := s.findPlanByStripeSubscription(ctx, stripeSubscription)
Expand Down
15 changes: 9 additions & 6 deletions billing/subscription/subscription.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,15 @@ type Subscription struct {

Phase Phase

CreatedAt time.Time
UpdatedAt time.Time
CanceledAt time.Time
DeletedAt time.Time
EndedAt time.Time
TrialEndsAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
CanceledAt time.Time
DeletedAt time.Time
EndedAt time.Time
TrialEndsAt time.Time
CurrentPeriodStartAt time.Time
CurrentPeriodEndAt time.Time
BillingCycleAnchorAt time.Time
}

type Filter struct {
Expand Down
14 changes: 13 additions & 1 deletion cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
"syscall"
"time"

"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"

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

"github.com/raystack/frontier/billing/usage"
Expand Down Expand Up @@ -90,6 +92,8 @@ func StartServer(logger *log.Zap, cfg *config.Frontier) error {
ctx, cancelFunc := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancelFunc()

ctx = ctxzap.ToContext(ctx, logger.GetInternalZapLogger().Desugar())

dbClient, err := setupDB(cfg.DB, logger)
if err != nil {
return err
Expand Down Expand Up @@ -217,6 +221,14 @@ func StartServer(logger *log.Zap, cfg *config.Frontier) error {
}
}()

deps.InvoiceService.Init(ctx)
defer func() {
logger.Debug("cleaning up invoices")
if err := deps.InvoiceService.Close(); err != nil {
logger.Warn("invoice service cleanup failed", "err", err)
}
}()

go server.ServeUI(ctx, logger, cfg.UI, cfg.App)

// serving server
Expand Down Expand Up @@ -380,7 +392,7 @@ func buildAPIDependencies(
checkoutService := checkout.NewService(stripeClient, cfg.Billing.StripeAutoTax, postgres.NewBillingCheckoutRepository(dbc),
customerService, planService, subscriptionService, productService, creditService, organizationService)

invoiceService := invoice.NewService(stripeClient, customerService)
invoiceService := invoice.NewService(stripeClient, postgres.NewBillingInvoiceRepository(dbc), customerService)

usageService := usage.NewService(creditService)

Expand Down
Loading

0 comments on commit e248439

Please sign in to comment.