Skip to content

Commit

Permalink
feat: generate invoice for overdraft credits
Browse files Browse the repository at this point in the history
It's necessary to configure the product name to calculate
per unit price before the overdraft credits can be invoiced
in `billing.customer.credit_overdraft_product`.
If not set, invoice reconcilation and creation is skipped.

If there is already an invoice for overdraft credits for same
time range, no new invoice will be created for that customer.
Currently the system generates them on the first day of the month.

Reconcilation of these invoice happens on same cadence as
invoices gets synced from the billing provider.

Signed-off-by: Kush Sharma <[email protected]>
  • Loading branch information
kushsharma committed Nov 10, 2024
1 parent 2c3acc1 commit 5e3baf7
Show file tree
Hide file tree
Showing 33 changed files with 2,466 additions and 920 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 := "8c05fcc798a7893761c9df0410c8d92cc926f68a"
PROTON_COMMIT := "7a10c1b3488a89d95199e3e25883976c597d03ed"

ui:
@echo " > generating ui build"
Expand Down
11 changes: 11 additions & 0 deletions billing/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ type AccountConfig struct {
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"`

// CreditOverdraftProduct helps identify the product pricing per unit amount for the overdraft
// credits being invoiced
CreditOverdraftProduct string `yaml:"credit_overdraft_product" mapstructure:"credit_overdraft_product"`

// CreditOverdraftInvoiceDay is the day of the range(month) when the overdraft credits are invoiced
CreditOverdraftInvoiceDay int `yaml:"credit_overdraft_invoice_day" mapstructure:"credit_overdraft_invoice_day" default:"1"`

// CreditOverdraftInvoiceRangeShift is the shift in the invoice range for the overdraft credits
// if positive, the invoice range will be shifted to the future else it will be shifted to the past
CreditOverdraftInvoiceRangeShift int `yaml:"credit_overdraft_invoice_range_shift" mapstructure:"credit_overdraft_invoice_range_shift" default:"0"`
}

