Skip to content

Commit 19f829b

Browse files
committed
refactor: billing detailed lines
1 parent 7b0118a commit 19f829b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+10425
-1607
lines changed

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2000,6 +2000,8 @@ github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
20002000
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
20012001
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
20022002
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
2003+
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
2004+
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
20032005
github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852 h1:Yl0tPBa8QPjGmesFh1D0rDy+q1Twx6FyU7VWHi8wZbI=
20042006
github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852/go.mod h1:eqOVx5Vwu4gd2mmMZvVZsgIqNSaW3xxRThUJ0k/TPk4=
20052007
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=

openmeter/billing/app.go

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"strings"
78
"time"
89

10+
"github.com/samber/lo"
911
"github.com/samber/mo"
1012

1113
"github.com/openmeterio/openmeter/openmeter/app"
@@ -222,23 +224,42 @@ func (r UpsertInvoiceResult) MergeIntoInvoice(invoice *Invoice) error {
222224
invoice.ExternalIDs.Invoicing = externalID
223225
}
224226

227+
if !invoice.Lines.IsPresent() {
228+
return errors.New("invoice has no expanded lines")
229+
}
230+
225231
var outErr error
226232

227233
// Let's merge the line IDs
228234
if len(r.GetLineExternalIDs()) > 0 {
229-
flattenedLines := invoice.FlattenLinesByID()
235+
lineIDToExternalID := r.GetLineExternalIDs()
236+
dicountIDToExternalID := r.GetLineDiscountExternalIDs()
230237

231-
// Merge the line IDs
232-
for lineID, externalID := range r.GetLineExternalIDs() {
233-
if line, ok := flattenedLines[lineID]; ok {
238+
lines := invoice.Lines.OrEmpty()
239+
240+
for _, line := range lines {
241+
if externalID, ok := lineIDToExternalID[line.ID]; ok {
234242
line.ExternalIDs.Invoicing = externalID
235-
} else {
236-
outErr = errors.Join(outErr, fmt.Errorf("line not found in invoice: %s", lineID))
243+
delete(lineIDToExternalID, line.ID)
244+
}
245+
246+
for idx, detailedLine := range line.DetailedLines {
247+
if externalID, ok := lineIDToExternalID[detailedLine.ID]; ok {
248+
line.DetailedLines[idx].InvoicingAppExternalID = &externalID
249+
delete(lineIDToExternalID, detailedLine.ID)
250+
}
237251
}
238252
}
239253

254+
if len(lineIDToExternalID) > 0 {
255+
outErr = errors.Join(outErr, fmt.Errorf("some lines were not found in the invoice: ids=[%s]", strings.Join(lo.Keys(lineIDToExternalID), ", ")))
256+
}
257+
258+
if len(dicountIDToExternalID) > 0 {
259+
outErr = errors.Join(outErr, fmt.Errorf("some line discounts were not found in the invoice: ids=[%s]", strings.Join(lo.Keys(dicountIDToExternalID), ", ")))
260+
}
261+
240262
// Let's merge the line discount IDs
241-
dicountIDToExternalID := r.GetLineDiscountExternalIDs()
242263

243264
for _, line := range flattenedLines {
244265
for idx, discount := range line.Discounts.Amount {

openmeter/billing/invoice.go

Lines changed: 8 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"errors"
55
"fmt"
66
"slices"
7-
"sort"
87
"strings"
98
"time"
109

@@ -404,45 +403,22 @@ func (i Invoice) RemoveMetaForCompare() Invoice {
404403
return invoice
405404
}
406405

407-
func (i *Invoice) FlattenLinesByID() map[string]*Line {
408-
out := make(map[string]*Line, len(i.Lines.OrEmpty()))
406+
func (i *Invoice) DetailedLinesByID() map[string]DetailedLine {
407+
out := make(map[string]DetailedLine, len(i.Lines.OrEmpty()))
409408

410409
for _, line := range i.Lines.OrEmpty() {
411-
out[line.ID] = line
412-
413-
for _, child := range line.Children.OrEmpty() {
410+
for _, child := range line.DetailedLines {
414411
out[child.ID] = child
415412
}
416413
}
417414

418415
return out
419416
}
420417

421-
// getLeafLines returns the leaf lines
422-
func (i *Invoice) getLeafLines() []*Line {
423-
var leafLines []*Line
424-
425-
for _, line := range i.FlattenLinesByID() {
426-
// Skip non leaf nodes
427-
if line.Type != InvoiceLineTypeFee {
428-
continue
429-
}
430-
431-
leafLines = append(leafLines, line)
432-
}
433-
434-
return leafLines
435-
}
436-
437-
// GetLeafLinesWithConsolidatedTaxBehavior returns the leaf lines with the tax behavior set to the invoice's tax behavior
418+
// GetDetailedLinesWithConsolidatedTaxBehavior returns the detailed lines with the tax behavior set to the invoice's tax behavior
438419
// unless the line already has a tax behavior set.
439-
func (i *Invoice) GetLeafLinesWithConsolidatedTaxBehavior() []*Line {
440-
leafLines := i.getLeafLines()
441-
if i.Workflow.Config.Invoicing.DefaultTaxConfig == nil {
442-
return leafLines
443-
}
444-
445-
return lo.Map(leafLines, func(line *Line, _ int) *Line {
420+
func (i *Invoice) GetDetailedLinesWithConsolidatedTaxBehavior() []DetailedLine {
421+
return lo.Map(lo.Values(i.DetailedLinesByID()), func(line DetailedLine, _ int) DetailedLine {
446422
line.TaxConfig = productcatalog.MergeTaxConfigs(i.Workflow.Config.Invoicing.DefaultTaxConfig, line.TaxConfig)
447423
return line
448424
})
@@ -468,59 +444,9 @@ func (i Invoice) RemoveCircularReferences() Invoice {
468444
return clone
469445
}
470446

447+
// TODO: Do we need this at all?
471448
func (i *Invoice) SortLines() {
472-
if !i.Lines.IsPresent() {
473-
return
474-
}
475-
476-
lines := i.Lines.OrEmpty()
477-
478-
sortLines(lines)
479-
480-
i.Lines = NewLineChildren(lines)
481-
}
482-
483-
func sortLines(lines []*Line) {
484-
sort.Slice(lines, func(a, b int) bool {
485-
lineA := lines[a]
486-
lineB := lines[b]
487-
488-
// If both lines are flat fee lines, we sort them by index if possible
489-
if lineA.Type == InvoiceLineTypeFee && lineB.Type == InvoiceLineTypeFee {
490-
if lineA.FlatFee.Index != nil && lineB.FlatFee.Index != nil {
491-
return *lineA.FlatFee.Index < *lineB.FlatFee.Index
492-
}
493-
494-
if lineA.FlatFee.Index != nil {
495-
return true
496-
}
497-
498-
if lineB.FlatFee.Index != nil {
499-
return false
500-
}
501-
}
502-
503-
if nameOrder := strings.Compare(lineA.Name, lineB.Name); nameOrder != 0 {
504-
return nameOrder < 0
505-
}
506-
507-
if !lineA.Period.Start.Equal(lineB.Period.Start) {
508-
return lineA.Period.Start.Before(lineB.Period.Start)
509-
}
510-
511-
return strings.Compare(lineA.ID, lineB.ID) < 0
512-
})
513-
514-
for idx, line := range lines {
515-
if line.Type == InvoiceLineTypeUsageBased && line.Children.IsPresent() {
516-
children := line.Children.OrEmpty()
517-
sortLines(children)
518-
519-
line.Children = NewLineChildren(children)
520-
}
521-
522-
lines[idx] = line
523-
}
449+
i.Lines = i.Lines.Sorted()
524450
}
525451

526452
type InvoiceExternalIDs struct {

openmeter/billing/invoice_test.go

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import (
44
"testing"
55
"time"
66

7+
"github.com/openmeterio/openmeter/pkg/models"
78
"github.com/samber/lo"
89
"github.com/stretchr/testify/require"
910
)
1011

1112
func TestSortLines(t *testing.T) {
12-
lines := []*Line{
13+
input := NewLineChildren([]*Line{
1314
{
1415
LineBase: LineBase{
1516
Type: InvoiceLineTypeUsageBased,
@@ -19,28 +20,22 @@ func TestSortLines(t *testing.T) {
1920
},
2021
Description: lo.ToPtr("index=1"),
2122
},
22-
Children: NewLineChildren([]*Line{
23+
DetailedLines: DetailedLines{
2324
{
24-
LineBase: LineBase{
25+
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
2526
ID: "child-2",
26-
Type: InvoiceLineTypeFee,
2727
Description: lo.ToPtr("index=1.1"),
28-
},
29-
FlatFee: &FlatFeeLine{
30-
Index: lo.ToPtr(1),
31-
},
28+
}),
29+
Index: lo.ToPtr(1),
3230
},
3331
{
34-
LineBase: LineBase{
32+
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
3533
ID: "child-1",
36-
Type: InvoiceLineTypeFee,
3734
Description: lo.ToPtr("index=1.0"),
38-
},
39-
FlatFee: &FlatFeeLine{
40-
Index: lo.ToPtr(0),
41-
},
35+
}),
36+
Index: lo.ToPtr(0),
4237
},
43-
}),
38+
},
4439
},
4540
{
4641
LineBase: LineBase{
@@ -51,15 +46,14 @@ func TestSortLines(t *testing.T) {
5146
},
5247
Description: lo.ToPtr("index=0"),
5348
},
54-
Children: NewLineChildren(nil),
5549
},
56-
}
50+
})
5751

58-
sortLines(lines)
52+
lines := input.Sorted().OrEmpty()
5953

6054
require.Equal(t, *lines[0].Description, "index=0")
6155
require.Equal(t, *lines[1].Description, "index=1")
62-
children := lines[1].Children.OrEmpty()
56+
children := lines[1].DetailedLines
6357
require.Equal(t, *children[0].Description, "index=1.0")
6458
require.Equal(t, *children[1].Description, "index=1.1")
6559
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package billing
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"reflect"
7+
"slices"
8+
9+
"github.com/alpacahq/alpacadecimal"
10+
"github.com/openmeterio/openmeter/openmeter/productcatalog"
11+
"github.com/openmeterio/openmeter/pkg/currencyx"
12+
"github.com/openmeterio/openmeter/pkg/models"
13+
"github.com/samber/lo"
14+
)
15+
16+
type DetailedLineCategory string
17+
18+
const (
19+
// DetailedLineCategoryRegular is a regular flat fee, that is based on the usage or a subscription.
20+
DetailedLineCategoryRegular DetailedLineCategory = "regular"
21+
// DetailedLineCategoryCommitment is a flat fee that is based on a commitment such as min spend.
22+
DetailedLineCategoryCommitment DetailedLineCategory = "commitment"
23+
)
24+
25+
func (DetailedLineCategory) Values() []string {
26+
return []string{
27+
string(DetailedLineCategoryRegular),
28+
string(DetailedLineCategoryCommitment),
29+
}
30+
}
31+
32+
var _ models.Validator = (*DetailedLineCategory)(nil)
33+
34+
func (c DetailedLineCategory) Validate() error {
35+
if !slices.Contains(DetailedLineCategory("").Values(), string(c)) {
36+
return fmt.Errorf("invalid category %s", c)
37+
}
38+
return nil
39+
}
40+
41+
type DetailedLine struct {
42+
models.Annotations
43+
models.ManagedResource
44+
45+
// Relationships
46+
InvoiceID string `json:"invoiceID"`
47+
ParentLineID string `json:"parentLineID"`
48+
49+
// Line details
50+
Category DetailedLineCategory `json:"category"`
51+
ChildUniqueReferenceID *string `json:"childUniqueReferenceID,omitempty"`
52+
Currency currencyx.Code `json:"currency"`
53+
Index *int `json:"index,omitempty"`
54+
PaymentTerm productcatalog.PaymentTermType `json:"paymentTerm"`
55+
PerUnitAmount alpacadecimal.Decimal `json:"perUnitAmount"`
56+
Quantity alpacadecimal.Decimal `json:"quantity"`
57+
ServicePeriod Period `json:"servicePeriod"`
58+
59+
// Apps
60+
TaxConfig *productcatalog.TaxConfig `json:"taxConfig,omitempty"`
61+
InvoicingAppExternalID *string `json:"invoicingAppExternalID,omitempty"`
62+
63+
// Discounts
64+
AmountDiscounts AmountLineDiscountsManaged `json:"discounts,omitempty"`
65+
}
66+
67+
var _ models.Validator = (*DetailedLine)(nil)
68+
69+
func (l DetailedLine) Validate() error {
70+
errs := []error{}
71+
72+
if l.InvoiceID == "" {
73+
errs = append(errs, errors.New("invoiceID is required"))
74+
}
75+
76+
if l.ParentLineID == "" {
77+
errs = append(errs, errors.New("parentLineID is required"))
78+
}
79+
80+
if err := l.Category.Validate(); err != nil {
81+
errs = append(errs, fmt.Errorf("category: %w", err))
82+
}
83+
84+
if l.PerUnitAmount.IsNegative() {
85+
errs = append(errs, errors.New("price should be positive or zero"))
86+
}
87+
88+
if l.Quantity.IsNegative() {
89+
errs = append(errs, errors.New("quantity should be positive or zero"))
90+
}
91+
92+
if err := l.PaymentTerm.Validate(); err != nil {
93+
errs = append(errs, fmt.Errorf("payment term: %w", err))
94+
}
95+
96+
if err := l.ServicePeriod.Validate(); err != nil {
97+
errs = append(errs, fmt.Errorf("service period: %w", err))
98+
}
99+
100+
if err := l.Currency.Validate(); err != nil {
101+
errs = append(errs, fmt.Errorf("currency: %w", err))
102+
}
103+
104+
if err := l.AmountDiscounts.Validate(); err != nil {
105+
errs = append(errs, fmt.Errorf("amount discounts: %w", err))
106+
}
107+
108+
return errors.Join(errs...)
109+
}
110+
111+
// TODO: Is this even needed?
112+
func (l DetailedLine) Clone() DetailedLine {
113+
if l.TaxConfig != nil {
114+
taxConfig := *l.TaxConfig
115+
l.TaxConfig = &taxConfig
116+
}
117+
118+
return l
119+
}
120+
121+
func (l DetailedLine) Equal(other *DetailedLine) bool {
122+
return reflect.DeepEqual(l, *other)
123+
}
124+
125+
type DetailedLines []DetailedLine
126+
127+
func (l DetailedLines) Map(fn func(DetailedLine) DetailedLine) DetailedLines {
128+
return lo.Map(l, func(item DetailedLine, _ int) DetailedLine {
129+
return fn(item)
130+
})
131+
}

0 commit comments

Comments
 (0)