Skip to content

feat(api): improve API types#4292

Closed
tothandras wants to merge 1 commit into
mainfrom
feat/v3-api-improvements
Closed

feat(api): improve API types#4292
tothandras wants to merge 1 commit into
mainfrom
feat/v3-api-improvements

Conversation

@tothandras
Copy link
Copy Markdown
Contributor

@tothandras tothandras commented May 5, 2026

Summary by CodeRabbit

  • Bug Fixes

    • Timestamps are now required on API resources, ensuring consistent inclusion across all responses instead of optional null values.
    • Enhanced credit filtering to support multiple values for statuses, types, and currencies for more granular control.
    • Removed unsupported feature filtering from credit balance queries.
  • Documentation

    • Clarified expand parameter documentation for customer charges, specifying supported values including real_time_usage.

@tothandras tothandras requested a review from a team as a code owner May 5, 2026 15:14
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 5, 2026

📝 Walkthrough

Walkthrough

The PR makes created/updated timestamps required across API spec models and aligns Go handler timestamp field representations from pointers to values. Additionally, credit transaction and grant filtering shifts from single optional values to slice-based collections to support multiple-value filtering.

Changes

Timestamp Representation & Filter Refactoring

Layer / File(s) Summary
API Spec Model Updates
api/spec/packages/aip/src/shared/resource.tsp, api/spec/packages/aip/src/currencies/..., api/spec/packages/aip/src/llmcost/prices.tsp, api/spec/packages/aip/src/customers/credits/operations.tsp
Core models make created_at/updated_at required; credit filter operations use Common.StringFieldFilterExact instead of typed enums for flexible querying of status, type, and currency fields.
Go Handler Conversion Layer
api/v3/handlers/*/convert*.go (addons, apps, billingprofiles, currencies, charges, customers, features, llmcost, meters, plans, planaddons, subscriptions, taxcodes)
Timestamp field assignments switch from pointer-wrapped values (lo.ToPtr(), &field, *time.Time) to direct value assignments (time.Time), and helper functions are updated accordingly (e.g., timeTimeToPTimeTimetimeTimeToTimeTime).
Credit Handler Filter Integration
api/v3/handlers/customers/credits/list_grants.go, api/v3/handlers/customers/credits/list_transactions.go, api/v3/handlers/customers/credits/convert.go
New filter conversion helpers (filterStringExactToStatuses, filterStringExactToTransactionTypes, filterStringExactToCurrencies) map request filters to slice collections; error handling updated to report invalid parameters.
Service & Ledger Layer Updates
openmeter/billing/creditgrant/service.go, openmeter/billing/creditgrant/service/service.go, openmeter/ledger/customerbalance/{transactions.go, loaders.go, ledger_loader.go, funded_loader.go}
ListInput and filtering structures shift from single optional values (Status *T, Currency *T, Type *T) to slice-based collections (Statuses []T, Currencies []T, Types []T); validation and loader wiring updated to handle multiple filter values.
Tests & Cleanup
api/v3/handlers/plans/convert_test.go, openmeter/ledger/customerbalance/transactions_test.go, api/v3/handlers/customers/credits/get_balance.go
Test assertions align with value-based timestamp fields; feature-filter validation removed from balance handler since filtering now supports flexible multi-value operations.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Handler as List Handler
    participant Converter as Filter Converter
    participant Service as Credit Service
    participant Ledger as Ledger/Loaders

    Client->>Handler: GET /credits/grants?filter[status]=active&filter[currency]=USD
    activate Handler
    
    Handler->>Converter: filterStringExactToStatuses(filter[status])
    activate Converter
    Converter-->>Handler: []meta.ChargeStatus{Active}
    deactivate Converter
    
    Handler->>Converter: filterStringExactToCurrencies(filter[currency])
    activate Converter
    Converter-->>Handler: []currencyx.Code{USD}
    deactivate Converter
    
    Handler->>Service: List(ctx, ListInput{Statuses: [Active], Currencies: [USD]})
    activate Service
    
    Service->>Ledger: creditTransactionLoaders([...types], ListInput{Statuses: [...], Currencies: [...]})
    activate Ledger
    Ledger-->>Service: []Loader (filtered by type & currency)
    deactivate Ledger
    
    Service-->>Handler: []CreditGrant (merged results)
    deactivate Service
    
    Handler-->>Client: 200 OK {data: [...grants with required created_at]}
    deactivate Handler
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

The changes follow consistent patterns across many files (repetitive timestamp conversions reduce complexity), but the credit filtering refactoring is more intricate—it touches handlers, services, and loaders with slice-based semantics that require understanding the interaction across layers.

Possibly related PRs

  • openmeterio/openmeter#4209: Both PRs coordinate changes to customer credit transaction listing filters, pagination, and handler/loader mappings.
  • openmeterio/openmeter#4052: Both modify the customers/credits balance handler and related request validation logic.
  • openmeterio/openmeter#4135: Both refactor API↔domain conversion in api/v3/handlers/* files, with the retrieved PR updating helper implementations and the main PR changing timestamp field representations.

Suggested labels

release-note/misc, area/billing, area/subscriptions

Suggested reviewers

  • chrisgacsal
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'feat(api): improve API types' is vague and generic, using non-descriptive language that doesn't convey what specific API improvements were made. Consider a more specific title that highlights the main change, such as 'feat(api): make timestamps required in API models' or 'feat(api): refactor timestamp handling across API handlers'.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/v3-api-improvements

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@tothandras tothandras added the release-note/feature Release note: Exciting New Features label May 5, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (7)
api/v3/handlers/llmcost/convert.go (1)

68-88: 💤 Low value

Tiny nit: CreatedAt/UpdatedAt could move into the struct literal

They're the only fields not initialized inline — looks like a leftover from removing the old if !p.CreatedAt.IsZero() guard. No bug here, just a quick cleanup opportunity.

✨ Proposed cleanup
 	out := api.LLMCostPrice{
 		Id: p.ID,
 		Provider: api.LLMCostProvider{
 			Id:   providerID,
 			Name: formatProviderName(providerID),
 		},
 		Model: api.LLMCostModel{
 			Id:   p.ModelID,
 			Name: p.ModelName,
 		},
 		Currency:      p.Currency,
 		Source:        source,
 		EffectiveFrom: p.EffectiveFrom,
 		EffectiveTo:   p.EffectiveTo,
 		Pricing:       domainPricingToAPI(p.Pricing),
+		CreatedAt:     p.CreatedAt,
+		UpdatedAt:     p.UpdatedAt,
 	}

-	out.CreatedAt = p.CreatedAt
-	out.UpdatedAt = p.UpdatedAt
-
 	return out
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/v3/handlers/llmcost/convert.go` around lines 68 - 88, Move the CreatedAt
and UpdatedAt assignments into the api.LLMCostPrice struct literal so all fields
are initialized inline; update the struct literal where out is constructed
(referencing api.LLMCostPrice, api.LLMCostProvider, api.LLMCostModel,
domainPricingToAPI and variable p) to include CreatedAt: p.CreatedAt and
UpdatedAt: p.UpdatedAt, then remove the separate out.CreatedAt/out.UpdatedAt
lines before the return.
api/spec/packages/aip/src/customers/credits/operations.tsp (2)

164-170: ⚡ Quick win

Same enum-erosion concern here, plus the TODO on date filters.

Two small things:

  1. Like ListCreditGrantsParamsFilter, the type and currency fields no longer surface allowed values in the spec — consider adding doc comments listing the valid options (funded, consumed for type).
  2. The // TODO: add date filters for created_at and booked_at is great context — happy to open a tracking issue or sketch out the filter shape (likely Common.DateFieldFilter or similar) if that'd help land it in a follow-up.

As per coding guidelines: "The declared API should be accurate, in parity with the actual implementation, and easy to understand for the user."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/spec/packages/aip/src/customers/credits/operations.tsp` around lines 164
- 170, The spec for Credits filter is losing allowed enum info and is missing
date filters: update the comments for the type and currency fields in the
Credits filter (same pattern used by ListCreditGrantsParamsFilter) to enumerate
valid values (e.g., type: funded, consumed; currency: list allowed currency
codes) so consumers see allowed options, and add filters for created_at and
booked_at using the existing Common.DateFieldFilter (or the project’s
DateFieldFilter shape) to match implementation; reference the type and currency
fields and add created_at/booked_at entries using Common.DateFieldFilter to keep
the API accurate and discoverable.

23-26: ⚡ Quick win

Heads up: enum context is lost in the OpenAPI spec for status and currency.

Switching status from CreditGrantStatus and currency from Shared.CurrencyCode to a generic Common.StringFieldFilterExact enables multi-value filtering (nice!), but the SDK/OpenAPI no longer surfaces the allowed enum values for these filter fields. API consumers will only see "string" inputs, and discover invalid values at runtime via the convert.go validation in filterStringExactToStatuses / filterStringExactToCurrencies.

Consider adding a doc comment on each filter field that enumerates the accepted values (and same for type/currency in ListCreditTransactionsParamsFilter at Lines 164-167), so the spec remains self-describing:

📝 Suggested doc additions
 model ListCreditGrantsParamsFilter {
+  /**
+   * Filter by credit grant status. Accepted values: `pending`, `active`, `voided`, `expired`.
+   */
   `#suppress` "@openmeter/api-spec-aip/doc-decorator" "shared model"
   status?: Common.StringFieldFilterExact;
