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
123 changes: 123 additions & 0 deletions api/v3/handlers/customers/credits/get_balance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package customerscredits

import (
"context"
"errors"
"net/http"

api "github.com/openmeterio/openmeter/api/v3"
"github.com/openmeterio/openmeter/api/v3/apierrors"
"github.com/openmeterio/openmeter/openmeter/customer"
"github.com/openmeterio/openmeter/openmeter/ledger"
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
"github.com/openmeterio/openmeter/pkg/clock"
"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/models"
)

var errUnsupportedFeatureFilter = errors.New("feature filter is not supported for this balance endpoint")

type (
GetCustomerCreditBalanceRequest struct {
CustomerID customer.CustomerID
Currencies customerbalance.CurrencyFilter
}
GetCustomerCreditBalanceResponse = api.BillingCreditBalances
GetCustomerCreditBalanceParams struct {
CustomerID api.ULID
Params api.GetCustomerCreditBalanceParams
}
GetCustomerCreditBalanceHandler httptransport.HandlerWithArgs[GetCustomerCreditBalanceRequest, GetCustomerCreditBalanceResponse, GetCustomerCreditBalanceParams]
)

func (h *handler) GetCustomerCreditBalance() GetCustomerCreditBalanceHandler {
return httptransport.NewHandlerWithArgs(
func(ctx context.Context, r *http.Request, args GetCustomerCreditBalanceParams) (GetCustomerCreditBalanceRequest, error) {
namespace, err := h.resolveNamespace(ctx)
if err != nil {
return GetCustomerCreditBalanceRequest{}, err
}

if args.Params.Filter != nil && args.Params.Filter.Feature != nil {
return GetCustomerCreditBalanceRequest{}, apierrors.NewBadRequestError(
ctx,
models.NewGenericValidationError(errUnsupportedFeatureFilter),
apierrors.InvalidParameters{
{
Field: "filter.feature",
Reason: errUnsupportedFeatureFilter.Error(),
Source: apierrors.InvalidParamSourceQuery,
},
},
)
}

request := GetCustomerCreditBalanceRequest{
CustomerID: customer.CustomerID{
Namespace: namespace,
ID: args.CustomerID,
},
}

if args.Params.Filter != nil && args.Params.Filter.Currency != nil {
currency := currencyx.Code(*args.Params.Filter.Currency)
request.Currencies = customerbalance.CurrencyFilter{
Codes: []currencyx.Code{currency},
}
}

return request, nil
},
func(ctx context.Context, request GetCustomerCreditBalanceRequest) (GetCustomerCreditBalanceResponse, error) {
_, err := h.customerService.GetCustomer(ctx, customer.GetCustomerInput{
CustomerID: &request.CustomerID,
})
if models.IsGenericNotFoundError(err) {
return GetCustomerCreditBalanceResponse{}, apierrors.NewNotFoundError(ctx, err, "customer")
}
if err != nil {
return GetCustomerCreditBalanceResponse{}, err
}

balancesByCurrency, err := h.balanceFacade.GetBalances(ctx, customerbalance.GetBalancesInput{
CustomerID: request.CustomerID,
Currencies: request.Currencies,
})
if err != nil {
return GetCustomerCreditBalanceResponse{}, err
}

balances := make([]api.CreditBalance, 0, len(balancesByCurrency))
for _, item := range balancesByCurrency {
if len(request.Currencies.Codes) == 0 && item.Balance.Settled().IsZero() && item.Balance.Pending().IsZero() {
continue
}

balances = append(balances, mapBalance(item.Currency, item.Balance))
}

return GetCustomerCreditBalanceResponse{
RetrievedAt: clock.Now(),
Balances: balances,
}, nil
},
commonhttp.JSONResponseEncoderWithStatus[GetCustomerCreditBalanceResponse](http.StatusOK),
httptransport.AppendOptions(
h.options,
httptransport.WithOperationName("get-customer-credit-balance"),
httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
)...,
)
}

