Skip to content

Commit 4f61789

Browse files
authored
feat(ledger): transaction listing API (#4147)
1 parent 91ea6e1 commit 4f61789

45 files changed

Lines changed: 1757 additions & 79 deletions

Some content is hidden

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

api/v3/handlers/customers/credits/convert.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment"
1414
"github.com/openmeterio/openmeter/openmeter/billing/creditgrant"
1515
"github.com/openmeterio/openmeter/openmeter/ledger"
16+
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
1617
"github.com/openmeterio/openmeter/openmeter/productcatalog"
1718
"github.com/openmeterio/openmeter/pkg/currencyx"
1819
"github.com/openmeterio/openmeter/pkg/models"
@@ -307,3 +308,104 @@ func convertBalance(currency currencyx.Code, balance ledger.Balance) api.CreditB
307308
Pending: balance.Pending().String(),
308309
}
309310
}
311+
312+
func fromAPIBillingCreditTransactionType(filter *api.BillingCreditTransactionType) *customerbalance.CreditTransactionType {
313+
if filter == nil {
314+
return nil
315+
}
316+
317+
var txType customerbalance.CreditTransactionType
318+
switch *filter {
319+
case api.BillingCreditTransactionTypeFunded:
320+
txType = customerbalance.CreditTransactionTypeFunded
321+
case api.BillingCreditTransactionTypeConsumed:
322+
txType = customerbalance.CreditTransactionTypeConsumed
323+
case api.BillingCreditTransactionTypeAdjusted:
324+
txType = customerbalance.CreditTransactionTypeAdjusted
325+
default:
326+
return nil
327+
}
328+
329+
return &txType
330+
}
331+
332+
func toAPIBillingCreditTransactions(items []customerbalance.CreditTransaction) []api.BillingCreditTransaction {
333+
out := make([]api.BillingCreditTransaction, 0, len(items))
334+
335+
for _, item := range items {
336+
out = append(out, toAPIBillingCreditTransaction(item))
337+
}
338+
339+
return out
340+
}
341+
342+
func toAPIBillingCreditTransaction(tx customerbalance.CreditTransaction) api.BillingCreditTransaction {
343+
apiTx := api.BillingCreditTransaction{
344+
Id: tx.ID.ID,
345+
CreatedAt: &tx.CreatedAt,
346+
BookedAt: tx.BookedAt,
347+
Type: toAPIBillingCreditTransactionType(tx.Type),
348+
Currency: api.BillingCurrencyCode(tx.Currency),
349+
Amount: tx.Amount.String(),
350+
Name: tx.Name,
351+
AvailableBalance: struct {
352+
After api.Numeric `json:"after"`
353+
Before api.Numeric `json:"before"`
354+
}{
355+
Before: tx.Balance.Before.String(),
356+
After: tx.Balance.After.String(),
357+
},
358+
}
359+
360+
labels := creditTransactionLabels(tx.Annotations)
361+
if len(labels) > 0 {
362+
apiLabels := api.Labels(labels)
363+
apiTx.Labels = &apiLabels
364+
}
365+
366+
return apiTx
367+
}
368+
369+
func toAPIBillingCreditTransactionType(txType customerbalance.CreditTransactionType) api.BillingCreditTransactionType {
370+
switch txType {
371+
case customerbalance.CreditTransactionTypeFunded:
372+
return api.BillingCreditTransactionTypeFunded
373+
case customerbalance.CreditTransactionTypeConsumed:
374+
return api.BillingCreditTransactionTypeConsumed
375+
default:
376+
return api.BillingCreditTransactionTypeAdjusted
377+
}
378+
}
379+
380+
func creditTransactionLabels(annotations models.Annotations) map[string]string {
381+
labels := make(map[string]string)
382+
383+
setLabel := func(key, annotationKey string) {
384+
value := stringAnnotation(annotations, annotationKey)
385+
if value != "" {
386+
labels[key] = value
387+
}
388+
}
389+
390+
setLabel("charge_id", ledger.AnnotationChargeID)
391+
setLabel("subscription_id", ledger.AnnotationSubscriptionID)
392+
setLabel("subscription_phase_id", ledger.AnnotationSubscriptionPhaseID)
393+
setLabel("subscription_item_id", ledger.AnnotationSubscriptionItemID)
394+
setLabel("feature_id", ledger.AnnotationFeatureID)
395+
396+
return labels
397+
}
398+
399+
func stringAnnotation(annotations models.Annotations, key string) string {
400+
raw, ok := annotations[key]
401+
if !ok {
402+
return ""
403+
}
404+
405+
value, ok := raw.(string)
406+
if !ok {
407+
return ""
408+
}
409+
410+
return value
411+
}