+
+  /**
+   * Filter by currency code (ISO-4217 or custom currency code).
+   */
   `#suppress` "@openmeter/api-spec-aip/doc-decorator" "shared model"
   currency?: Common.StringFieldFilterExact;
 }

As per coding guidelines: "The declared API should be accurate, in parity with the actual implementation, and easy to understand for the user."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/spec/packages/aip/src/customers/credits/operations.tsp` around lines 23 -
26, The status and currency filter fields use Common.StringFieldFilterExact
which loses enum context in the OpenAPI output; add doc comments above the
status and currency fields (and similarly above type/currency in
ListCreditTransactionsParamsFilter) listing the accepted values from
CreditGrantStatus and Shared.CurrencyCode respectively so the generated spec
surfaces allowed enum values; reference the conversion helpers
filterStringExactToStatuses and filterStringExactToCurrencies in the comment if
helpful for consumers.
openmeter/ledger/customerbalance/transactions_test.go (1)

22-28: ⚡ Quick win

Test still works — but coverage could grow with the new shape.

The test now exercises the slice-input path for the invalid case 👍. Since creditTransactionLoaders has new branches (empty slice → all loaders; multiple values → ordered loaders; potential duplicates), it'd be lovely to add coverage:

  • Empty []CreditTransactionType returns loaders for all known types in creditTransactionLoaderOrder.
  • A slice with [Funded, Consumed] returns 2 loaders in input order.
  • Duplicate types behavior (if you decide to dedupe per the loaders.go suggestion, this locks it in).

As per coding guidelines for **/*_test.go: "Make sure the tests are comprehensive and cover the changes."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openmeter/ledger/customerbalance/transactions_test.go` around lines 22 - 28,
Add tests to exercise the new branches of creditTransactionLoaders: (1) call
creditTransactionLoaders with an empty []CreditTransactionType and assert it
returns loaders for all known types in creditTransactionLoaderOrder (check
length and that order matches creditTransactionLoaderOrder), (2) call it with
[]CreditTransactionType{Funded, Consumed} and assert it returns exactly two
loaders in that input order, and (3) add a test for duplicate types (e.g.
[]CreditTransactionType{Funded, Funded}) asserting the expected behavior you
choose (either deduped single loader or duplicated loaders) and adjust
assertions to match that decision; use the service{} setup already in
TestCreditTransactionLoaders_InvalidType and the existing require helpers for
assertions.
api/v3/handlers/customers/credits/convert.go (2)

