Skip to content

Commit 78d5c7a

Browse files
committed
chore: simplify lineservice
1 parent 93aed55 commit 78d5c7a

25 files changed

+842
-664
lines changed

openmeter/billing/errors.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,18 @@ type AppError struct {
152152
func (e AppError) Error() string {
153153
return fmt.Sprintf("app %s type with id %s in namespace %s: %s", e.AppType, e.AppID.ID, e.AppID.Namespace, e.Err.Error())
154154
}
155+
156+
// ErrSnapshotInvalidDatabaseState is returned when the database state is invalid for snapshotting the line quantity.
157+
// This can happen if the feature or meter is not found. In such cases we should transition the invoice to
158+
// draft.invalid state.
159+
type ErrSnapshotInvalidDatabaseState struct {
160+
Err error
161+
}
162+
163+
func (e ErrSnapshotInvalidDatabaseState) Error() string {
164+
return fmt.Sprintf("snapshotting line quantity: %s", e.Err.Error())
165+
}
166+
167+
func (e ErrSnapshotInvalidDatabaseState) Unwrap() error {
168+
return e.Err
169+
}

openmeter/billing/featuremeter.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package billing
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/openmeterio/openmeter/openmeter/meter"
7+
"github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
8+
)
9+
10+
type FeatureMeter struct {
11+
Feature feature.Feature
12+
Meter *meter.Meter
13+
}
14+
15+
type FeatureMeters map[string]FeatureMeter
16+
17+
func (f FeatureMeters) Get(featureKey string, dependsOnMeteredQuantity bool) (FeatureMeter, error) {
18+
featureMeter, exists := f[featureKey]
19+
if !exists {
20+
return FeatureMeter{}, &ErrSnapshotInvalidDatabaseState{
21+
Err: fmt.Errorf("feature[%s] not found", featureKey),
22+
}
23+
}
24+
25+
if dependsOnMeteredQuantity && featureMeter.Meter == nil {
26+
return FeatureMeter{}, &ErrSnapshotInvalidDatabaseState{
27+
Err: fmt.Errorf("feature[%s] has no meter associated, but the line depends on metered quantity", featureMeter.Feature.Key),
28+
}
29+
}
30+
31+
return featureMeter, nil
32+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package billingservice
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/samber/lo"
9+
10+
"github.com/openmeterio/openmeter/openmeter/billing"
11+
"github.com/openmeterio/openmeter/openmeter/meter"
12+
"github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
13+
)
14+
15+
func (s *Service) resolveFeatureMeters(ctx context.Context, lines billing.StandardLines) (billing.FeatureMeters, error) {
16+
namespaces := lo.Uniq(lo.Map(lines, func(line *billing.StandardLine, _ int) string {
17+
return line.Namespace
18+
}))
19+
20+
if len(namespaces) != 1 {
21+
return nil, fmt.Errorf("all lines must be in the same namespace")
22+
}
23+
24+
namespace := namespaces[0]
25+
26+
featuresToResolve := lo.Uniq(
27+
lo.Filter(
28+
lo.Map(lines, func(line *billing.StandardLine, _ int) string {
29+
return line.UsageBased.FeatureKey
30+
}),
31+
func(featureKey string, _ int) bool {
32+
return featureKey != ""
33+
},
34+
),
35+
)
36+
37+
// Let's resolve the features
38+
features, err := s.featureService.ListFeatures(ctx, feature.ListFeaturesParams{
39+
IDsOrKeys: featuresToResolve,
40+
Namespace: namespace,
41+
IncludeArchived: true,
42+
})
43+
if err != nil {
44+
return nil, fmt.Errorf("listing features: %w", err)
45+
}
46+
47+
featuresByKey := getLastFeatures(features.Items)
48+
49+
metersToResolve := lo.Uniq(
50+
lo.Filter(
51+
lo.Map(lo.Values(featuresByKey), func(feature feature.Feature, _ int) string {
52+
if feature.MeterSlug == nil {
53+
return ""
54+
}
55+
56+
return *feature.MeterSlug
57+
}),
58+
func(meterSlug string, _ int) bool {
59+
return meterSlug != ""
60+
},
61+
),
62+
)
63+
64+
meters, err := s.meterService.ListMeters(ctx, meter.ListMetersParams{
65+
SlugFilter: lo.ToPtr(metersToResolve),
66+
Namespace: namespace,
67+
IncludeDeleted: true,
68+
})
69+
if err != nil {
70+
return nil, fmt.Errorf("listing meters: %w", err)
71+
}
72+
73+
metersByKey := getLastMeters(meters.Items)
74+
75+
out := make(billing.FeatureMeters, len(featuresByKey))
76+
for featureKey, feature := range featuresByKey {
77+
if feature.MeterSlug == nil {
78+
out[featureKey] = billing.FeatureMeter{
79+
Feature: feature,
80+
}
81+
82+
continue
83+
}
84+
85+
meter, exists := metersByKey[*feature.MeterSlug]
86+
if !exists {
87+
out[featureKey] = billing.FeatureMeter{
88+
Feature: feature,
89+
}
90+
91+
continue
92+
}
93+
94+
out[featureKey] = billing.FeatureMeter{
95+
Feature: feature,
96+
Meter: &meter,
97+
}
98+
}
99+
100+
return out, nil
101+
}
102+
103+
type lastEntityAccessor[T any] interface {
104+
GetKey(T) string
105+
GetDeletedAt(T) *time.Time
106+
}
107+
108+
func getLastEntity[T any](entities []T, accessor lastEntityAccessor[T]) map[string]T {
109+
featuresByKey := lo.GroupBy(entities, func(entity T) string {
110+
return accessor.GetKey(entity)
111+
})
112+
113+
out := make(map[string]T, len(featuresByKey))
114+
for key, features := range featuresByKey {
115+
// Let's try to find an unarchived feature
116+
out[key] = latestEntity(features, accessor)
117+
}
118+
119+
return out
120+
}
121+
122+
func latestEntity[T any](entities []T, accessor lastEntityAccessor[T]) T {
123+
for _, entity := range entities {
124+
if accessor.GetDeletedAt(entity) == nil {
125+
return entity
126+
}
127+
}
128+
129+
// Otherwise, let's find the most recently archived feature:
130+
// - all entities have non-nil deleted at (or we would have returned already)
131+
// - and we have at least one entity due to the definition of the groupBy
132+
mostRecentlyArchivedFeature := entities[0]
133+
for _, entity := range entities {
134+
if accessor.GetDeletedAt(entity).After(*accessor.GetDeletedAt(mostRecentlyArchivedFeature)) {
135+
mostRecentlyArchivedFeature = entity
136+
}
137+
}
138+
139+
return mostRecentlyArchivedFeature
140+
}
141+
142+
type featureAccessor struct{}
143+
144+
var _ lastEntityAccessor[feature.Feature] = (*featureAccessor)(nil)
145+
146+
func (a featureAccessor) GetKey(feature feature.Feature) string {
147+
return feature.Key
148+
}
149+
150+
func (a featureAccessor) GetDeletedAt(feature feature.Feature) *time.Time {
151+
return feature.ArchivedAt
152+
}
153+
154+
func getLastFeatures(features []feature.Feature) map[string]feature.Feature {
155+
return getLastEntity(features, featureAccessor{})
156+
}
157+
158+
type meterAccessor struct{}
159+
160+
var _ lastEntityAccessor[meter.Meter] = (*meterAccessor)(nil)
161+
162+
func (a meterAccessor) GetKey(meter meter.Meter) string {
163+
return meter.Key
164+
}
165+
166+
func (a meterAccessor) GetDeletedAt(meter meter.Meter) *time.Time {
167+
return meter.DeletedAt
168+
}
169+
170+
func getLastMeters(meters []meter.Meter) map[string]meter.Meter {
171+
return getLastEntity(meters, meterAccessor{})
172+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package billingservice
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/samber/lo"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
11+
)
12+
13+
func TestGetLastFeatures(t *testing.T) {
14+
tcs := []struct {
15+
name string
16+
features []feature.Feature
17+
expected map[string]string
18+
}{
19+
{
20+
name: "single-active",
21+
features: []feature.Feature{
22+
{ID: "id-active", ArchivedAt: nil, Key: "feature-1-active"},
23+
},
24+
expected: map[string]string{"feature-1-active": "id-active"},
25+
},
26+
{
27+
name: "single-archived",
28+
features: []feature.Feature{
29+
{ID: "id-archived", ArchivedAt: lo.ToPtr(time.Now()), Key: "feature-1-archived"},
30+
},
31+
expected: map[string]string{"feature-1-archived": "id-archived"},
32+
},
33+
{
34+
name: "multi-archived",
35+
features: []feature.Feature{
36+
{ID: "id-archived", ArchivedAt: lo.ToPtr(time.Now()), Key: "feature-1"},
37+
{ID: "id-active", ArchivedAt: nil, Key: "feature-1"},
38+
},
39+
expected: map[string]string{"feature-1": "id-active"},
40+
},
41+
{
42+
name: "archived-ordering",
43+
features: []feature.Feature{
44+
{ID: "id-archived-1", ArchivedAt: lo.ToPtr(time.Now()), Key: "feature-1"},
45+
{ID: "id-archived-2", ArchivedAt: lo.ToPtr(time.Now().Add(5 * time.Second)), Key: "feature-1"},
46+
},
47+
expected: map[string]string{"feature-1": "id-archived-2"},
48+
},
49+
}
50+
51+
for _, tc := range tcs {
52+
t.Run(tc.name, func(t *testing.T) {
53+
out := getLastFeatures(tc.features)
54+
55+
featureKeyToID := map[string]string{}
56+
for key, feature := range out {
57+
featureKeyToID[key] = feature.ID
58+
}
59+
60+
require.Equal(t, tc.expected, featureKeyToID)
61+
})
62+
}
63+
}

0 commit comments

Comments
 (0)