Skip to content

Commit

Permalink
feat: billing subscription handler
Browse files Browse the repository at this point in the history
  • Loading branch information
turip committed Dec 14, 2024
1 parent 6192c89 commit e6e4e65
Show file tree
Hide file tree
Showing 18 changed files with 1,769 additions and 133 deletions.
9 changes: 9 additions & 0 deletions openmeter/billing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,12 @@ The entity's `ChildrenWithIDReuse` call can be used to facilitate the line reuse
Then the adapter layer will use those IDs to make decisions if they want to persist or recreate the records.

We could do the same logic in the adapter layer, but this approach makes it more flexible on the calculation layer if we want to generate new lines or not. If this becomes a burden we can do the same matching logic as part of the upsert logic in adapter.

## Subscription adapter

The subscription adapter is responsible for feeding the billing with line items during the subscription's lifecycle. The generation of items is event-driven, new items are yielded when:
- A subscription is created
- A new invoice is created
- A subscription is modified

TODO: for new versions the previous line will be split and will not get the stuff from the other side -> this means that the unique id should encode the version of the item
1 change: 1 addition & 0 deletions openmeter/billing/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type InvoiceLineAdapter interface {
ListInvoiceLines(ctx context.Context, input ListInvoiceLinesAdapterInput) ([]*Line, error)
AssociateLinesToInvoice(ctx context.Context, input AssociateLinesToInvoiceAdapterInput) ([]*Line, error)
GetInvoiceLine(ctx context.Context, input GetInvoiceLineAdapterInput) (*Line, error)
GetLinesForSubscription(ctx context.Context, input GetLinesForSubscriptionInput) ([]*Line, error)

GetInvoiceLineOwnership(ctx context.Context, input GetInvoiceLineOwnershipAdapterInput) (GetOwnershipAdapterResponse, error)
}
Expand Down
4 changes: 4 additions & 0 deletions openmeter/billing/adapter/invoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ func (a *adapter) ListInvoices(ctx context.Context, input billing.ListInvoicesIn
query = query.Where(billinginvoice.StatusIn(input.ExtendedStatuses...))
}

if len(input.IDs) > 0 {
query = query.Where(billinginvoice.IDIn(input.IDs...))
}

if len(input.Statuses) > 0 {
query = query.Where(func(s *sql.Selector) {
s.Where(sql.Or(
Expand Down
26 changes: 26 additions & 0 deletions openmeter/billing/adapter/invoicelines.go
Original file line number Diff line number Diff line change
Expand Up @@ -492,3 +492,29 @@ func (a *adapter) GetInvoiceLineOwnership(ctx context.Context, in billing.GetInv
}, nil
})
}

func (a *adapter) GetLinesForSubscription(ctx context.Context, in billing.GetLinesForSubscriptionInput) ([]*billing.Line, error) {
if err := in.Validate(); err != nil {
return nil, billing.ValidationError{
Err: err,
}
}

return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]*billing.Line, error) {
query := tx.db.BillingInvoiceLine.Query().
Where(billinginvoiceline.Namespace(in.Namespace)).
Where(billinginvoiceline.SubscriptionID(in.SubscriptionID)).
// TODO: document issues with deleted lines
// Where(billinginvoiceline.DeletedAtIsNil()). TBD
Where(billinginvoiceline.ParentLineIDIsNil()) // This one is required so that we are not fetching split line's children directly, the mapper will handle that

query = tx.expandLineItems(query)

dbLines, err := query.All(ctx)
if err != nil {
return nil, fmt.Errorf("fetching lines: %w", err)
}

return tx.mapInvoiceLineFromDB(ctx, dbLines)
})
}
4 changes: 4 additions & 0 deletions openmeter/billing/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ import "time"

const (
DefaultMeterResolution = time.Minute
// DefaultProratePercision is the default number of decimal places to round to when prorating, this should be
// at least one digit more than the smallest unit represented in the currency (e.g. 3 for USD)
// TODO[later]: This should come from the currency
DefaultProratePercision = 3
)
1 change: 1 addition & 0 deletions openmeter/billing/invoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ type ListInvoicesInput struct {
pagination.Page

Namespace string
IDs []string
Customers []string
// Statuses searches by short InvoiceStatus (e.g. draft, issued)
Statuses []string
Expand Down
21 changes: 21 additions & 0 deletions openmeter/billing/invoiceline.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ func (p Period) Contains(t time.Time) bool {
return t.After(p.Start) && t.Before(p.End)
}

func (p Period) Duration() time.Duration {
return p.End.Sub(p.Start)
}

// LineBase represents the common fields for an invoice item.
type LineBase struct {
Namespace string `json:"namespace"`
Expand Down Expand Up @@ -952,3 +956,20 @@ type GetInvoiceLineInput = LineID
type GetInvoiceLineOwnershipAdapterInput = LineID

type DeleteInvoiceLineInput = LineID

type GetLinesForSubscriptionInput struct {
Namespace string
SubscriptionID string
}

func (i GetLinesForSubscriptionInput) Validate() error {
if i.Namespace == "" {
return errors.New("namespace is required")
}

if i.SubscriptionID == "" {
return errors.New("subscription id is required")
}

return nil
}
1 change: 1 addition & 0 deletions openmeter/billing/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type CustomerOverrideService interface {
type InvoiceLineService interface {
CreatePendingInvoiceLines(ctx context.Context, input CreateInvoiceLinesInput) ([]*Line, error)
GetInvoiceLine(ctx context.Context, input GetInvoiceLineInput) (*Line, error)
GetLinesForSubscription(ctx context.Context, input GetLinesForSubscriptionInput) ([]*Line, error)
UpdateInvoiceLine(ctx context.Context, input UpdateInvoiceLineInput) (*Line, error)
DeleteInvoiceLine(ctx context.Context, input DeleteInvoiceLineInput) error
}
Expand Down
10 changes: 10 additions & 0 deletions openmeter/billing/service/invoiceline.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,3 +383,13 @@ func (s *Service) DeleteInvoiceLine(ctx context.Context, input billing.DeleteInv
return err
})
}

func (s *Service) GetLinesForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) ([]*billing.Line, error) {
if err := input.Validate(); err != nil {
return nil, billing.ValidationError{
Err: err,
}
}

return s.adapter.GetLinesForSubscription(ctx, input)
}
2 changes: 2 additions & 0 deletions openmeter/billing/service/lineservice/feeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ func (l feeLine) PrepareForCreate(context.Context) (Line, error) {
}

func (l feeLine) CanBeInvoicedAsOf(_ context.Context, t time.Time) (*billing.Period, error) {
// TODO[later]: Prorate can be implemented here for progressive billing of the fee

if !t.Before(l.line.InvoiceAt) {
return &l.line.Period, nil
}
Expand Down
133 changes: 0 additions & 133 deletions openmeter/billing/subscriptionhandler/scanario_test.go

This file was deleted.

87 changes: 87 additions & 0 deletions openmeter/billing/worker/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package billingworker

import (
"context"
"fmt"
"slices"
"time"

"github.com/openmeterio/openmeter/openmeter/billing"
"github.com/openmeterio/openmeter/openmeter/subscription"
"github.com/openmeterio/openmeter/pkg/timex"
)

type GetUpcomingLineItemsInput struct {
SubscriptionView subscription.SubscriptionView
StartFrom *time.Time

Customer billing.ProfileWithCustomerDetails
}

func GetUpcomingLineItems(ctx context.Context, in GetUpcomingLineItemsInput) ([]billing.Line, error) {
// Given we are event-driven we should at least yield one line item. If that's assigned to
// a new invoice, this function can be triggered again.

// TODO: there's a builtin for this
slices.SortFunc(in.SubscriptionView.Phases, func(i, j subscription.SubscriptionPhaseView) int {
return timex.Compare(i.SubscriptionPhase.ActiveFrom, j.SubscriptionPhase.ActiveFrom)
})

// Let's identify the first phase that is invoicable
firstInvoicablePhaseIdx, found := findFirstInvoicablePhase(in.SubscriptionView.Phases, in.StartFrom)
if !found {
// There are no invoicable items in the subscription, so we can return an empty list
// If the subscription has changed we will just recalculate the line items with the updated
// contents.
return nil, nil
}

// Let's find out the limit of the generation. As a rule of thumb, we should have at least one line per
// invoicable item.

switch in.Customer.Profile.WorkflowConfig.Collection.Alignment {
case billing.AlignmentKindSubscription:
// In this case, the end of the generation end will be
default:
return nil, fmt.Errorf("unsupported alignment type: %s", in.Customer.Profile.WorkflowConfig.Collection.Alignment)
}

for i := firstInvoicablePhaseIdx; i < len(in.SubscriptionView.Phases); i++ {
}

return nil, nil
}

func findFirstInvoicablePhase(phases []subscription.SubscriptionPhaseView, startFrom *time.Time) (int, bool) {
// A phase is invoicable, if it has any items that has price objects set, and if it's activeFrom is before or equal startFrom
// and the next phase's activeFrom is after startFrom.

for i, phase := range phases {
isBillable := false
// TODO: maybe forEachRateCard or similar
for _, items := range phase.ItemsByKey {
for _, item := range items {
if item.Spec.RateCard.Price != nil {
isBillable = true
break
}
}
}

if !isBillable {
continue
}

if !phase.SubscriptionPhase.ActiveFrom.After(*startFrom) {
if i == len(phases)-1 {
return i, true
}

if phases[i+1].SubscriptionPhase.ActiveFrom.After(*startFrom) {
return i, true
}
}
}

return -1, false
}
Loading

0 comments on commit e6e4e65

Please sign in to comment.