func mapBalance(currency currencyx.Code, balance ledger.Balance) api.CreditBalance {
// Temporary mapping while the v3 credit-balance schema still predates the
// customerbalance service's settled/live-pending semantics.
return api.CreditBalance{
Currency: api.BillingCurrencyCode(currency),
Available: balance.Settled().String(),
Pending: balance.Pending().String(),
}
Comment on lines +115 to +122
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

Pending means something different in the public schema.

I get the temporary bridge comment, but balance.Pending() here is the live delta after outstanding charge impacts, while api.CreditBalance.Pending is documented as not-yet-consumable granted credits. That means this endpoint can return pending values with semantics clients won't expect, potentially even negative ones, so I don't think this field is safe to expose until the response shape catches up.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/v3/handlers/customers/credits/get_balance.go` around lines 115 - 122,
mapBalance is returning ledger.Balance.Pending() into api.CreditBalance.Pending
but those semantics differ (ledger.Pending is live delta, while the public
Pending must be not-yet-consumable granted credits), so stop exposing the
misleading value: update the mapBalance(currency currencyx.Code, balance
ledger.Balance) function to not propagate balance.Pending() into
api.CreditBalance.Pending — instead set Pending to a safe zero/neutral value
(e.g., "0" or empty) or omit/populate the field with a clearly safe placeholder
until the customer balance service can provide the correct granted-pending
semantics; keep Currency and Available mapping the same and add a TODO comment
referencing api.CreditBalance and ledger.Balance.Pending for future
reconciliation.

}
38 changes: 38 additions & 0 deletions api/v3/handlers/customers/credits/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package customerscredits

import (
"context"

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

type customerBalanceFacade interface {
GetBalances(ctx context.Context, input customerbalance.GetBalancesInput) ([]customerbalance.BalanceByCurrency, error)
}

type Handler interface {
GetCustomerCreditBalance() GetCustomerCreditBalanceHandler
}

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

func New(
resolveNamespace func(ctx context.Context) (string, error),
customerService customer.Service,
balanceFacade customerBalanceFacade,
options ...httptransport.HandlerOption,
) Handler {
return &handler{
resolveNamespace: resolveNamespace,
customerService: customerService,
balanceFacade: balanceFacade,
options: options,
}
}
11 changes: 10 additions & 1 deletion api/v3/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

api "github.com/openmeterio/openmeter/api/v3"
currencieshandler "github.com/openmeterio/openmeter/api/v3/handlers/currencies"
customerscreditshandler "github.com/openmeterio/openmeter/api/v3/handlers/customers/credits"
)

// Meters
Expand Down Expand Up @@ -322,7 +323,15 @@ 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) {
unimplemented.GetCustomerCreditBalance(w, r, customerId, params)
if s.customersCreditsHandler == nil {
unimplemented.GetCustomerCreditBalance(w, r, customerId, params)
return
}

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

func (s *Server) ListCreditGrants(w http.ResponseWriter, r *http.Request, customerId api.ULID, params api.ListCreditGrantsParams) {
Expand Down
11 changes: 11 additions & 0 deletions api/v3/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
currencieshandler "github.com/openmeterio/openmeter/api/v3/handlers/currencies"
customershandler "github.com/openmeterio/openmeter/api/v3/handlers/customers"
customersbillinghandler "github.com/openmeterio/openmeter/api/v3/handlers/customers/billing"
customerscreditshandler "github.com/openmeterio/openmeter/api/v3/handlers/customers/credits"
customersentitlementhandler "github.com/openmeterio/openmeter/api/v3/handlers/customers/entitlementaccess"
eventshandler "github.com/openmeterio/openmeter/api/v3/handlers/events"
featurecosthandler "github.com/openmeterio/openmeter/api/v3/handlers/featurecost"
Expand All @@ -29,6 +30,7 @@ import (
taxcodeshandler "github.com/openmeterio/openmeter/api/v3/handlers/taxcodes"
"github.com/openmeterio/openmeter/api/v3/oasmiddleware"
"github.com/openmeterio/openmeter/api/v3/render"
"github.com/openmeterio/openmeter/app/config"
"github.com/openmeterio/openmeter/openmeter/app"
appstripe "github.com/openmeterio/openmeter/openmeter/app/stripe"
"github.com/openmeterio/openmeter/openmeter/billing"
Expand All @@ -37,6 +39,7 @@ import (
"github.com/openmeterio/openmeter/openmeter/customer"
"github.com/openmeterio/openmeter/openmeter/entitlement"
"github.com/openmeterio/openmeter/openmeter/ingest"
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
"github.com/openmeterio/openmeter/openmeter/llmcost"
"github.com/openmeterio/openmeter/openmeter/meter"
"github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver"
Expand All @@ -57,6 +60,7 @@ type Config struct {
ErrorHandler errorsx.Handler
Middlewares []server.MiddlewareFunc
PostAuthMiddlewares []server.MiddlewareFunc
Credits config.CreditsConfiguration

// services
AppService app.Service
Expand All @@ -66,6 +70,7 @@ type Config struct {
StreamingConnector streaming.Connector
IngestService ingest.Service
CustomerService customer.Service
CustomerBalanceFacade *customerbalance.Facade
EntitlementService entitlement.Service
PlanService plan.Service
PlanSubscriptionService plansubscription.PlanSubscriptionService
Expand Down Expand Up @@ -162,6 +167,7 @@ type Server struct {
llmcostHandler llmcosthandler.Handler
customersHandler customershandler.Handler
customersBillingHandler customersbillinghandler.Handler
customersCreditsHandler customerscreditshandler.Handler
customersEntitlementHandler customersentitlementhandler.Handler
metersHandler metershandler.Handler
subscriptionsHandler subscriptionshandler.Handler
Expand Down Expand Up @@ -207,6 +213,10 @@ func NewServer(config *Config) (*Server, error) {
eventsHandler := eventshandler.New(resolveNamespace, config.IngestService, httptransport.WithErrorHandler(config.ErrorHandler))
customersHandler := customershandler.New(resolveNamespace, config.CustomerService, httptransport.WithErrorHandler(config.ErrorHandler))
customersBillingHandler := customersbillinghandler.New(resolveNamespace, config.BillingService, config.CustomerService, config.StripeService, httptransport.WithErrorHandler(config.ErrorHandler))
var customersCreditsHandler customerscreditshandler.Handler
if config.CustomerBalanceFacade != nil && config.Credits.Enabled {
customersCreditsHandler = customerscreditshandler.New(resolveNamespace, config.CustomerService, config.CustomerBalanceFacade, httptransport.WithErrorHandler(config.ErrorHandler))
}
customersEntitlementHandler := customersentitlementhandler.New(resolveNamespace, config.CustomerService, config.EntitlementService, httptransport.WithErrorHandler(config.ErrorHandler))
metersHandler := metershandler.New(resolveNamespace, config.MeterService, config.StreamingConnector, config.CustomerService, httptransport.WithErrorHandler(config.ErrorHandler))
subscriptionsHandler := subscriptionshandler.New(resolveNamespace, config.CustomerService, config.PlanService, config.PlanSubscriptionService, config.SubscriptionService, httptransport.WithErrorHandler(config.ErrorHandler))
Expand Down Expand Up @@ -234,6 +244,7 @@ func NewServer(config *Config) (*Server, error) {
llmcostHandler: llmcostH,
customersHandler: customersHandler,
customersBillingHandler: customersBillingHandler,
customersCreditsHandler: customersCreditsHandler,
customersEntitlementHandler: customersEntitlementHandler,
metersHandler: metersHandler,
subscriptionsHandler: subscriptionsHandler,
Expand Down
Loading
Loading