api/v3/handlers/customers/credits/handler.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,36 @@ package customerscredits
33
import (
44
"context"
55

6+
"github.com/alpacahq/alpacadecimal"
7+
68
"github.com/openmeterio/openmeter/openmeter/billing/creditgrant"
79
"github.com/openmeterio/openmeter/openmeter/customer"
10+
"github.com/openmeterio/openmeter/openmeter/ledger"
811
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
912
"github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
1013
)
1114

1215
type customerBalanceFacade interface {
16+
GetBalance(ctx context.Context, input customerbalance.GetBalanceInput) (alpacadecimal.Decimal, error)
1317
GetBalances(ctx context.Context, input customerbalance.GetBalancesInput) ([]customerbalance.BalanceByCurrency, error)
18+
ListCreditTransactions(ctx context.Context, input customerbalance.ListCreditTransactionsInput) (customerbalance.ListCreditTransactionsResult, error)
1419
}
1520

1621
type Handler interface {
1722
GetCustomerCreditBalance() GetCustomerCreditBalanceHandler
1823
ListCreditGrants() ListCreditGrantsHandler
1924
CreateCreditGrant() CreateCreditGrantHandler
2025
GetCreditGrant() GetCreditGrantHandler
26+
ListCreditTransactions() ListCreditTransactionsHandler
2127
}
2228

2329
type handler struct {
2430
resolveNamespace func(ctx context.Context) (string, error)
2531
customerService customer.Service
2632
balanceFacade customerBalanceFacade
2733
creditGrantService creditgrant.Service
34+
ledger ledger.Ledger
35+
accountResolver ledger.AccountResolver
2836
options []httptransport.HandlerOption
2937
}
3038