495-495: 💤 Low value

Unused context.Context parameter — keep it intentional or drop it.

All three new helpers take _ context.Context and never use it. If you anticipate needing a context soon (e.g., to validate currency codes against tenant-defined custom currencies), great — leave a brief comment explaining why it stays. Otherwise, dropping the parameter is cleaner per the repo's context-propagation guideline.

As per coding guidelines for openmeter/**/*.go: "Propagate caller's context through the full call path or remove unused context parameters." (the same spirit applies in api/**/*.go).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/v3/handlers/customers/credits/convert.go` at line 495, The helper
filterStringExactToTransactionTypes currently declares an unused context.Context
parameter; either remove the unused parameter from this function (and the two
sibling helpers that also take _ context.Context) to follow the repo's
context-propagation guideline, or keep the parameter but add a one-line comment
above each function explaining why the context is intentionally accepted (e.g.,
"context retained for future tenant currency validation") so reviewers know it's
deliberate; update the function signatures for
filterStringExactToTransactionTypes and the two related helpers accordingly.

493-589: ⚡ Quick win

Plenty of overlap between the three filter helpers — a small generic could shrink this.

The three converters are nearly identical: pull Eq/In into a []string, reject Ne, then map each value via a per-type function. A tiny generic helper would remove ~60 lines of duplication while keeping the same per-field error messages.

♻️ Sketch of a shared helper
func mapFilterStringExact[T any](
	f *filters.FilterStringExact,
	field string,
	mapValue func(string) (T, error),
) ([]T, error) {
	fs, err := filters.FromAPIFilterStringExact(f)
	if err != nil {
		return nil, err
	}
	if fs == nil {
		return nil, nil
	}
	if fs.Ne != nil {
		return nil, models.NewGenericValidationError(
			fmt.Errorf("only eq and oeq operators are supported for %s", field),
		)
	}

	var values []string
	if fs.Eq != nil {
		values = append(values, *fs.Eq)
	}
	if fs.In != nil {
		values = append(values, *fs.In...)
	}

	result := make([]T, 0, len(values))
	for _, v := range values {
		mapped, err := mapValue(v)
		if err != nil {
			return nil, err
		}
		result = append(result, mapped)
	}
	return result, nil
}

Then each helper becomes a thin wrapper that just supplies the per-value mapping function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/v3/handlers/customers/credits/convert.go` around lines 493 - 589, Extract
the common logic in filterStringExactToTransactionTypes,
filterStringExactToStatuses, and filterStringExactToCurrencies into a generic
helper (e.g. mapFilterStringExact[T]) that calls
filters.FromAPIFilterStringExact, returns nil if fs==nil, rejects fs.Ne with the
same per-field error text ("only eq and oeq operators are supported for
<field>") and builds the []string from fs.Eq/fs.In, then maps each string via a
provided mapValue func(string) (T, error); replace the three functions with thin
wrappers that call mapFilterStringExact supplying the field name and a mapping
function (for transaction types map to customerbalance.CreditTransactionType,
for statuses call the provided convert func, for currencies map to
currencyx.Code) so behavior and error messages remain identical.
openmeter/ledger/customerbalance/transactions.go (1)

125-136: ⚡ Quick win

Consider de-duplicating filter slices before loader/query fan-out.

A quick lo.Uniq on input.Types (and optionally input.Currencies) would avoid redundant loader calls/query work when clients pass duplicate filter values.

💡 Suggested tweak
-	loaders, err := s.creditTransactionLoaders(input.Types)
+	uniqueTypes := lo.Uniq(input.Types)
+	loaders, err := s.creditTransactionLoaders(uniqueTypes)
 	if err != nil {
 		return ListCreditTransactionsResult{}, err
 	}

 	loaderInput := creditTransactionLoaderInput{
 		Limit:      input.Limit,
 		After:      input.After,
 		Before:     input.Before,
 		CustomerID: input.CustomerID,
 		AccountID:  accountID,
-		Currencies: input.Currencies,
+		Currencies: lo.Uniq(input.Currencies),
 	}

As per coding guidelines: "Performance should be a priority in critical code paths... database operations... should be vetted for potential performance bottlenecks."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openmeter/ledger/customerbalance/transactions.go` around lines 125 - 136,
De-duplicate filter slices before fan-out: call a uniqueness operation on
input.Types (and optionally input.Currencies) before passing them to
creditTransactionLoaders and before populating creditTransactionLoaderInput so
you don't create duplicate loaders or redundant queries; update the call site
that constructs loaderInput (creditTransactionLoaderInput) and the call to
s.creditTransactionLoaders to use the deduplicated slices (e.g., uniqueTypes and
uniqueCurrencies) so loader creation and downstream queries only run once per
distinct filter value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@api/v3/handlers/customers/credits/convert.go`:
- Around line 493-589: Replace plain errors and fmt.Errorf in
filterStringExactToTransactionTypes, filterStringExactToStatuses, and
filterStringExactToCurrencies with validation-typed errors so clients get 4xx
responses: for the unsupported operator checks in each function (currently
errors.New("only eq and oeq operators are supported for ...")) return a
models.NewGenericValidationError(...) (or an existing specific factory if one
exists) with the same message; in filterStringExactToTransactionTypes convert
the unsupported-type fmt.Errorf into a validation error (either call
models.NewGenericValidationError(fmt.Sprintf(...)) or add a specific factory
like newCreditTransactionTypeInvalid(value)); and ensure any errors returned
from the provided convert(...) in filterStringExactToStatuses are preserved or
mapped to a validation error type rather than a plain error. Use the function
names filterStringExactToTransactionTypes, filterStringExactToStatuses, and
filterStringExactToCurrencies to locate and update these error returns.

In `@openmeter/ledger/customerbalance/funded_loader.go`:
- Around line 21-33: The loader currently drops multi-value currency filters by
setting currency = nil when input.Currencies has length != 1; update
fundedCreditTransactionLoader.Load (and the analogous ledger loader method in
ledger_loader.go) to explicitly handle len(input.Currencies) > 1 by returning a
validation error instead of silently ignoring the filter: check
creditTransactionLoaderInput.Currencies (and ledger loader's input.Currencies)
and if len > 1 return a clear error (e.g., "multiple currencies not supported")
to the caller; keep the existing behavior of using &input.Currencies[0] when len
== 1 and nil when len == 0.

In `@openmeter/ledger/customerbalance/loaders.go`:
- Around line 53-63: The code currently returns (nil, txType.Validate()) when a
factory is missing, but txType.Validate() can be nil and mask the error; change
the missing-factory branch in the loader construction to return an explicit
non-nil error (e.g. fmt.Errorf("no creditTransactionLoader factory registered
for %v", txType)) instead of relying on txType.Validate(), and also deduplicate
input txTypes before creating loaders (use a seen map keyed by the
CreditTransactionType and only append one creditTransactionLoader per unique
type when building loaders) so the same loader is not created/run multiple
times.

---

Nitpick comments:
In `@api/spec/packages/aip/src/customers/credits/operations.tsp`:
- Around line 164-170: The spec for Credits filter is losing allowed enum info
and is missing date filters: update the comments for the type and currency
fields in the Credits filter (same pattern used by ListCreditGrantsParamsFilter)
to enumerate valid values (e.g., type: funded, consumed; currency: list allowed
currency codes) so consumers see allowed options, and add filters for created_at
and booked_at using the existing Common.DateFieldFilter (or the project’s
DateFieldFilter shape) to match implementation; reference the type and currency
fields and add created_at/booked_at entries using Common.DateFieldFilter to keep
the API accurate and discoverable.
- Around line 23-26: The status and currency filter fields use
Common.StringFieldFilterExact which loses enum context in the OpenAPI output;
add doc comments above the status and currency fields (and similarly above
type/currency in ListCreditTransactionsParamsFilter) listing the accepted values
from CreditGrantStatus and Shared.CurrencyCode respectively so the generated
spec surfaces allowed enum values; reference the conversion helpers
filterStringExactToStatuses and filterStringExactToCurrencies in the comment if
helpful for consumers.

In `@api/v3/handlers/customers/credits/convert.go`:
- Line 495: The helper filterStringExactToTransactionTypes currently declares an
unused context.Context parameter; either remove the unused parameter from this
function (and the two sibling helpers that also take _ context.Context) to
follow the repo's context-propagation guideline, or keep the parameter but add a
one-line comment above each function explaining why the context is intentionally
accepted (e.g., "context retained for future tenant currency validation") so
reviewers know it's deliberate; update the function signatures for
filterStringExactToTransactionTypes and the two related helpers accordingly.
- Around line 493-589: Extract the common logic in
filterStringExactToTransactionTypes, filterStringExactToStatuses, and
filterStringExactToCurrencies into a generic helper (e.g.
mapFilterStringExact[T]) that calls filters.FromAPIFilterStringExact, returns
nil if fs==nil, rejects fs.Ne with the same per-field error text ("only eq and
oeq operators are supported for <field>") and builds the []string from
fs.Eq/fs.In, then maps each string via a provided mapValue func(string) (T,
error); replace the three functions with thin wrappers that call
mapFilterStringExact supplying the field name and a mapping function (for
transaction types map to customerbalance.CreditTransactionType, for statuses
call the provided convert func, for currencies map to currencyx.Code) so
behavior and error messages remain identical.

In `@api/v3/handlers/llmcost/convert.go`:
- Around line 68-88: Move the CreatedAt and UpdatedAt assignments into the
api.LLMCostPrice struct literal so all fields are initialized inline; update the
struct literal where out is constructed (referencing api.LLMCostPrice,
api.LLMCostProvider, api.LLMCostModel, domainPricingToAPI and variable p) to
include CreatedAt: p.CreatedAt and UpdatedAt: p.UpdatedAt, then remove the
separate out.CreatedAt/out.UpdatedAt lines before the return.

In `@openmeter/ledger/customerbalance/transactions_test.go`:
- Around line 22-28: Add tests to exercise the new branches of
creditTransactionLoaders: (1) call creditTransactionLoaders with an empty
[]CreditTransactionType and assert it returns loaders for all known types in
creditTransactionLoaderOrder (check length and that order matches
creditTransactionLoaderOrder), (2) call it with []CreditTransactionType{Funded,
Consumed} and assert it returns exactly two loaders in that input order, and (3)
add a test for duplicate types (e.g. []CreditTransactionType{Funded, Funded})
asserting the expected behavior you choose (either deduped single loader or
duplicated loaders) and adjust assertions to match that decision; use the
service{} setup already in TestCreditTransactionLoaders_InvalidType and the
existing require helpers for assertions.

In `@openmeter/ledger/customerbalance/transactions.go`:
- Around line 125-136: De-duplicate filter slices before fan-out: call a
uniqueness operation on input.Types (and optionally input.Currencies) before
passing them to creditTransactionLoaders and before populating
creditTransactionLoaderInput so you don't create duplicate loaders or redundant
queries; update the call site that constructs loaderInput
(creditTransactionLoaderInput) and the call to s.creditTransactionLoaders to use
the deduplicated slices (e.g., uniqueTypes and uniqueCurrencies) so loader
creation and downstream queries only run once per distinct filter value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c68ba0e1-2b22-47d2-9269-2c02c91ed249

📥 Commits

Reviewing files that changed from the base of the PR and between 5f7ad28 and 255e2e5.

⛔ Files ignored due to path filters (1)
  • api/v3/openapi.yaml is excluded by !**/openapi.yaml
📒 Files selected for processing (32)
  • api/spec/packages/aip/src/currencies/cost-bases/cost-basis.tsp
  • api/spec/packages/aip/src/currencies/currency.tsp
  • api/spec/packages/aip/src/customers/charges/operations.tsp
  • api/spec/packages/aip/src/customers/credits/operations.tsp
  • api/spec/packages/aip/src/llmcost/prices.tsp
  • api/spec/packages/aip/src/shared/resource.tsp
  • api/v3/api.gen.go
  • api/v3/handlers/addons/convert.go
  • api/v3/handlers/apps/convert.go
  • api/v3/handlers/billingprofiles/convert.gen.go
  • api/v3/handlers/currencies/convert.go
  • api/v3/handlers/customers/charges/convert.go
  • api/v3/handlers/customers/convert.gen.go
  • api/v3/handlers/customers/credits/convert.go
  • api/v3/handlers/customers/credits/get_balance.go
  • api/v3/handlers/customers/credits/list_grants.go
  • api/v3/handlers/customers/credits/list_transactions.go
  • api/v3/handlers/features/convert.go
  • api/v3/handlers/llmcost/convert.go
  • api/v3/handlers/meters/convert.gen.go
  • api/v3/handlers/plans/convert.go
  • api/v3/handlers/plans/convert_test.go
  • api/v3/handlers/plans/planaddons/convert.go
  • api/v3/handlers/subscriptions/convert.go
  • api/v3/handlers/taxcodes/convert.gen.go
  • openmeter/billing/creditgrant/service.go
  • openmeter/billing/creditgrant/service/service.go
  • openmeter/ledger/customerbalance/funded_loader.go
  • openmeter/ledger/customerbalance/ledger_loader.go
  • openmeter/ledger/customerbalance/loaders.go
  • openmeter/ledger/customerbalance/transactions.go
  • openmeter/ledger/customerbalance/transactions_test.go
💤 Files with no reviewable changes (1)
  • api/v3/handlers/customers/credits/get_balance.go

Comment on lines +493 to +589
// filterStringExactToTransactionTypes converts a StringFieldFilterExact to a slice of CreditTransactionType values.
// Uses FromAPIFilterStringExact for mapping; validates that only allowed values are provided.
func filterStringExactToTransactionTypes(_ context.Context, f *filters.FilterStringExact) ([]customerbalance.CreditTransactionType, error) {
fs, err := filters.FromAPIFilterStringExact(f)
if err != nil {
return nil, err
}
if fs == nil {
return nil, nil
}
if fs.Ne != nil {
return nil, errors.New("only eq and oeq operators are supported for type")
}

var values []string
if fs.Eq != nil {
values = append(values, *fs.Eq)
}
if fs.In != nil {
values = append(values, *fs.In...)
}

result := make([]customerbalance.CreditTransactionType, 0, len(values))
for _, v := range values {
switch api.BillingCreditTransactionType(v) {
case api.BillingCreditTransactionTypeFunded:
result = append(result, customerbalance.CreditTransactionTypeFunded)
case api.BillingCreditTransactionTypeConsumed:
result = append(result, customerbalance.CreditTransactionTypeConsumed)
default:
return nil, fmt.Errorf("unsupported credit transaction type: %s", v)
}
}

return result, nil
}

// filterStringExactToStatuses converts a StringFieldFilterExact to a slice of meta.ChargeStatus values.
// Uses FromAPIFilterStringExact for mapping; validates that only allowed values are provided.
func filterStringExactToStatuses(_ context.Context, f *filters.FilterStringExact, convert func(api.BillingCreditGrantStatus) (meta.ChargeStatus, error)) ([]meta.ChargeStatus, error) {
fs, err := filters.FromAPIFilterStringExact(f)
if err != nil {
return nil, err
}
if fs == nil {
return nil, nil
}
if fs.Ne != nil {
return nil, errors.New("only eq and oeq operators are supported for status")
}

var values []string
if fs.Eq != nil {
values = append(values, *fs.Eq)
}
if fs.In != nil {
values = append(values, *fs.In...)
}

result := make([]meta.ChargeStatus, 0, len(values))
for _, v := range values {
converted, err := convert(api.BillingCreditGrantStatus(v))
if err != nil {
return nil, err
}
result = append(result, converted)
}

return result, nil
}

// filterStringExactToCurrencies converts a StringFieldFilterExact to a slice of currencyx.Code values.
// Uses FromAPIFilterStringExact for mapping; validates that only allowed values are provided.
func filterStringExactToCurrencies(_ context.Context, f *filters.FilterStringExact) ([]currencyx.Code, error) {
fs, err := filters.FromAPIFilterStringExact(f)
if err != nil {
return nil, err
}
if fs == nil {
return nil, nil
}
if fs.Ne != nil {
return nil, errors.New("only eq and oeq operators are supported for currency")
}

var codes []currencyx.Code
if fs.Eq != nil {
codes = append(codes, currencyx.Code(*fs.Eq))
}
if fs.In != nil {
for _, v := range *fs.In {
codes = append(codes, currencyx.Code(v))
}
}

return codes, nil
}
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 | ⚡ Quick win

Use validation-typed errors so clients get 4xx instead of 5xx.

The three new helpers return plain errors.New(...) / fmt.Errorf(...) for what are clearly user-input validation problems (unsupported operator, unsupported transaction type, etc.). Other code in this same file uses models.NewGenericValidationError(...) and dedicated factories like newCreditGrantExternalSettlementStatusInvalid(...) for similar cases — without that, these end up surfaced as generic internal errors rather than ValidationIssue responses.

🛡️ Suggested error wrapping
-	if fs.Ne != nil {
-		return nil, errors.New("only eq and oeq operators are supported for type")
-	}
+	if fs.Ne != nil {
+		return nil, models.NewGenericValidationError(errors.New("only eq and oeq operators are supported for type"))
+	}
@@
-		default:
-			return nil, fmt.Errorf("unsupported credit transaction type: %s", v)
+		default:
+			return nil, models.NewGenericValidationError(fmt.Errorf("unsupported credit transaction type: %s", v))

(Apply analogously in filterStringExactToStatuses and filterStringExactToCurrencies.)

As per coding guidelines for api/**/*.go: "Follow HTTP handler patterns and ValidationIssue conventions as documented in the /api skill."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/v3/handlers/customers/credits/convert.go` around lines 493 - 589, Replace
plain errors and fmt.Errorf in filterStringExactToTransactionTypes,
filterStringExactToStatuses, and filterStringExactToCurrencies with
validation-typed errors so clients get 4xx responses: for the unsupported
operator checks in each function (currently errors.New("only eq and oeq
operators are supported for ...")) return a
models.NewGenericValidationError(...) (or an existing specific factory if one
exists) with the same message; in filterStringExactToTransactionTypes convert
the unsupported-type fmt.Errorf into a validation error (either call
models.NewGenericValidationError(fmt.Sprintf(...)) or add a specific factory
like newCreditTransactionTypeInvalid(value)); and ensure any errors returned
from the provided convert(...) in filterStringExactToStatuses are preserved or
mapped to a validation error type rather than a plain error. Use the function
names filterStringExactToTransactionTypes, filterStringExactToStatuses, and
filterStringExactToCurrencies to locate and update these error returns.

Comment on lines 21 to 33
func (l *fundedCreditTransactionLoader) Load(ctx context.Context, input creditTransactionLoaderInput) (creditTransactionLoaderResult, error) {
var currency *currencyx.Code
if len(input.Currencies) == 1 {
currency = &input.Currencies[0]
}

result, err := l.service.CreditPurchaseSvc.ListFundedCreditActivities(ctx, creditpurchase.ListFundedCreditActivitiesInput{
Customer: input.CustomerID,
Limit: input.Limit,
After: toFundedCreditActivityCursor(input.After),
Before: toFundedCreditActivityCursor(input.Before),
Currency: input.Currency,
Currency: currency,
})
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 | 🏗️ Heavy lift

Multi-currency filters silently ignored — likely surprising behavior.

Now that the API filter accepts Common.StringFieldFilterExact (which can carry multiple values via In), filterStringExactToCurrencies can return a slice of length ≥ 2. But here, anything other than exactly one element results in currency = nil, meaning the currency filter is effectively dropped and the loader returns funded credit activities across all currencies. That'll feel pretty surprising to API users who think they're narrowing the result set.

Same issue exists in openmeter/ledger/customerbalance/ledger_loader.go (Lines 22-26) — flagging at the root cause here.

A few options to consider:

  • Push []currencyx.Code through ListFundedCreditActivitiesInput / ledger.ListTransactionsInput so multi-value filtering works end-to-end.
  • Or, if multi-currency isn't supported yet, return an explicit validation error from the handler when len(currencies) > 1 instead of silently ignoring it.
🛡️ Quick guard option (until backend supports multi-value)
-	var currency *currencyx.Code
-	if len(input.Currencies) == 1 {
-		currency = &input.Currencies[0]
-	}
+	if len(input.Currencies) > 1 {
+		return creditTransactionLoaderResult{}, fmt.Errorf("multi-currency filtering is not yet supported")
+	}
+	var currency *currencyx.Code
+	if len(input.Currencies) == 1 {
+		currency = &input.Currencies[0]
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func (l *fundedCreditTransactionLoader) Load(ctx context.Context, input creditTransactionLoaderInput) (creditTransactionLoaderResult, error) {
var currency *currencyx.Code
if len(input.Currencies) == 1 {
currency = &input.Currencies[0]
}
result, err := l.service.CreditPurchaseSvc.ListFundedCreditActivities(ctx, creditpurchase.ListFundedCreditActivitiesInput{
Customer: input.CustomerID,
Limit: input.Limit,
After: toFundedCreditActivityCursor(input.After),
Before: toFundedCreditActivityCursor(input.Before),
Currency: input.Currency,
Currency: currency,
})
func (l *fundedCreditTransactionLoader) Load(ctx context.Context, input creditTransactionLoaderInput) (creditTransactionLoaderResult, error) {
if len(input.Currencies) > 1 {
return creditTransactionLoaderResult{}, fmt.Errorf("multi-currency filtering is not yet supported")
}
var currency *currencyx.Code
if len(input.Currencies) == 1 {
currency = &input.Currencies[0]
}
result, err := l.service.CreditPurchaseSvc.ListFundedCreditActivities(ctx, creditpurchase.ListFundedCreditActivitiesInput{
Customer: input.CustomerID,
Limit: input.Limit,
After: toFundedCreditActivityCursor(input.After),
Before: toFundedCreditActivityCursor(input.Before),
Currency: currency,
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openmeter/ledger/customerbalance/funded_loader.go` around lines 21 - 33, The
loader currently drops multi-value currency filters by setting currency = nil
when input.Currencies has length != 1; update fundedCreditTransactionLoader.Load
(and the analogous ledger loader method in ledger_loader.go) to explicitly
handle len(input.Currencies) > 1 by returning a validation error instead of
silently ignoring the filter: check creditTransactionLoaderInput.Currencies (and
ledger loader's input.Currencies) and if len > 1 return a clear error (e.g.,
"multiple currencies not supported") to the caller; keep the existing behavior
of using &input.Currencies[0] when len == 1 and nil when len == 0.

Comment on lines +53 to +63
loaders := make([]creditTransactionLoader, 0, len(txTypes))
for _, txType := range txTypes {
factory, ok := creditTransactionLoaderFactories[txType]
if !ok {
return nil, txType.Validate()
}

factory, ok := creditTransactionLoaderFactories[*txType]
if !ok {
return nil, txType.Validate()
loaders = append(loaders, factory(s))
}

return []creditTransactionLoader{factory(s)}, nil
return loaders, nil
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 | 🟡 Minor | ⚡ Quick win

Subtle: txType.Validate() could return nil and silently mask a missing factory.

If a new CreditTransactionType is added that passes Validate() but isn't yet registered in creditTransactionLoaderFactories, this branch returns (nil, nil). Callers will see "no loaders, no error" and silently produce empty results — a fun bug to debug later 🙃

A small belt-and-suspenders tweak:

🛡️ Suggested guard
 		factory, ok := creditTransactionLoaderFactories[txType]
 		if !ok {
-			return nil, txType.Validate()
+			if err := txType.Validate(); err != nil {
+				return nil, err
+			}
+			return nil, fmt.Errorf("no loader registered for credit transaction type: %s", txType)
 		}

Also: if the same txType appears twice in the input slice (possible from a multi-value filter), the corresponding loader will run twice. Worth either deduplicating here or documenting that callers must dedupe.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openmeter/ledger/customerbalance/loaders.go` around lines 53 - 63, The code
currently returns (nil, txType.Validate()) when a factory is missing, but
txType.Validate() can be nil and mask the error; change the missing-factory
branch in the loader construction to return an explicit non-nil error (e.g.
fmt.Errorf("no creditTransactionLoader factory registered for %v", txType))
instead of relying on txType.Validate(), and also deduplicate input txTypes
before creating loaders (use a seen map keyed by the CreditTransactionType and
only append one creditTransactionLoader per unique type when building loaders)
so the same loader is not created/run multiple times.

@tothandras tothandras closed this May 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-note/feature Release note: Exciting New Features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant