Skip to content

Commit d4a411a

Browse files
committed
fix(ledger): correct feature-filtered transaction balances
1 parent d37c23d commit d4a411a

6 files changed

Lines changed: 221 additions & 47 deletions

File tree

openmeter/ledger/customerbalance/expired_loader.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func (l *expiredCreditTransactionLoader) Load(ctx context.Context, input creditT
3131

3232
items := make([]CreditTransaction, 0, len(result.Items))
3333
for _, impact := range result.Items {
34+
balanceAsOf := impact.BookedAt
3435
items = append(items, CreditTransaction{
3536
ID: impact.ID,
3637
CreatedAt: impact.CreatedAt,
@@ -40,6 +41,7 @@ func (l *expiredCreditTransactionLoader) Load(ctx context.Context, input creditT
4041
Amount: impact.Amount,
4142
Name: "Expired credits",
4243
Annotations: impact.Annotations,
44+
balanceAsOf: &balanceAsOf,
4345
})
4446
}
4547

openmeter/ledger/customerbalance/expired_loader_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -310,10 +310,10 @@ func TestListCreditTransactionsFeatureFilter(t *testing.T) {
310310
FeatureFilter: NewFeatureFilter([]string{testFeatureKey}),
311311
})
312312
require.NoError(t, err)
313-
requireCreditTransactionEvents(t, issuedAt, beforeConsumption.Items, []expectedCreditTransaction{
314-
{txType: CreditTransactionTypeFunded, bookedAfter: 2 * time.Minute, amount: 20},
315-
{txType: CreditTransactionTypeFunded, bookedAfter: time.Minute, amount: 10},
316-
{txType: CreditTransactionTypeFunded, bookedAfter: 0, amount: 100},
313+
requireCreditTransactions(t, issuedAt, beforeConsumption.Items, []expectedCreditTransaction{
314+
{txType: CreditTransactionTypeFunded, bookedAfter: 2 * time.Minute, amount: 20, balanceBefore: 110, balanceAfter: 130},
315+
{txType: CreditTransactionTypeFunded, bookedAfter: time.Minute, amount: 10, balanceBefore: 100, balanceAfter: 110},
316+
{txType: CreditTransactionTypeFunded, bookedAfter: 0, amount: 100, balanceBefore: 0, balanceAfter: 100},
317317
})
318318

319319
consumedAt := issuedAt.Add(time.Hour)

openmeter/ledger/customerbalance/funded_loader.go

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package customerbalance
22

33
import (
44
"context"
5+
"fmt"
56

67
"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
78
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
@@ -33,20 +34,26 @@ func (l *fundedCreditTransactionLoader) Load(ctx context.Context, input creditTr
3334

3435
items := make([]CreditTransaction, 0, len(result.Items))
3536
for _, activity := range result.Items {
37+
balanceCursor, err := l.balanceCursorForFundedActivity(ctx, input.CustomerID.Namespace, activity)
38+
if err != nil {
39+
return creditTransactionLoaderResult{}, err
40+
}
41+
3642
annotations := models.Annotations{
3743
ledger.AnnotationChargeID: activity.ChargeID.ID,
3844
}
3945

4046
items = append(items, CreditTransaction{
41-
ID: models.NamespacedID(activity.ChargeID),
42-
CreatedAt: activity.ChargeCreatedAt,
43-
BookedAt: activity.FundedAt,
44-
Type: CreditTransactionTypeFunded,
45-
Currency: activity.Currency,
46-
Amount: activity.Amount,
47-
Name: activity.Name,
48-
Description: activity.Description,
49-
Annotations: annotations,
47+
ID: models.NamespacedID(activity.ChargeID),
48+
CreatedAt: activity.ChargeCreatedAt,
49+
BookedAt: activity.FundedAt,
50+
Type: CreditTransactionTypeFunded,
51+
Currency: activity.Currency,
52+
Amount: activity.Amount,
53+
Name: activity.Name,
54+
Description: activity.Description,
55+
Annotations: annotations,
56+
balanceCursor: balanceCursor,
5057
})
5158
}
5259

@@ -56,6 +63,38 @@ func (l *fundedCreditTransactionLoader) Load(ctx context.Context, input creditTr
5663
}, nil
5764
}
5865

66+
func (l *fundedCreditTransactionLoader) balanceCursorForFundedActivity(
67+
ctx context.Context,
68+
namespace string,
69+
activity creditpurchase.FundedCreditActivity,
70+
) (*ledger.TransactionCursor, error) {
71+
if activity.TransactionGroupID == "" {
72+
return nil, nil
73+
}
74+
75+
group, err := l.service.Ledger.GetTransactionGroup(ctx, models.NamespacedID{
76+
Namespace: namespace,
77+
ID: activity.TransactionGroupID,
78+
})
79+
if err != nil {
80+
return nil, fmt.Errorf("get funded credit transaction group %s: %w", activity.TransactionGroupID, err)
81+
}
82+
83+
for _, tx := range group.Transactions() {
84+
impact, currency, err := creditTransactionFBOImpact(tx)
85+
if err != nil {
86+
continue
87+
}
88+
89+
if currency == activity.Currency && impact.Equal(activity.Amount) {
90+
cursor := tx.Cursor()
91+
return &cursor, nil
92+
}
93+
}
94+
95+
return nil, fmt.Errorf("funded credit transaction group %s has no matching customer FBO transaction", activity.TransactionGroupID)
96+
}
97+
5998
func toFundedCreditActivityCursor(cursor *ledger.TransactionCursor) *creditpurchase.FundedCreditActivityCursor {
6099
if cursor == nil {
61100
return nil

openmeter/ledger/customerbalance/transactions.go

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ type CreditTransaction struct {
111111
Name string
112112
Description *string
113113
Annotations models.Annotations
114+
115+
balanceCursor *ledger.TransactionCursor
116+
balanceAsOf *time.Time
114117
}
115118

116119
type CreditTransactionBalance struct {
@@ -182,9 +185,7 @@ func (s *service) ListCreditTransactions(ctx context.Context, input ListCreditTr
182185
CustomerID: input.CustomerID,
183186
Currency: items[0].Currency,
184187
FeatureFilter: normalizeFeatureFilter(input.FeatureFilter),
185-
BalanceQuery: ledger.BalanceQuery{
186-
After: lo.ToPtr(creditTransactionCursor(items[0])),
187-
},
188+
BalanceQuery: items[0].balanceQuery(),
188189
})
189190
if err != nil {
190191
return ListCreditTransactionsResult{}, fmt.Errorf("get FBO balance after transaction %s: %w", items[0].ID.ID, err)
@@ -264,16 +265,18 @@ func creditTransactionFromLedgerTransaction(tx ledger.Transaction) (CreditTransa
264265
if err != nil {
265266
return CreditTransaction{}, err
266267
}
268+
cursor := tx.Cursor()
267269

268270
return CreditTransaction{
269-
ID: tx.ID(),
270-
CreatedAt: tx.Cursor().CreatedAt,
271-
BookedAt: tx.BookedAt(),
272-
Type: creditTransactionType(fboImpact),
273-
Currency: currency,
274-
Amount: fboImpact,
275-
Name: "",
276-
Annotations: tx.Annotations(),
271+
ID: tx.ID(),
272+
CreatedAt: tx.Cursor().CreatedAt,
273+
BookedAt: tx.BookedAt(),
274+
Type: creditTransactionType(fboImpact),
275+
Currency: currency,
276+
Amount: fboImpact,
277+
Name: "",
278+
Annotations: tx.Annotations(),
279+
balanceCursor: &cursor,
277280
}, nil
278281
}
279282

@@ -314,6 +317,19 @@ func applyCreditTransactionBalances(items []CreditTransaction, after alpacadecim
314317
}
315318
}
316319

320+
func (tx CreditTransaction) balanceQuery() ledger.BalanceQuery {
321+
if tx.balanceCursor != nil {
322+
return ledger.BalanceQuery{After: tx.balanceCursor}
323+
}
324+
325+
if tx.balanceAsOf != nil {
326+
return ledger.BalanceQuery{AsOf: tx.balanceAsOf}
327+
}
328+
329+
asOf := tx.BookedAt
330+
return ledger.BalanceQuery{AsOf: &asOf}
331+
}
332+
317333
func creditTransactionType(fboImpact alpacadecimal.Decimal) CreditTransactionType {
318334
if fboImpact.IsPositive() {
319335
return CreditTransactionTypeFunded

openmeter/ledger/primitives.go

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -274,35 +274,14 @@ func (i ListTransactionsInput) Validate() error {
274274
})
275275
}
276276

277-
if i.Route.Features.IsPresent() && i.Route.MatchFeature != "" {
277+
if err := validateListTransactionsRouteFilter(i.Route); err != nil {
278278
return ErrListTransactionsInputInvalid.WithAttrs(models.Attributes{
279279
"reason": "route_invalid",
280280
"route": i.Route,
281-
"error": errors.New("features and match feature filters cannot be combined"),
281+
"error": err,
282282
})
283283
}
284284

285-
if i.Route.Features.IsPresent() {
286-
features, _ := i.Route.Features.Get()
287-
if err := validateFeatures(features); err != nil {
288-
return ErrListTransactionsInputInvalid.WithAttrs(models.Attributes{
289-
"reason": "route_invalid",
290-
"route": i.Route,
291-
"error": fmt.Errorf("features: %w", err),
292-
})
293-
}
294-
}
295-
296-
if i.Route.MatchFeature != "" {
297-
if err := validateFeatures([]string{i.Route.MatchFeature}); err != nil {
298-
return ErrListTransactionsInputInvalid.WithAttrs(models.Attributes{
299-
"reason": "route_invalid",
300-
"route": i.Route,
301-
"error": fmt.Errorf("match feature: %w", err),
302-
})
303-
}
304-
}
305-
306285
switch i.CreditMovement {
307286
case ListTransactionsCreditMovementUnspecified, ListTransactionsCreditMovementPositive, ListTransactionsCreditMovementNegative:
308287
default:
@@ -315,6 +294,49 @@ func (i ListTransactionsInput) Validate() error {
315294
return nil
316295
}
317296

297+
func validateListTransactionsRouteFilter(route RouteFilter) error {
298+
var errs []error
299+
300+
if route.TaxCode.IsPresent() {
301+
errs = append(errs, errors.New("tax code filter is not supported"))
302+
}
303+
304+
if route.TaxBehavior.IsPresent() {
305+
errs = append(errs, errors.New("tax behavior filter is not supported"))
306+
}
307+
308+
if route.CostBasis.IsPresent() {
309+
errs = append(errs, errors.New("cost basis filter is not supported"))
310+
}
311+
312+
if route.CreditPriority != nil {
313+
errs = append(errs, errors.New("credit priority filter is not supported"))
314+
}
315+
316+
if route.TransactionAuthorizationStatus != nil {
317+
errs = append(errs, errors.New("transaction authorization status filter is not supported"))
318+
}
319+
320+
if route.Features.IsPresent() && route.MatchFeature != "" {
321+
errs = append(errs, errors.New("features and match feature filters cannot be combined"))
322+
}
323+
324+
if route.Features.IsPresent() {
325+
features, _ := route.Features.Get()
326+
if err := validateFeatures(features); err != nil {
327+
errs = append(errs, fmt.Errorf("features: %w", err))
328+
}
329+
}
330+
331+
if route.MatchFeature != "" {
332+
if err := validateFeatures([]string{route.MatchFeature}); err != nil {
333+
errs = append(errs, fmt.Errorf("match feature: %w", err))
334+
}
335+
}
336+
337+
return errors.Join(errs...)
338+
}
339+
318340
type ListTransactionsCreditMovement uint8
319341

320342
const (

openmeter/ledger/validations_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"time"
66

77
"github.com/alpacahq/alpacadecimal"
8+
"github.com/samber/mo"
89
"github.com/stretchr/testify/require"
910

1011
"github.com/openmeterio/openmeter/openmeter/ledger"
@@ -91,6 +92,100 @@ func TestValidateTransactionInputEntryAmountPrecision(t *testing.T) {
9192
}
9293
}
9394

95+
func TestListTransactionsInputValidateRouteFilter(t *testing.T) {
96+
costBasis := alpacadecimal.NewFromFloat(0.7)
97+
taxCode := "vat"
98+
taxBehavior := ledger.TaxBehaviorInclusive
99+
creditPriority := 1
100+
authStatus := ledger.TransactionAuthorizationStatusAuthorized
101+
102+
tests := []struct {
103+
name string
104+
route ledger.RouteFilter
105+
wantErr bool
106+
}{
107+
{
108+
name: "currency route filter is supported",
109+
route: ledger.RouteFilter{
110+
Currency: currencyx.Code("USD"),
111+
},
112+
},
113+
{
114+
name: "exact features route filter is supported",
115+
route: ledger.RouteFilter{
116+
Features: mo.Some([]string{"feature-a"}),
117+
},
118+
},
119+
{
120+
name: "match feature route filter is supported",
121+
route: ledger.RouteFilter{
122+
MatchFeature: "feature-a",
123+
},
124+
},
125+
{
126+
name: "cost basis route filter is rejected",
127+
route: ledger.RouteFilter{
128+
CostBasis: mo.Some(&costBasis),
129+
},
130+
wantErr: true,
131+
},
132+
{
133+
name: "tax code route filter is rejected",
134+
route: ledger.RouteFilter{
135+
TaxCode: mo.Some(&taxCode),
136+
},
137+
wantErr: true,
138+
},
139+
{
140+
name: "tax behavior route filter is rejected",
141+
route: ledger.RouteFilter{
142+
TaxBehavior: mo.Some(&taxBehavior),
143+
},
144+
wantErr: true,
145+
},
146+
{
147+
name: "credit priority route filter is rejected",
148+
route: ledger.RouteFilter{
149+
CreditPriority: &creditPriority,
150+
},
151+
wantErr: true,
152+
},
153+
{
154+
name: "transaction authorization route filter is rejected",
155+
route: ledger.RouteFilter{
156+
TransactionAuthorizationStatus: &authStatus,
157+
},
158+
wantErr: true,
159+
},
160+
{
161+
name: "exact features and match feature cannot be combined",
162+
route: ledger.RouteFilter{
163+
Features: mo.Some([]string{"feature-a"}),
164+
MatchFeature: "feature-a",
165+
},
166+
wantErr: true,
167+
},
168+
}
169+
170+
for _, tt := range tests {
171+
t.Run(tt.name, func(t *testing.T) {
172+
err := ledger.ListTransactionsInput{
173+
Namespace: "ns-test",
174+
Limit: 1,
175+
Route: tt.route,
176+
}.Validate()
177+
178+
if tt.wantErr {
179+
require.Error(t, err)
180+
require.ErrorIs(t, err, ledger.ErrListTransactionsInputInvalid)
181+
return
182+
}
183+
184+
require.NoError(t, err)
185+
})
186+
}
187+
}
188+
94189
func mustPostingAddress(t *testing.T, currency currencyx.Code) ledger.PostingAddress {
95190
t.Helper()
96191

0 commit comments

Comments
 (0)