Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions api/v3/handlers/customers/credits/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment"
"github.com/openmeterio/openmeter/openmeter/billing/creditgrant"
"github.com/openmeterio/openmeter/openmeter/ledger"
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
"github.com/openmeterio/openmeter/openmeter/productcatalog"
"github.com/openmeterio/openmeter/pkg/currencyx"
"github.com/openmeterio/openmeter/pkg/models"
Expand Down Expand Up @@ -307,3 +308,104 @@ func convertBalance(currency currencyx.Code, balance ledger.Balance) api.CreditB
Pending: balance.Pending().String(),
}
}

func fromAPIBillingCreditTransactionType(filter *api.BillingCreditTransactionType) *customerbalance.CreditTransactionType {
if filter == nil {
return nil
}

var txType customerbalance.CreditTransactionType
switch *filter {
case api.BillingCreditTransactionTypeFunded:
txType = customerbalance.CreditTransactionTypeFunded
case api.BillingCreditTransactionTypeConsumed:
txType = customerbalance.CreditTransactionTypeConsumed
case api.BillingCreditTransactionTypeAdjusted:
txType = customerbalance.CreditTransactionTypeAdjusted
default:
return nil
}
Comment on lines +312 to +327
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Invalid transaction-type filters currently get silently dropped

On Line 326, unknown values return nil, which effectively removes the filter instead of rejecting bad input. That can broaden results unexpectedly for clients.

💡 Suggested fix
-func fromAPIBillingCreditTransactionType(filter *api.BillingCreditTransactionType) *customerbalance.CreditTransactionType {
+func fromAPIBillingCreditTransactionType(filter *api.BillingCreditTransactionType) (*customerbalance.CreditTransactionType, error) {
 	if filter == nil {
-		return nil
+		return nil, nil
 	}
 
 	var txType customerbalance.CreditTransactionType
 	switch *filter {
 	case api.BillingCreditTransactionTypeFunded:
 		txType = customerbalance.CreditTransactionTypeFunded
 	case api.BillingCreditTransactionTypeConsumed:
 		txType = customerbalance.CreditTransactionTypeConsumed
 	case api.BillingCreditTransactionTypeAdjusted:
 		txType = customerbalance.CreditTransactionTypeAdjusted
 	default:
-		return nil
+		return nil, models.NewGenericValidationError(fmt.Errorf("invalid transaction type: %s", *filter))
 	}
 
-	return &txType
+	return &txType, nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/v3/handlers/customers/credits/convert.go` around lines 312 - 327, The
helper fromAPIBillingCreditTransactionType currently returns nil for unknown
api.BillingCreditTransactionType values which silently drops the filter; change
its signature to return (*customerbalance.CreditTransactionType, error), map
known cases to a pointer to the corresponding
customerbalance.CreditTransactionType, and on the default branch return a
descriptive error (e.g., fmt.Errorf("unknown BillingCreditTransactionType: %v",
*filter)). Update all callers to handle the error and propagate/return a 400
validation error to the client when the transaction-type is invalid.


return &txType
}

func toAPIBillingCreditTransactions(items []customerbalance.CreditTransaction) []api.BillingCreditTransaction {
out := make([]api.BillingCreditTransaction, 0, len(items))

for _, item := range items {
out = append(out, toAPIBillingCreditTransaction(item))
}

return out
}

func toAPIBillingCreditTransaction(tx customerbalance.CreditTransaction) api.BillingCreditTransaction {
apiTx := api.BillingCreditTransaction{
Id: tx.ID.ID,
CreatedAt: &tx.CreatedAt,
BookedAt: tx.BookedAt,
Type: toAPIBillingCreditTransactionType(tx.Type),
Currency: api.BillingCurrencyCode(tx.Currency),
Amount: tx.Amount.String(),
Name: tx.Name,
AvailableBalance: struct {
After api.Numeric `json:"after"`
Before api.Numeric `json:"before"`
}{
Before: tx.Balance.Before.String(),
After: tx.Balance.After.String(),
},
}

labels := creditTransactionLabels(tx.Annotations)
if len(labels) > 0 {
apiLabels := api.Labels(labels)
apiTx.Labels = &apiLabels
}

return apiTx
}

func toAPIBillingCreditTransactionType(txType customerbalance.CreditTransactionType) api.BillingCreditTransactionType {
switch txType {
case customerbalance.CreditTransactionTypeFunded:
return api.BillingCreditTransactionTypeFunded
case customerbalance.CreditTransactionTypeConsumed:
return api.BillingCreditTransactionTypeConsumed
default:
return api.BillingCreditTransactionTypeAdjusted
}
Comment on lines +369 to +377
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t coerce unknown transaction types to adjusted.

The default branch turns any bad or future customerbalance.CreditTransactionType into a valid-looking adjustment, so clients can get silently mislabeled data. Please handle CreditTransactionTypeAdjusted explicitly and leave unknown values unmapped or error out instead.

💡 Minimal safer mapping
 func toAPIBillingCreditTransactionType(txType customerbalance.CreditTransactionType) api.BillingCreditTransactionType {
 	switch txType {
 	case customerbalance.CreditTransactionTypeFunded:
 		return api.BillingCreditTransactionTypeFunded
 	case customerbalance.CreditTransactionTypeConsumed:
 		return api.BillingCreditTransactionTypeConsumed
+	case customerbalance.CreditTransactionTypeAdjusted:
+		return api.BillingCreditTransactionTypeAdjusted
 	default:
-		return api.BillingCreditTransactionTypeAdjusted
+		return api.BillingCreditTransactionTypeNaked
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/v3/handlers/customers/credits/convert.go` around lines 369 - 377, The
toAPIBillingCreditTransactionType function currently maps any unknown
customerbalance.CreditTransactionType to BillingCreditTransactionTypeAdjusted;
explicitly handle customerbalance.CreditTransactionTypeAdjusted by returning
api.BillingCreditTransactionTypeAdjusted and change the default branch to
surface unmapped values (e.g., return an error or a sentinel/unmapped value)
instead of silently coercing them so callers can detect unknown/future enum
values; update the function signature and all callers of
toAPIBillingCreditTransactionType accordingly to handle the error or unmapped
result and reference customerbalance.CreditTransactionType and
api.BillingCreditTransactionType in your changes.

}

func creditTransactionLabels(annotations models.Annotations) map[string]string {
labels := make(map[string]string)

setLabel := func(key, annotationKey string) {
value := stringAnnotation(annotations, annotationKey)
if value != "" {
labels[key] = value
}
}

setLabel("charge_id", ledger.AnnotationChargeID)
setLabel("subscription_id", ledger.AnnotationSubscriptionID)
setLabel("subscription_phase_id", ledger.AnnotationSubscriptionPhaseID)
setLabel("subscription_item_id", ledger.AnnotationSubscriptionItemID)
setLabel("feature_id", ledger.AnnotationFeatureID)

return labels
}

func stringAnnotation(annotations models.Annotations, key string) string {
raw, ok := annotations[key]
if !ok {
return ""
}

value, ok := raw.(string)
if !ok {
return ""
}

return value
}
12 changes: 12 additions & 0 deletions api/v3/handlers/customers/credits/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,36 @@ package customerscredits
import (
"context"

"github.com/alpacahq/alpacadecimal"

"github.com/openmeterio/openmeter/openmeter/billing/creditgrant"
"github.com/openmeterio/openmeter/openmeter/customer"
"github.com/openmeterio/openmeter/openmeter/ledger"
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
"github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
)

type customerBalanceFacade interface {
GetBalance(ctx context.Context, input customerbalance.GetBalanceInput) (alpacadecimal.Decimal, error)
GetBalances(ctx context.Context, input customerbalance.GetBalancesInput) ([]customerbalance.BalanceByCurrency, error)
ListCreditTransactions(ctx context.Context, input customerbalance.ListCreditTransactionsInput) (customerbalance.ListCreditTransactionsResult, error)
}

type Handler interface {
GetCustomerCreditBalance() GetCustomerCreditBalanceHandler
ListCreditGrants() ListCreditGrantsHandler
CreateCreditGrant() CreateCreditGrantHandler
GetCreditGrant() GetCreditGrantHandler
ListCreditTransactions() ListCreditTransactionsHandler
}

type handler struct {
resolveNamespace func(ctx context.Context) (string, error)
customerService customer.Service
balanceFacade customerBalanceFacade
creditGrantService creditgrant.Service
ledger ledger.Ledger
accountResolver ledger.AccountResolver
options []httptransport.HandlerOption
}

Expand All @@ -33,13 +41,17 @@ func New(
customerService customer.Service,
balanceFacade customerBalanceFacade,
creditGrantService creditgrant.Service,
ledger ledger.Ledger,
accountResolver ledger.AccountResolver,
options ...httptransport.HandlerOption,
) Handler {
return &handler{
resolveNamespace: resolveNamespace,
customerService: customerService,
balanceFacade: balanceFacade,
creditGrantService: creditGrantService,
ledger: ledger,
accountResolver: accountResolver,
options: options,
}
}
95 changes: 95 additions & 0 deletions api/v3/handlers/customers/credits/list_transactions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package customerscredits

import (
"context"
"fmt"
"net/http"

"github.com/samber/lo"

api "github.com/openmeterio/openmeter/api/v3"
"github.com/openmeterio/openmeter/api/v3/apierrors"
"github.com/openmeterio/openmeter/api/v3/response"
"github.com/openmeterio/openmeter/openmeter/customer"
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
"github.com/openmeterio/openmeter/pkg/currencyx"
"github.com/openmeterio/openmeter/pkg/framework/commonhttp"
"github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
"github.com/openmeterio/openmeter/pkg/pagination"
)

type (
ListCreditTransactionsRequest = customerbalance.ListCreditTransactionsInput
ListCreditTransactionsResponse = response.PagePaginationResponse[api.BillingCreditTransaction]
ListCreditTransactionsParams struct {
CustomerID api.ULID
Params api.ListCreditTransactionsParams
}
ListCreditTransactionsHandler httptransport.HandlerWithArgs[ListCreditTransactionsRequest, ListCreditTransactionsResponse, ListCreditTransactionsParams]
)

func (h *handler) ListCreditTransactions() ListCreditTransactionsHandler {
return httptransport.NewHandlerWithArgs(
func(ctx context.Context, r *http.Request, args ListCreditTransactionsParams) (ListCreditTransactionsRequest, error) {
ns, err := h.resolveNamespace(ctx)
if err != nil {
return ListCreditTransactionsRequest{}, err
}

page := pagination.NewPage(1, 20)
if args.Params.Page != nil {
page = pagination.NewPage(
lo.FromPtrOr(args.Params.Page.Number, 1),
lo.FromPtrOr(args.Params.Page.Size, 20),
)
}

if err := page.Validate(); err != nil {
return ListCreditTransactionsRequest{}, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
{
Field: "page",
Reason: err.Error(),
Source: apierrors.InvalidParamSourceQuery,
},
})
}

req := customerbalance.ListCreditTransactionsInput{
CustomerID: customer.CustomerID{
Namespace: ns,
ID: args.CustomerID,
},
Page: page,
}

if args.Params.Filter != nil {
req.Type = fromAPIBillingCreditTransactionType(args.Params.Filter.Type)

if args.Params.Filter.Currency != nil {
currency := currencyx.Code(*args.Params.Filter.Currency)
req.Currency = &currency
}
}

return req, nil
},
func(ctx context.Context, request ListCreditTransactionsRequest) (ListCreditTransactionsResponse, error) {
result, err := h.balanceFacade.ListCreditTransactions(ctx, request)
if err != nil {
return ListCreditTransactionsResponse{}, fmt.Errorf("list credit transactions: %w", err)
}

return response.NewPagePaginationResponse(toAPIBillingCreditTransactions(result.Items), response.PageMetaPage{
Size: request.Page.PageSize,
Number: request.Page.PageNumber,
Total: lo.ToPtr(result.TotalCount),
}), nil
},
commonhttp.JSONResponseEncoderWithStatus[ListCreditTransactionsResponse](http.StatusOK),
httptransport.AppendOptions(
h.options,
httptransport.WithOperationName("list-credit-transactions"),
httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
)...,
)
}
58 changes: 58 additions & 0 deletions api/v3/handlers/customers/credits/list_transactions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package customerscredits