type PlanChangeConfig struct {
Expand Down
14 changes: 10 additions & 4 deletions billing/credit/credit.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package credit

import (
"errors"
"strings"
"time"

"github.com/google/uuid"
Expand All @@ -20,10 +21,11 @@ var (
// TxNamespaceUUID is the namespace for generating transaction UUIDs deterministically
TxNamespaceUUID = uuid.MustParse("967416d0-716e-4308-b58f-2468ac14f20a")

SourceSystemBuyEvent = "system.buy"
SourceSystemAwardedEvent = "system.awarded"
SourceSystemOnboardEvent = "system.starter"
SourceSystemRevertEvent = "system.revert"
SourceSystemBuyEvent = "system.buy"
SourceSystemAwardedEvent = "system.awarded"
SourceSystemOnboardEvent = "system.starter"
SourceSystemRevertEvent = "system.revert"
SourceSystemOverdraftEvent = "system.overdraft"
)

type TransactionType string
Expand Down Expand Up @@ -71,3 +73,7 @@ type Filter struct {
StartRange time.Time
EndRange time.Time
}

func TxUUID(tags ...string) string {
return uuid.NewSHA1(TxNamespaceUUID, []byte(strings.Join(tags, ":"))).String()
}
63 changes: 62 additions & 1 deletion billing/credit/mocks/transaction_repository.go

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

12 changes: 12 additions & 0 deletions billing/credit/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package credit
import (
"context"
"fmt"
"time"

"github.com/google/uuid"

"github.com/pkg/errors"

Expand All @@ -14,6 +17,7 @@ type TransactionRepository interface {
GetBalance(ctx context.Context, id string) (int64, error)
List(ctx context.Context, flt Filter) ([]Transaction, error)
GetByID(ctx context.Context, id string) (Transaction, error)
GetBalanceForRange(ctx context.Context, accountID string, start time.Time, end time.Time) (int64, error)
}

type Service struct {
Expand All @@ -40,6 +44,7 @@ func (s Service) Add(ctx context.Context, cred Credit) error {
}

_, err := s.transactionRepository.CreateEntry(ctx, Transaction{
ID: uuid.NewString(),
CustomerID: schema.PlatformOrgID.String(),
Type: DebitType,
Amount: cred.Amount,
Expand Down Expand Up @@ -89,6 +94,7 @@ func (s Service) Deduct(ctx context.Context, cred Credit) error {
UserID: cred.UserID,
Metadata: cred.Metadata,
}, Transaction{
ID: uuid.NewString(),
Type: CreditType,
CustomerID: schema.PlatformOrgID.String(),
Amount: cred.Amount,
Expand All @@ -115,6 +121,12 @@ func (s Service) GetBalance(ctx context.Context, accountID string) (int64, error
return s.transactionRepository.GetBalance(ctx, accountID)
}

// GetBalanceForRange returns the balance for the given accountID within the given time range
// start time is inclusive, end time is exclusive
func (s Service) GetBalanceForRange(ctx context.Context, accountID string, start time.Time, end time.Time) (int64, error) {
return s.transactionRepository.GetBalanceForRange(ctx, accountID, start, end)
}

func (s Service) GetByID(ctx context.Context, id string) (Transaction, error) {
return s.transactionRepository.GetByID(ctx, id)
}
3 changes: 3 additions & 0 deletions billing/customer/customer.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ type Filter struct {
OrgID string
ProviderID string
State State

Online *bool
AllowedOverdraft *bool
}

type PaymentMethod struct {
Expand Down
71 changes: 67 additions & 4 deletions billing/invoice/invoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,42 @@ var (
ErrInvalidDetail = fmt.Errorf("invalid invoice detail")
)

const (
// ItemIDMetadataKey is used to uniquely identify the item in the invoice
// this is useful when reconciling the invoice items for payments and
// avoid creating duplicate credits
ItemIDMetadataKey = "item_id"
// ItemTypeMetadataKey is used to identify the item type in the invoice
// this is useful when reconciling the invoice items for payments and
// avoid creating duplicate invoices
ItemTypeMetadataKey = "item_type"

// ReconciledMetadataKey marks the invoice as reconciled and avoid processing it again
// as an optimization
ReconciledMetadataKey = "reconciled"

// GenerateForCreditLockKey is used to lock the invoice generation within current application
GenerateForCreditLockKey = "generate_for_credit"
)

type State string

func (s State) String() string {
return string(s)
}

const (
DraftState State = "draft"
OpenState State = "open"
PaidState State = "paid"
)

type Invoice struct {
ID string
CustomerID string
ProviderID string
State string
ID string
CustomerID string
ProviderID string
// State could be one of draft, open, paid, uncollectible, void
State State
Currency string
Amount int64
HostedURL string
Expand All @@ -28,12 +59,44 @@ type Invoice struct {
PeriodStartAt time.Time
PeriodEndAt time.Time

Items []Item
Metadata metadata.Metadata
}

type ItemType string

func (t ItemType) String() string {
return string(t)
}

const (
// CreditItemType is used to charge for the credits used in the system
// as overdraft
CreditItemType ItemType = "credit"
)

type Item struct {
ID string `json:"id"`
ProviderID string `json:"provider_id"`
// Name is the item name
Name string `json:"name"`
// Type is the item type
Type ItemType `json:"type"`
// UnitAmount is per unit cost
UnitAmount int64 `json:"unit_amount"`
// Quantity is the number of units
Quantity int64 `json:"quantity"`

// TimeRangeStart is the start time of the item since it's effective
TimeRangeStart *time.Time `json:"range_start"`
// TimeRangeEnd is the end time of the item since it's effective
TimeRangeEnd *time.Time `json:"range_end"`
}

type Filter struct {
CustomerID string
NonZeroOnly bool
State State

Pagination *pagination.Pagination
}
Loading

0 comments on commit 5e3baf7

Please sign in to comment.