@@ -33,13 +41,17 @@ func New(
3341
customerService customer.Service,
3442
balanceFacade customerBalanceFacade,
3543
creditGrantService creditgrant.Service,
44+
ledger ledger.Ledger,
45+
accountResolver ledger.AccountResolver,
3646
options ...httptransport.HandlerOption,
3747
) Handler {
3848
return &handler{
3949
resolveNamespace: resolveNamespace,
4050
customerService: customerService,
4151
balanceFacade: balanceFacade,
4252
creditGrantService: creditGrantService,
53+
ledger: ledger,
54+
accountResolver: accountResolver,
4355
options: options,
4456
}
4557
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package customerscredits
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
8+
"github.com/samber/lo"
9+
10+
api "github.com/openmeterio/openmeter/api/v3"
11+
"github.com/openmeterio/openmeter/api/v3/apierrors"
12+
"github.com/openmeterio/openmeter/api/v3/response"
13+
"github.com/openmeterio/openmeter/openmeter/customer"
14+
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
15+
"github.com/openmeterio/openmeter/pkg/currencyx"
16+
"github.com/openmeterio/openmeter/pkg/framework/commonhttp"
17+
"github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
18+
"github.com/openmeterio/openmeter/pkg/pagination"
19+
)
20+
21+
type (
22+
ListCreditTransactionsRequest = customerbalance.ListCreditTransactionsInput
23+
ListCreditTransactionsResponse = response.PagePaginationResponse[api.BillingCreditTransaction]
24+
ListCreditTransactionsParams struct {
25+
CustomerID api.ULID
26+
Params api.ListCreditTransactionsParams
27+
}
28+
ListCreditTransactionsHandler httptransport.HandlerWithArgs[ListCreditTransactionsRequest, ListCreditTransactionsResponse, ListCreditTransactionsParams]
29+
)
30+
31+
func (h *handler) ListCreditTransactions() ListCreditTransactionsHandler {
32+
return httptransport.NewHandlerWithArgs(
33+
func(ctx context.Context, r *http.Request, args ListCreditTransactionsParams) (ListCreditTransactionsRequest, error) {
34+
ns, err := h.resolveNamespace(ctx)
35+
if err != nil {
36+
return ListCreditTransactionsRequest{}, err
37+
}
38+
39+
page := pagination.NewPage(1, 20)
40+
if args.Params.Page != nil {
41+
page = pagination.NewPage(
42+
lo.FromPtrOr(args.Params.Page.Number, 1),
43+
lo.FromPtrOr(args.Params.Page.Size, 20),
44+
)
45+
}
46+
47+
if err := page.Validate(); err != nil {
48+
return ListCreditTransactionsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
49+
{
50+
Field: "page",
51+
Reason: err.Error(),
52+
Source: apierrors.InvalidParamSourceQuery,
53+
},
54+
})
55+
}
56+
57+
req := customerbalance.ListCreditTransactionsInput{
58+
CustomerID: customer.CustomerID{
59+
Namespace: ns,
60+
ID: args.CustomerID,
61+
},
62+
Page: page,
63+
}
64+
65+
if args.Params.Filter != nil {
66+
req.Type = fromAPIBillingCreditTransactionType(args.Params.Filter.Type)
67+
68+
if args.Params.Filter.Currency != nil {
69+
currency := currencyx.Code(*args.Params.Filter.Currency)
70+
req.Currency = &currency
71+
}
72+
}
73+
74+
return req, nil
75+
},
76+
func(ctx context.Context, request ListCreditTransactionsRequest) (ListCreditTransactionsResponse, error) {
77+
result, err := h.balanceFacade.ListCreditTransactions(ctx, request)
78+
if err != nil {
79+
return ListCreditTransactionsResponse{}, fmt.Errorf("list credit transactions: %w", err)
80+
}
81+
82+
return response.NewPagePaginationResponse(toAPIBillingCreditTransactions(result.Items), response.PageMetaPage{
83+
Size: request.Page.PageSize,
84+
Number: request.Page.PageNumber,
85+
Total: lo.ToPtr(result.TotalCount),
86+
}), nil
87+
},
88+
commonhttp.JSONResponseEncoderWithStatus[ListCreditTransactionsResponse](http.StatusOK),
89+
httptransport.AppendOptions(
90+
h.options,
91+
httptransport.WithOperationName("list-credit-transactions"),
92+
httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
93+
)...,
94+
)
95+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package customerscredits
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/alpacahq/alpacadecimal"
8+
"github.com/stretchr/testify/require"
9+
10+
api "github.com/openmeterio/openmeter/api/v3"
11+
"github.com/openmeterio/openmeter/openmeter/ledger"
12+
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
13+
"github.com/openmeterio/openmeter/pkg/currencyx"
14+
"github.com/openmeterio/openmeter/pkg/models"
15+
)
16+
17+
func TestFromAPIBillingCreditTransactionType_Adjusted(t *testing.T) {
18+
filter := api.BillingCreditTransactionTypeAdjusted
19+
20+
txType := fromAPIBillingCreditTransactionType(&filter)
21+
22+
require.NotNil(t, txType)
23+
require.Equal(t, customerbalance.CreditTransactionTypeAdjusted, *txType)
24+
}
25+
26+
func TestToAPIBillingCreditTransaction(t *testing.T) {
27+
createdAt := time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC)
28+
bookedAt := createdAt.Add(time.Second)
29+
30+
tx := toAPIBillingCreditTransaction(customerbalance.CreditTransaction{
31+
ID: models.NamespacedID{
32+
Namespace: "ns",
33+
ID: "tx-1",
34+
},
35+
CreatedAt: createdAt,
36+
BookedAt: bookedAt,
37+
Type: customerbalance.CreditTransactionTypeConsumed,
38+
Currency: currencyx.Code("USD"),
39+
Amount: alpacadecimal.NewFromInt(-10),
40+
Balance: customerbalance.CreditTransactionBalance{
41+
Before: alpacadecimal.NewFromInt(52),
42+
After: alpacadecimal.NewFromInt(42),
43+
},
44+
Name: "credit_transaction",
45+
Annotations: models.Annotations{
46+
ledger.AnnotationChargeID: "charge-1",
47+
},
48+
})
49+
50+
require.Equal(t, api.ULID("tx-1"), tx.Id)
51+
require.Equal(t, api.BillingCreditTransactionTypeConsumed, tx.Type)
52+
require.Equal(t, api.BillingCurrencyCode("USD"), tx.Currency)
53+
require.Equal(t, api.Numeric("-10"), tx.Amount)
54+
require.Equal(t, api.Numeric("52"), tx.AvailableBalance.Before)
55+
require.Equal(t, api.Numeric("42"), tx.AvailableBalance.After)
56+
require.NotNil(t, tx.Labels)
57+
require.Equal(t, "charge-1", (*tx.Labels)["charge_id"])
58+
}

api/v3/server/routes.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ func (s *Server) DeletePlanAddon(w http.ResponseWriter, r *http.Request, planId
323323
var unimplemented = api.Unimplemented{}
324324

325325
func (s *Server) GetCustomerCreditBalance(w http.ResponseWriter, r *http.Request, customerId api.ULID, params api.GetCustomerCreditBalanceParams) {
326-
if s.customersCreditsHandler == nil {
326+
if !s.Credits.Enabled || s.customersCreditsHandler == nil {
327327
unimplemented.GetCustomerCreditBalance(w, r, customerId, params)
328328
return
329329
}
@@ -335,7 +335,7 @@ func (s *Server) GetCustomerCreditBalance(w http.ResponseWriter, r *http.Request
335335
}
336336

337337
func (s *Server) ListCreditGrants(w http.ResponseWriter, r *http.Request, customerId api.ULID, params api.ListCreditGrantsParams) {
338-
if s.customersCreditsHandler == nil || s.CreditGrantService == nil {
338+
if !s.Credits.Enabled || s.customersCreditsHandler == nil || s.CreditGrantService == nil {
339339
unimplemented.ListCreditGrants(w, r, customerId, params)
340340
return
341341
}
@@ -347,7 +347,7 @@ func (s *Server) ListCreditGrants(w http.ResponseWriter, r *http.Request, custom
347347
}
348348

349349
func (s *Server) CreateCreditGrant(w http.ResponseWriter, r *http.Request, customerId api.ULID) {
350-
if s.customersCreditsHandler == nil || s.CreditGrantService == nil {
350+
if !s.Credits.Enabled || s.customersCreditsHandler == nil || s.CreditGrantService == nil {
351351
unimplemented.CreateCreditGrant(w, r, customerId)
352352
return
353353
}
@@ -358,7 +358,7 @@ func (s *Server) CreateCreditGrant(w http.ResponseWriter, r *http.Request, custo
358358
}
359359

360360
func (s *Server) GetCreditGrant(w http.ResponseWriter, r *http.Request, customerId api.ULID, creditGrantId api.ULID) {
361-
if s.customersCreditsHandler == nil || s.CreditGrantService == nil {
361+
if !s.Credits.Enabled || s.customersCreditsHandler == nil || s.CreditGrantService == nil {
362362
unimplemented.GetCreditGrant(w, r, customerId, creditGrantId)
363363
return
364364
}
@@ -378,7 +378,15 @@ func (s *Server) UpdateCreditGrantExternalSettlement(w http.ResponseWriter, r *h
378378
}
379379

380380
func (s *Server) ListCreditTransactions(w http.ResponseWriter, r *http.Request, customerId api.ULID, params api.ListCreditTransactionsParams) {
381-
unimplemented.ListCreditTransactions(w, r, customerId, params)
381+
if !s.Credits.Enabled || s.customersCreditsHandler == nil || s.Ledger == nil {
382+
unimplemented.ListCreditTransactions(w, r, customerId, params)
383+
return
384+
}
385+
386+
s.customersCreditsHandler.ListCreditTransactions().With(customerscreditshandler.ListCreditTransactionsParams{
387+
CustomerID: customerId,
388+
Params: params,
389+
}).ServeHTTP(w, r)
382390
}
383391

384392
// Charges

0 commit comments

Comments
 (0)