import (
"testing"
"time"

"github.com/alpacahq/alpacadecimal"
"github.com/stretchr/testify/require"

api "github.com/openmeterio/openmeter/api/v3"
"github.com/openmeterio/openmeter/openmeter/ledger"
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
"github.com/openmeterio/openmeter/pkg/currencyx"
"github.com/openmeterio/openmeter/pkg/models"
)

func TestFromAPIBillingCreditTransactionType_Adjusted(t *testing.T) {
filter := api.BillingCreditTransactionTypeAdjusted

txType := fromAPIBillingCreditTransactionType(&filter)

require.NotNil(t, txType)
require.Equal(t, customerbalance.CreditTransactionTypeAdjusted, *txType)
}

func TestToAPIBillingCreditTransaction(t *testing.T) {
createdAt := time.Date(2026, 4, 10, 9, 0, 0, 0, time.UTC)
bookedAt := createdAt.Add(time.Second)

tx := toAPIBillingCreditTransaction(customerbalance.CreditTransaction{
ID: models.NamespacedID{
Namespace: "ns",
ID: "tx-1",
},
CreatedAt: createdAt,
BookedAt: bookedAt,
Type: customerbalance.CreditTransactionTypeConsumed,
Currency: currencyx.Code("USD"),
Amount: alpacadecimal.NewFromInt(-10),
Balance: customerbalance.CreditTransactionBalance{
Before: alpacadecimal.NewFromInt(52),
After: alpacadecimal.NewFromInt(42),
},
Name: "credit_transaction",
Annotations: models.Annotations{
ledger.AnnotationChargeID: "charge-1",
},
})

require.Equal(t, api.ULID("tx-1"), tx.Id)
require.Equal(t, api.BillingCreditTransactionTypeConsumed, tx.Type)
require.Equal(t, api.BillingCurrencyCode("USD"), tx.Currency)
require.Equal(t, api.Numeric("-10"), tx.Amount)
require.Equal(t, api.Numeric("52"), tx.AvailableBalance.Before)
require.Equal(t, api.Numeric("42"), tx.AvailableBalance.After)
require.NotNil(t, tx.Labels)
require.Equal(t, "charge-1", (*tx.Labels)["charge_id"])
}
18 changes: 13 additions & 5 deletions api/v3/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ func (s *Server) DeletePlanAddon(w http.ResponseWriter, r *http.Request, planId
var unimplemented = api.Unimplemented{}

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

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

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

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

func (s *Server) ListCreditTransactions(w http.ResponseWriter, r *http.Request, customerId api.ULID, params api.ListCreditTransactionsParams) {
unimplemented.ListCreditTransactions(w, r, customerId, params)
if !s.Credits.Enabled || s.customersCreditsHandler == nil || s.Ledger == nil {
unimplemented.ListCreditTransactions(w, r, customerId, params)
return
}

s.customersCreditsHandler.ListCreditTransactions().With(customerscreditshandler.ListCreditTransactionsParams{
CustomerID: customerId,
Params: params,
}).ServeHTTP(w, r)
}

// Charges
Expand Down
Loading
Loading