Skip to content

feat: credit balances final#4559

Merged
GAlexIHU merged 8 commits into
mainfrom
feat/credit-balances-final
Jun 29, 2026
Merged

feat: credit balances final#4559
GAlexIHU merged 8 commits into
mainfrom
feat/credit-balances-final

Conversation

@GAlexIHU

@GAlexIHU GAlexIHU commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • rename customer credit balance semantics:
    • available -> settled
    • previous pending -> live
    • new pending = credit grants not yet effective in the ledger
  • support scheduled credit grants via effectiveAt by carrying it through creditpurchase service periods and ledger booking time
  • add pending-grant balance calculation for customer balance APIs
    • includes invoice-funded grants before realization
    • includes realized grants booked in the future until asOf >= service_period.to
    • excludes deleted/voided/failed/final-without-realization cases
  • thread feature filters through balance, transaction, charge impact, historical balance, and related ledger loaders
  • simplify ledger.Balance into a decimal alias
  • add conservative query-side filtering for the temporary pending-grant scan and document it as slow/RTE debt

Testing

  • nix develop --impure .#ci -c env POSTGRES_HOST=127.0.0.1 go test -tags=dynamic ./openmeter/ledger/customerbalance -run 'Test(GetBalancePending|IsPendingCreditGrantAt|FacadeGetBalancesDiscoversPendingGrantCurrencies)'
  • nix develop --impure .#ci -c go vet -tags=dynamic ./openmeter/ledger/customerbalance
  • git diff --check

Notes

The pending-grant lookup is intentionally a temporary bridge: it scans expanded creditpurchase charges on balance reads. The durable version should be an RTE/queryable scheduled-grant fact or index keyed for the balance path.

Summary by CodeRabbit

  • New Features
    • Credit balances now report separate live, settled, and pending amounts.
    • Credit grants can be scheduled with an optional effective_at timestamp.
    • Credit balance and credit transaction listings support filtering by feature_key.
  • Bug Fixes
    • Credit balance totals and running balances now better reflect the correct booked/scheduled timing and credit state.
    • Ledger booking for credit purchases/grants now uses the intended effective/scheduled time (not the creation time).

@GAlexIHU GAlexIHU added the release-note/ignore Ignore this change when generating release notes label Jun 23, 2026
@GAlexIHU GAlexIHU requested a review from a team as a code owner June 23, 2026 16:08
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR updates credit balance and grant schemas, wires effective_at through credit purchase and grant flows, refactors ledger balance handling to return decimals directly, and expands customer balance computation, filtering, and tests.

Changes

Credits and ledger routing updates

Layer / File(s) Summary
API contracts and generated models
api/spec/packages/aip/src/customers/credits/balance.tsp, api/spec/packages/aip/src/customers/credits/grant.tsp, api/spec/packages/aip-client-javascript/src/models/schemas.ts, api/spec/packages/aip-client-javascript/src/models/types.ts, api/v3/api.gen.go
TypeSpec, JS client schemas/types, and generated Go models replace available with live/settled in CreditBalance, add feature_key filters to balance and transaction query params, and add optional effective_at to credit grant request and response shapes.
EffectiveAt scheduling and grant wiring
openmeter/billing/charges/creditpurchase/charge.go, openmeter/billing/charges/creditpurchase/charge_test.go, openmeter/billing/creditgrant/service.go, openmeter/billing/creditgrant/service/service.go, api/v3/handlers/customers/credits/convert.go, openmeter/ledger/chargeadapter/creditpurchase.go, openmeter/ledger/chargeadapter/creditpurchase_test.go, test/credits/creditgrant_test.go, test/credits/sanity_test.go
Credit purchase intents pin service periods to effective_at, validation no longer rejects effective_at, credit grant input and intent construction propagate effective_at, API conversion maps it, and ledger issuance books templates at the service period end; tests cover the normalized periods and booked-at behavior.
Ledger balance decimals and callers
openmeter/ledger/primitives.go, openmeter/ledger/query.go, openmeter/ledger/historical/balance.go, openmeter/ledger/noop/noop.go, openmeter/ledger/chargeadapter/helpers.go, openmeter/ledger/collector/collection_fbo.go, openmeter/ledger/transactions/collection.go, openmeter/ledger/testutils/integration.go, openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go, test/credits/base.go
ledger.Balance changes from an interface with Settled()/Pending() to a decimal alias; QuerySummedResult is removed; historical and noop balance paths return decimals directly; callers stop unwrapping Settled() before returning balances.
Customer balance service and facade routing
openmeter/ledger/customerbalance/service.go, openmeter/ledger/customerbalance/calculation.go, openmeter/ledger/customerbalance/facade.go, openmeter/ledger/customerbalance/noop.go, openmeter/ledger/customerbalance/funded_loader.go, openmeter/ledger/customerbalance/transactions.go
The customer-balance service adds live and settled balance accessors, feature-filtered balance and currency discovery, pending-grant computation, and live impact calculation; the facade, noop service, funded loader, and transaction listing wire those new inputs and return shapes through.
Customer balance tests and test environment wiring
openmeter/ledger/customerbalance/service_test.go, openmeter/ledger/customerbalance/facade_test.go, openmeter/ledger/customerbalance/expired_loader_test.go, openmeter/ledger/customerbalance/transactions_test.go, openmeter/ledger/customerbalance/testenv_test.go
Customer balance tests expand for live balance assertions, feature-filtered transaction listing, pending grant discovery, and credit purchase charge support in the test charge store.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • tothandras
  • turip
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.66% 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 is too vague and generic to convey the main change in the changeset. Replace it with a specific summary such as 'Add settled/live credit balances and scheduled credit grants'.
✅ 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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/credit-balances-final

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.

@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR completes the credit balance refactor: renames available → settled, redefines pending to mean "not-yet-ledger-booked grants", and adds a new live field for post-charge-impact balance. It also enables scheduled credit grants via effectiveAt by pinning service periods and ledger booked_at to the effective instant rather than wall-clock creation time.

  • Balance semantics rework: settled (ledger-booked amount), live (settled minus open charge impacts), and pending (grants not yet on the ledger or booked with a future time) replace the old two-field available/pending model.
  • Scheduled grant support: effectiveAt on a credit grant is now carried through toIntent, IntentMutableFields.Normalized(), and issueCreditPurchaseGroup, so all ledger entries are booked at the intended effective time rather than wall-clock creation time.
  • Pending-grant scan: A new listPendingGrantCandidateCharges path expands all credit-purchase charges on every GetBalance call to compute the pending amount; this is documented as a temporary bridge with FIXME notes pending a durable RTE-owned scheduled-grant index.

Confidence Score: 5/5

The core balance calculation logic, the effectiveAt scheduling path, and the ledger booking time change are all correct and well-tested.

All guard conditions in isPendingCreditGrantAt and canBecomeEffectiveLedgerCreditAt are verified by unit tests. The bookedAt = ServicePeriod.To change in issueCreditPurchaseGroup is covered by a dedicated chargeadapter test. The only new gaps found are a missing live assertion in the e2e helper and a silent no-match edge case for multi-feature internal callers — both non-blocking and already partially constrained by ValidateAsFeatureFilter.

The listPendingGrantCandidateCharges path in service.go carries documented FIXME performance debt that should be revisited before this feature reaches high-volume customers.

Important Files Changed

Filename Overview
openmeter/ledger/customerbalance/service.go Core balance computation refactored: adds GetSettledBalance, GetBalanceCurrencies, pending-grant scan via listPendingGrantCandidateCharges, and isPendingCreditGrantAt/canBecomeEffectiveLedgerCreditAt predicates; performance FIXMEs are acknowledged inline.
openmeter/billing/charges/creditpurchase/charge.go IntentMutableFields.Normalized() now pins all three service periods to EffectiveAt when set; removes the not-yet-supported error guard for effectiveAt.
openmeter/billing/creditgrant/service/service.go toIntent now derives effectiveAt from input.EffectiveAt with clock.Now() fallback and stores it on the intent; period is correctly pinned to effectiveAt for scheduled grants.
openmeter/ledger/chargeadapter/creditpurchase.go issueCreditPurchaseGroup now derives bookedAt from ServicePeriod.To instead of CreatedAt, enabling future-dated ledger entries for scheduled grants.
openmeter/ledger/historical/balance.go Simplified: Balance struct removed, ledger.Balance is now a plain decimal alias; GetAccountBalance returns alpacadecimal.Decimal directly.
e2e/billinginvoice_override_test.go requireCustomerCreditBalance now asserts settled+pending (new semantics); the live field (charge-impact balance) is no longer verified in the e2e test.
api/v3/handlers/customers/credits/convert.go toAPICreditBalance now maps all three balance fields (settled, live, pending) from customerbalance.Balance; EffectiveAt threaded through grant create/read.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[GetBalance request] --> B[getSettledBalance]
    B --> B1[GetAccountBalance FBO]
    B --> B2[GetAccountBalance Receivable]
    B1 & B2 --> B3[settled = FBO + Receivable]
    A --> C[getChargeLiveBalanceImpacts]
    C --> C1[ListCharges flat-fee + usage-based]
    C1 --> C2[CalculateLiveBalance settled, impacts]
    A --> D[getPendingGrantAmount]
    D --> D1[listPendingGrantCandidateCharges]
    D1 --> D2[isPendingCreditGrantAt predicates]
    D2 --> D3[pending = sum CreditAmount]
    B3 --> E[balance struct]
    C2 --> E
    D3 --> E
    E --> F[settled / live / pending]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[GetBalance request] --> B[getSettledBalance]
    B --> B1[GetAccountBalance FBO]
    B --> B2[GetAccountBalance Receivable]
    B1 & B2 --> B3[settled = FBO + Receivable]
    A --> C[getChargeLiveBalanceImpacts]
    C --> C1[ListCharges flat-fee + usage-based]
    C1 --> C2[CalculateLiveBalance settled, impacts]
    A --> D[getPendingGrantAmount]
    D --> D1[listPendingGrantCandidateCharges]
    D1 --> D2[isPendingCreditGrantAt predicates]
    D2 --> D3[pending = sum CreditAmount]
    B3 --> E[balance struct]
    C2 --> E
    D3 --> E
    E --> F[settled / live / pending]
Loading

Reviews (6): Last reviewed commit: "fix: pending grant asof" | Re-trigger Greptile

Comment on lines +289 to +310
if err != nil {
return nil, fmt.Errorf("get pending grant amount: %w", err)
}

settled := bookedBalance.Add(advanceBalance)

return balance{
settled: settled,
pending: s.balanceCalculator.CalculatePendingBalance(settled, impacts),
live: s.balanceCalculator.CalculateLiveBalance(settled, impacts),
pending: pending,
}, nil
}

func currentBalanceQuery(query ledger.BalanceQuery) ledger.BalanceQuery {
if query.After != nil || query.AsOf != nil {
return query
func (s *service) GetBalanceCurrencies(ctx context.Context, input GetBalanceCurrenciesInput) ([]currencyx.Code, error) {
if err := input.Validate(); err != nil {
return nil, err
}

asOf := clock.Now()
query.AsOf = &asOf
return query
fboCurrencies, err := s.getFBOCurrencies(ctx, input.CustomerID)
if err != nil {
return nil, err
}

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.

P2 Pending-grant scan runs even when only Settled() is consumed

Every cursor-based GetBalance call (the path used by ListCreditTransactions to compute the running balance) still executes getPendingGrantAmount, which triggers the full listPendingGrantCandidateCharges expansion. The result is immediately thrown away — ListCreditTransactions calls runningBalance.Settled() on line 194 and discards Live() and Pending(). For customers with many credit-purchase charges this is an N-page × (charge-scan) overhead on every transaction-list request.

Fix in Claude Code

Comment thread openmeter/ledger/customerbalance/service.go
Comment on lines 33 to 56
currency: Currencies.CurrencyCode;

/**
* Credits that have been granted but cannot yet be consumed. Includes grants
* awaiting payment clearance or with a future effective date.
* Credits available after applying currently live charge impacts.
*/
@visibility(Lifecycle.Read)
@example("200.00")
pending: Shared.Numeric;
live: Shared.Numeric;

/**
* Credits that can be consumed right now. Derived from cleared grants after
* applying eligibility and restriction rules.
* Credits that have been booked on the ledger as of the balance timestamp.
*/
@visibility(Lifecycle.Read)
@example("150.00")
available: Shared.Numeric;
settled: Shared.Numeric;

/**
* Credits that have been granted but are not yet written to the ledger, or are
* written to the ledger with a future booked time.
*/
@visibility(Lifecycle.Read)
@example("50.00")
pending: Shared.Numeric;
}

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.

P1 Breaking schema change: available removed and pending semantics silently redefined

The old CreditBalance exposed available (consumable right now) and pending (awaiting payment / future effective). This PR removes available entirely and reuses the field name pending for a different concept (not-yet-ledger-booked grants). Any client currently reading available will get null/missing, and any client reading pending will get a value with entirely different meaning. If this API is consumed by external clients or SDKs, a versioned deprecation or at minimum an explicit changelog entry is needed before merging.

Fix in Claude Code

Comment thread openmeter/ledger/customerbalance/service.go

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🧹 Nitpick comments (5)
openmeter/billing/creditgrant/service/service.go (1)

216-234: 📐 Maintainability & Code Quality | 🔵 Trivial

The instant period is correct—remove the stale TODO comment.

Good catch! The instant period (From == To == effectiveAt) is actually intentional. The Intent struct has a docstring that explains it directly: "When set, the credit purchase service period is pinned to this instant." Credit grants are point-in-time entities by design, and the test expectations confirm this behavior is deliberate.

Since the Intent field's docstring already explains the semantics, the TODO comment on line 228 is redundant and confusing. Removing it would make the code clearer.

🤖 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/billing/creditgrant/service/service.go` around lines 216 - 234, The
TODO comment on line 228 within the creditpurchase.Intent initialization is
stale and should be removed. The instant period (where From, To, and
FullServicePeriod are all set to the same effectiveAt value) is intentional and
correct for credit grants as point-in-time entities, and the Intent struct's
docstring already explains this behavior. Simply delete the comment "// TODO:
replace with actual service period" to remove the confusion and make the code
clearer.
api/v3/handlers/customers/credits/feature_filter.go (1)

21-27: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider enhancing the error message for better UX.

The error "exists=true operator is not supported" is accurate, but users might not know what to do instead. You could optionally make it more helpful like:

"exists=true is not supported; use filter[feature_key][eq]=<key> to filter by feature, or omit the filter to include all features"

That said, the current message is consistent with other operator rejections, so this is just a nice-to-have.

🤖 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/feature_filter.go` around lines 21 - 27,
The error message returned when the exists=true operator is encountered in the
feature filter needs to be more user-friendly and provide guidance. In the
condition where f.Exists is checked and returns an error (the branch returning
customerbalance.AllFeatureFilter()), replace the current error message
"exists=true operator is not supported" with a more helpful message that
explains what users should do instead, such as suggesting they use
filter[feature_key][eq]=<key> to filter by specific features or omit the filter
entirely to include all features.
test/credits/creditgrant_test.go (1)

191-237: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider adding defer clock.UnFreeze() after clock.SetTime().

The test freezes time on Line 202 with clock.SetTime(now) but doesn't unfreeze it. As per coding guidelines, when freezing time in tests, pair it with defer clock.UnFreeze() to prevent the frozen time from affecting later assertions or subtests.

⏱️ Proposed fix
 	now := datetime.MustParseTimeInLocation(s.T(), "2026-04-17T11:23:00Z", time.UTC).AsTime()
 	effectiveAt := datetime.MustParseTimeInLocation(s.T(), "2026-04-18T09:30:00Z", time.UTC).AsTime()
 	clock.SetTime(now)
+	defer clock.UnFreeze()
 
 	grant, err := s.CreditGrantService.Create(ctx, creditgrant.CreateInput{
🤖 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 `@test/credits/creditgrant_test.go` around lines 191 - 237, The test function
TestCreatePromotionalGrantUsesEffectiveAtForServicePeriodAndLedgerBookedAt
freezes time with clock.SetTime(now) but does not unfreeze it, which can cause
the frozen time to persist and affect subsequent tests. Add a defer statement
immediately after the clock.SetTime(now) call to unfreeze the clock when the
test completes, ensuring that the time freeze is scoped only to this test and
does not interfere with other tests.

Source: Coding guidelines

openmeter/ledger/customerbalance/service.go (2)

419-443: 🚀 Performance & Scalability | 🔵 Trivial

Add short-term guardrails around the pending-grant full scan path.

Nice callout in the FIXME. Since this runs on balance reads, consider adding lightweight guardrails now (latency/page-count metrics and a temporary max-page safety limit) so this path can’t silently become a tail-latency hotspot before the durable query shape lands.

As per path instructions **/*.go: “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/service.go` around lines 419 - 443, The
listPendingGrantCandidateCharges function performs a full scan without any
performance guardrails, which could become a tail-latency hotspot since it runs
on every balance read. Add lightweight guardrails to this function by
implementing latency and page-count metrics to monitor performance, and
introduce a temporary maximum page safety limit to prevent unbounded pagination
scans. This will help catch performance degradation before the durable query
shape is implemented.

Source: Path instructions


553-556: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename pending-named impact helpers to live-named helpers.

These helpers now compute live balance impacts, but the PendingBalanceImpact names make the flow harder to follow.

♻️ Suggested rename
-		return getFlatFeeChargePendingBalanceImpact(charge, currency, featureFilter)
+		return getFlatFeeChargeLiveBalanceImpact(charge, currency, featureFilter)
 	case meta.ChargeTypeUsageBased:
-		return s.getUsageBasedChargePendingBalanceImpact(ctx, charge, currency, featureFilter)
+		return s.getUsageBasedChargeLiveBalanceImpact(ctx, charge, currency, featureFilter)

-func getFlatFeeChargePendingBalanceImpact(charge charges.Charge, currency currencyx.Code, featureFilter mo.Option[creditpurchase.FeatureFilters]) (*Impact, error) {
+func getFlatFeeChargeLiveBalanceImpact(charge charges.Charge, currency currencyx.Code, featureFilter mo.Option[creditpurchase.FeatureFilters]) (*Impact, error) {

-func (s *service) getUsageBasedChargePendingBalanceImpact(ctx context.Context, charge charges.Charge, currency currencyx.Code, featureFilter mo.Option[creditpurchase.FeatureFilters]) (*Impact, error) {
+func (s *service) getUsageBasedChargeLiveBalanceImpact(ctx context.Context, charge charges.Charge, currency currencyx.Code, featureFilter mo.Option[creditpurchase.FeatureFilters]) (*Impact, error) {

As per path instructions **/*.go: “make readability and maintainability a priority.”

Also applies to: 561-578

🤖 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/service.go` around lines 553 - 556, The
function names getFlatFeeChargePendingBalanceImpact and
getUsageBasedChargePendingBalanceImpact contain "PendingBalanceImpact" but now
compute live balance impacts, making the code flow confusing. Rename these
functions and all their usages throughout the file (including the range 561-578)
to reflect that they compute live balance impacts instead of pending ones.
Update all call sites where these functions are invoked to use the new names
consistently.

Source: Path instructions

🤖 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 `@openmeter/ledger/customerbalance/expired_loader_test.go`:
- Around line 125-126: Add an immediate cleanup pairing for each
clock.FreezeTime call in the test file expired_loader_test.go. After each
clock.FreezeTime invocation (at lines 125-126, 320-321, and 600-601), add a
defer statement or t.Cleanup call in the same scope that invokes
clock.UnFreeze() to ensure frozen time does not leak across subsequent
assertions or subtests. This prevents test isolation issues where frozen time
from one test section affects another.

In `@openmeter/ledger/customerbalance/funded_loader.go`:
- Around line 35-40: The Load method has an N+1 query pattern where
balanceCursorForFundedActivity is called for each activity in the loop over
result.Items, causing a separate database round-trip per item. Move the balance
cursor resolution into the funded-activity query path itself so the cursors are
resolved as part of the initial query, or implement a batched group lookup by
IDs that resolves all cursors in a single operation instead of calling
balanceCursorForFundedActivity per activity. Ensure this is applied to all
locations in the Load method where this pattern occurs, including both the main
loop and the additional code sections mentioned in the review.

In `@openmeter/ledger/customerbalance/service_test.go`:
- Around line 278-279: Replace the boolean-based assertions using
`assert.True(t, balance.Settled().Equal(...))` and `assert.True(t,
balance.Live().Equal(...))` with the standard repo convention of using
`require.Equal(t, expectedValue, actual.InexactFloat64())`. Convert the
assertions to call `.InexactFloat64()` on the return values from `Settled()` and
`Live()` methods and use direct equality comparison with `require.Equal` instead
of the `.Equal()` method check wrapped in `assert.True`. Apply the same pattern
to all affected assertion lines noted in the comment.

---

Nitpick comments:
In `@api/v3/handlers/customers/credits/feature_filter.go`:
- Around line 21-27: The error message returned when the exists=true operator is
encountered in the feature filter needs to be more user-friendly and provide
guidance. In the condition where f.Exists is checked and returns an error (the
branch returning customerbalance.AllFeatureFilter()), replace the current error
message "exists=true operator is not supported" with a more helpful message that
explains what users should do instead, such as suggesting they use
filter[feature_key][eq]=<key> to filter by specific features or omit the filter
entirely to include all features.

In `@openmeter/billing/creditgrant/service/service.go`:
- Around line 216-234: The TODO comment on line 228 within the
creditpurchase.Intent initialization is stale and should be removed. The instant
period (where From, To, and FullServicePeriod are all set to the same
effectiveAt value) is intentional and correct for credit grants as point-in-time
entities, and the Intent struct's docstring already explains this behavior.
Simply delete the comment "// TODO: replace with actual service period" to
remove the confusion and make the code clearer.

In `@openmeter/ledger/customerbalance/service.go`:
- Around line 419-443: The listPendingGrantCandidateCharges function performs a
full scan without any performance guardrails, which could become a tail-latency
hotspot since it runs on every balance read. Add lightweight guardrails to this
function by implementing latency and page-count metrics to monitor performance,
and introduce a temporary maximum page safety limit to prevent unbounded
pagination scans. This will help catch performance degradation before the
durable query shape is implemented.
- Around line 553-556: The function names getFlatFeeChargePendingBalanceImpact
and getUsageBasedChargePendingBalanceImpact contain "PendingBalanceImpact" but
now compute live balance impacts, making the code flow confusing. Rename these
functions and all their usages throughout the file (including the range 561-578)
to reflect that they compute live balance impacts instead of pending ones.
Update all call sites where these functions are invoked to use the new names
consistently.

In `@test/credits/creditgrant_test.go`:
- Around line 191-237: The test function
TestCreatePromotionalGrantUsesEffectiveAtForServicePeriodAndLedgerBookedAt
freezes time with clock.SetTime(now) but does not unfreeze it, which can cause
the frozen time to persist and affect subsequent tests. Add a defer statement
immediately after the clock.SetTime(now) call to unfreeze the clock when the
test completes, ensuring that the time freeze is scoped only to this test and
does not interfere with other tests.
🪄 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: c9ac4572-810c-4dc9-a60f-92558af1f2ad

📥 Commits

Reviewing files that changed from the base of the PR and between facf3b0 and d1b3c60.

⛔ Files ignored due to path filters (1)
  • api/v3/openapi.yaml is excluded by !**/openapi.yaml
📒 Files selected for processing (65)
  • api/spec/packages/aip-client-javascript/src/models/schemas.ts
  • api/spec/packages/aip-client-javascript/src/models/types.ts
  • api/spec/packages/aip/src/customers/credits/balance.tsp
  • api/spec/packages/aip/src/customers/credits/grant.tsp
  • api/spec/packages/aip/src/customers/credits/operations.tsp
  • api/v3/api.gen.go
  • api/v3/filters/parse.go
  • api/v3/filters/parse_test.go
  • api/v3/handlers/customers/credits/convert.go
  • api/v3/handlers/customers/credits/feature_filter.go
  • api/v3/handlers/customers/credits/feature_filter_test.go
  • api/v3/handlers/customers/credits/get_balance.go
  • api/v3/handlers/customers/credits/list_transactions.go
  • api/v3/test/filters_test.go
  • openmeter/billing/charges/creditpurchase/adapter/funded_credit_activity.go
  • openmeter/billing/charges/creditpurchase/adapter/funded_credit_activity_test.go
  • openmeter/billing/charges/creditpurchase/charge.go
  • openmeter/billing/charges/creditpurchase/charge_test.go
  • openmeter/billing/charges/creditpurchase/featurefilters.go
  • openmeter/billing/charges/creditpurchase/funded_credit_activity.go
  • openmeter/billing/creditgrant/errors.go
  • openmeter/billing/creditgrant/service.go
  • openmeter/billing/creditgrant/service/service.go
  • openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go
  • openmeter/ledger/account/adapter/subaccount.go
  • openmeter/ledger/breakage/adapter/query.go
  • openmeter/ledger/breakage/adapter/query_test.go
  • openmeter/ledger/breakage/adapter/record.go
  • openmeter/ledger/breakage/adapter/record_test.go
  • openmeter/ledger/breakage/breakage_impacts.go
  • openmeter/ledger/breakage/service.go
  • openmeter/ledger/breakage/types.go
  • openmeter/ledger/chargeadapter/creditpurchase.go
  • openmeter/ledger/chargeadapter/creditpurchase_test.go
  • openmeter/ledger/chargeadapter/helpers.go
  • openmeter/ledger/collector/collection_fbo.go
  • openmeter/ledger/customerbalance/calculation.go
  • openmeter/ledger/customerbalance/expired_loader.go
  • openmeter/ledger/customerbalance/expired_loader_test.go
  • openmeter/ledger/customerbalance/facade.go
  • openmeter/ledger/customerbalance/facade_test.go
  • openmeter/ledger/customerbalance/feature_filter.go
  • openmeter/ledger/customerbalance/funded_loader.go
  • openmeter/ledger/customerbalance/ledger_loader.go
  • openmeter/ledger/customerbalance/loaders.go
  • openmeter/ledger/customerbalance/noop.go
  • openmeter/ledger/customerbalance/service.go
  • openmeter/ledger/customerbalance/service_test.go
  • openmeter/ledger/customerbalance/testenv_test.go
  • openmeter/ledger/customerbalance/transactions.go
  • openmeter/ledger/historical/adapter/ledger.go
  • openmeter/ledger/historical/adapter/ledger_test.go
  • openmeter/ledger/historical/adapter/sumentries_query.go
  • openmeter/ledger/historical/balance.go
  • openmeter/ledger/noop/noop.go
  • openmeter/ledger/primitives.go
  • openmeter/ledger/query.go
  • openmeter/ledger/routing.go
  • openmeter/ledger/routing_test.go
  • openmeter/ledger/testutils/integration.go
  • openmeter/ledger/transactions/collection.go
  • openmeter/ledger/validations_test.go
  • test/credits/base.go
  • test/credits/creditgrant_test.go
  • test/credits/sanity_test.go
💤 Files with no reviewable changes (2)
  • openmeter/billing/creditgrant/errors.go
  • openmeter/ledger/query.go

Comment thread openmeter/ledger/customerbalance/expired_loader_test.go
Comment on lines 35 to +40
items := make([]CreditTransaction, 0, len(result.Items))
for _, activity := range result.Items {
balanceCursor, err := l.balanceCursorForFundedActivity(ctx, input.CustomerID.Namespace, activity)
if err != nil {
return creditTransactionLoaderResult{}, err
}

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.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Avoid per-activity ledger group fetches in this list path

Load() now resolves balanceCursor by calling GetTransactionGroup() once per funded activity. That creates an N+1 DB/read pattern on a hot listing path and can noticeably increase latency and backend load as page size or traffic grows.

Please move cursor resolution into the funded-activity query path (or add a batched group lookup by IDs) so each page stays near constant round-trips.

As per path instructions, "Performance should be a priority in critical code paths. Anything related to ... database operations ... should be vetted for potential performance bottlenecks."

Also applies to: 66-97

🤖 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 35 - 40, The
Load method has an N+1 query pattern where balanceCursorForFundedActivity is
called for each activity in the loop over result.Items, causing a separate
database round-trip per item. Move the balance cursor resolution into the
funded-activity query path itself so the cursors are resolved as part of the
initial query, or implement a batched group lookup by IDs that resolves all
cursors in a single operation instead of calling balanceCursorForFundedActivity
per activity. Ensure this is applied to all locations in the Load method where
this pattern occurs, including both the main loop and the additional code
sections mentioned in the review.

Source: Path instructions

Comment thread openmeter/ledger/customerbalance/service_test.go Outdated

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
openmeter/ledger/customerbalance/expired_loader_test.go (1)

185-211: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add brief given/when/then comments to these subtests.

These new scenarios are pretty dense, and a one-line intent comment at the top of each subtest body would make the feature-filter/breakage cases much easier to scan later. As per coding guidelines, "For service and lifecycle subtests in Go, start each subtest body with concise intent comments using given-when-then format when the scenario is non-trivial."

Also applies to: 383-398, 458-474

🤖 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/expired_loader_test.go` around lines 185 -
211, Add concise given-when-then intent comments at the start of each
non-trivial service subtest body in expired_loader_test, including the subtests
around the ListCreditTransactions cases and the other referenced subtest blocks.
Update the t.Run closures to begin with a short comment that states the setup,
action, and expected outcome so the feature-filter and breakage scenarios are
easier to scan later. Keep the comments brief and consistent across the affected
subtests, using the existing test names and ListCreditTransactions flow as the
locator points.

Source: Coding guidelines

🤖 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.

Nitpick comments:
In `@openmeter/ledger/customerbalance/expired_loader_test.go`:
- Around line 185-211: Add concise given-when-then intent comments at the start
of each non-trivial service subtest body in expired_loader_test, including the
subtests around the ListCreditTransactions cases and the other referenced
subtest blocks. Update the t.Run closures to begin with a short comment that
states the setup, action, and expected outcome so the feature-filter and
breakage scenarios are easier to scan later. Keep the comments brief and
consistent across the affected subtests, using the existing test names and
ListCreditTransactions flow as the locator points.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 46876848-d483-4bd4-9843-3530058335b9

📥 Commits

Reviewing files that changed from the base of the PR and between d1b3c60 and ac7ce3c.

📒 Files selected for processing (8)
  • openmeter/ledger/customerbalance/expired_loader_test.go
  • openmeter/ledger/customerbalance/facade.go
  • openmeter/ledger/customerbalance/funded_loader.go
  • openmeter/ledger/customerbalance/noop.go
  • openmeter/ledger/customerbalance/service.go
  • openmeter/ledger/customerbalance/service_test.go
  • openmeter/ledger/customerbalance/transactions.go
  • openmeter/ledger/customerbalance/transactions_test.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • openmeter/ledger/customerbalance/funded_loader.go
  • openmeter/ledger/customerbalance/service_test.go
  • openmeter/ledger/customerbalance/noop.go
  • openmeter/ledger/customerbalance/transactions.go
  • openmeter/ledger/customerbalance/service.go

tothandras
tothandras previously approved these changes Jun 29, 2026
@GAlexIHU GAlexIHU force-pushed the feat/credit-balances-final branch 2 times, most recently from a5bd143 to 144ceee Compare June 29, 2026 12:59
@GAlexIHU GAlexIHU enabled auto-merge (squash) June 29, 2026 13:00

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 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 `@test/credits/creditgrant_test.go`:
- Around line 203-205: The test case that calls clock.SetTime in the credit
grant suite leaves the global clock shifted for later tests. In the affected
test function around the now/effectiveAt setup, ensure the clock is restored
after the test completes by adding a deferred reset/cleanup in the same scope so
the mutation does not leak into other suite cases.
🪄 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: 70618b45-50bb-4517-bd11-e013ab6472e7

📥 Commits

Reviewing files that changed from the base of the PR and between a5bd143 and 144ceee.

⛔ Files ignored due to path filters (1)
  • api/v3/openapi.yaml is excluded by !**/openapi.yaml
📒 Files selected for processing (35)
  • api/spec/packages/aip-client-javascript/src/models/schemas.ts
  • api/spec/packages/aip-client-javascript/src/models/types.ts
  • api/spec/packages/aip/src/customers/credits/balance.tsp
  • api/spec/packages/aip/src/customers/credits/grant.tsp
  • api/v3/api.gen.go
  • api/v3/handlers/customers/credits/convert.go
  • openmeter/billing/charges/creditpurchase/charge.go
  • openmeter/billing/charges/creditpurchase/charge_test.go
  • openmeter/billing/creditgrant/service.go
  • openmeter/billing/creditgrant/service/service.go
  • openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go
  • openmeter/ledger/chargeadapter/creditpurchase.go
  • openmeter/ledger/chargeadapter/creditpurchase_test.go
  • openmeter/ledger/chargeadapter/helpers.go
  • openmeter/ledger/collector/collection_fbo.go
  • openmeter/ledger/customerbalance/calculation.go
  • openmeter/ledger/customerbalance/expired_loader_test.go
  • openmeter/ledger/customerbalance/facade.go
  • openmeter/ledger/customerbalance/facade_test.go
  • openmeter/ledger/customerbalance/funded_loader.go
  • openmeter/ledger/customerbalance/noop.go
  • openmeter/ledger/customerbalance/service.go
  • openmeter/ledger/customerbalance/service_test.go
  • openmeter/ledger/customerbalance/testenv_test.go
  • openmeter/ledger/customerbalance/transactions.go
  • openmeter/ledger/customerbalance/transactions_test.go
  • openmeter/ledger/historical/balance.go
  • openmeter/ledger/noop/noop.go
  • openmeter/ledger/primitives.go
  • openmeter/ledger/query.go
  • openmeter/ledger/testutils/integration.go
  • openmeter/ledger/transactions/collection.go
  • test/credits/base.go
  • test/credits/creditgrant_test.go
  • test/credits/sanity_test.go
💤 Files with no reviewable changes (1)
  • openmeter/ledger/query.go
🚧 Files skipped from review as they are similar to previous changes (29)
  • openmeter/ledger/chargeadapter/helpers.go
  • openmeter/ledger/testutils/integration.go
  • api/v3/handlers/customers/credits/convert.go
  • api/spec/packages/aip/src/customers/credits/balance.tsp
  • openmeter/billing/charges/creditpurchase/charge_test.go
  • openmeter/ledger/collector/collection_fbo.go
  • openmeter/ledger/transactions/collection.go
  • openmeter/ledger/customerbalance/transactions_test.go
  • openmeter/ledger/customerbalance/funded_loader.go
  • api/spec/packages/aip-client-javascript/src/models/schemas.ts
  • openmeter/billing/creditgrant/service.go
  • openmeter/ledger/chargeadapter/creditpurchase_test.go
  • openmeter/ledger/customerbalance/facade_test.go
  • api/spec/packages/aip-client-javascript/src/models/types.ts
  • test/credits/base.go
  • openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go
  • api/spec/packages/aip/src/customers/credits/grant.tsp
  • openmeter/ledger/primitives.go
  • openmeter/ledger/chargeadapter/creditpurchase.go
  • openmeter/ledger/customerbalance/calculation.go
  • openmeter/billing/charges/creditpurchase/charge.go
  • openmeter/ledger/historical/balance.go
  • openmeter/ledger/customerbalance/noop.go
  • openmeter/billing/creditgrant/service/service.go
  • openmeter/ledger/customerbalance/facade.go
  • test/credits/sanity_test.go
  • openmeter/ledger/noop/noop.go
  • openmeter/ledger/customerbalance/service.go
  • openmeter/ledger/customerbalance/service_test.go

Comment on lines +203 to +205
now := datetime.MustParseTimeInLocation(s.T(), "2026-04-17T11:23:00Z", time.UTC).AsTime()
effectiveAt := datetime.MustParseTimeInLocation(s.T(), "2026-04-18T09:30:00Z", time.UTC).AsTime()
clock.SetTime(now)

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Reset the shifted clock after this test.

Line 205 calls clock.SetTime(now), but this new case never restores it. Since SetTime mutates global clock drift, later suite tests can inherit the shifted wall clock and become order-dependent.

🧹 Small fix
 	now := datetime.MustParseTimeInLocation(s.T(), "2026-04-17T11:23:00Z", time.UTC).AsTime()
 	effectiveAt := datetime.MustParseTimeInLocation(s.T(), "2026-04-18T09:30:00Z", time.UTC).AsTime()
 	clock.SetTime(now)
+	s.T().Cleanup(clock.ResetTime)
📝 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
now := datetime.MustParseTimeInLocation(s.T(), "2026-04-17T11:23:00Z", time.UTC).AsTime()
effectiveAt := datetime.MustParseTimeInLocation(s.T(), "2026-04-18T09:30:00Z", time.UTC).AsTime()
clock.SetTime(now)
now := datetime.MustParseTimeInLocation(s.T(), "2026-04-17T11:23:00Z", time.UTC).AsTime()
effectiveAt := datetime.MustParseTimeInLocation(s.T(), "2026-04-18T09:30:00Z", time.UTC).AsTime()
clock.SetTime(now)
s.T().Cleanup(clock.ResetTime)
🤖 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 `@test/credits/creditgrant_test.go` around lines 203 - 205, The test case that
calls clock.SetTime in the credit grant suite leaves the global clock shifted
for later tests. In the affected test function around the now/effectiveAt setup,
ensure the clock is restored after the test completes by adding a deferred
reset/cleanup in the same scope so the mutation does not leak into other suite
cases.

tothandras
tothandras previously approved these changes Jun 29, 2026
Comment on lines +478 to +500
func isPendingCreditGrantAt(charge creditpurchase.Charge, asOf time.Time) bool {
if !canBecomeEffectiveLedgerCreditAt(charge, asOf) {
return false
}

if charge.Realizations.CreditGrantRealization == nil {
return true
}

return charge.Intent.ServicePeriod.To.After(asOf)
}

func canBecomeEffectiveLedgerCreditAt(charge creditpurchase.Charge, asOf time.Time) bool {
if charge.Status == creditpurchase.StatusDeleted || charge.IsDeletedAt(asOf) {
return false
}

if charge.Realizations.InvoiceSettlement != nil && charge.Realizations.InvoiceSettlement.IsDeletedAt(asOf) {
return false
}

if charge.Realizations.ExternalPaymentSettlement != nil && charge.Realizations.ExternalPaymentSettlement.IsDeletedAt(asOf) {
return false

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.

P1 isPendingCreditGrantAt includes charges created after asOf in historical queries

listPendingGrantCandidateCharges fetches all credit-purchase charges for the customer with no creation-time filter. canBecomeEffectiveLedgerCreditAt only checks DeletedAt and Status, not CreatedAt. For a historical GetBalance(AsOf = T1) call, any charge created at T2 > T1 passes all guards — IsDeletedAt(T1) is false and status checks pass — so isPendingCreditGrantAt returns true for it. Charges that did not yet exist at the query point inflate the returned Pending() amount.

This does not affect live balance reads (AsOf == nil, resolved to clock.Now()), but any historical AsOf query can produce an incorrect pending total. Adding a guard like charge.CreatedAt.After(asOf) → skip in canBecomeEffectiveLedgerCreditAt would close this gap without conflicting with the documented bridge limitations.

Prompt To Fix With AI
This is a comment left during a code review.
Path: openmeter/ledger/customerbalance/service.go
Line: 478-500

Comment:
**`isPendingCreditGrantAt` includes charges created after `asOf` in historical queries**

`listPendingGrantCandidateCharges` fetches all credit-purchase charges for the customer with no creation-time filter. `canBecomeEffectiveLedgerCreditAt` only checks `DeletedAt` and `Status`, not `CreatedAt`. For a historical `GetBalance(AsOf = T1)` call, any charge created at `T2 > T1` passes all guards — `IsDeletedAt(T1)` is false and status checks pass — so `isPendingCreditGrantAt` returns `true` for it. Charges that did not yet exist at the query point inflate the returned `Pending()` amount.

This does not affect live balance reads (`AsOf == nil`, resolved to `clock.Now()`), but any historical `AsOf` query can produce an incorrect pending total. Adding a guard like `charge.CreatedAt.After(asOf)` → skip in `canBecomeEffectiveLedgerCreditAt` would close this gap without conflicting with the documented bridge limitations.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

@tothandras

Copy link
Copy Markdown
Contributor

It's outside of this PR, but you could use lo.Uniq instead of:

func dedupeCurrencies(codes []currencyx.Code) []currencyx.Code {
  seen := make(map[currencyx.Code]struct{}, len(codes))
  out := make([]currencyx.Code, 0, len(codes))

  for _, code := range codes {
    if _, ok := seen[code]; ok {
      continue
    }

    seen[code] = struct{}{}
    out = append(out, code)
  }

  return out
}

@GAlexIHU GAlexIHU requested a review from tothandras June 29, 2026 14:58
@GAlexIHU GAlexIHU merged commit eaeb4d5 into main Jun 29, 2026
29 checks passed
@GAlexIHU GAlexIHU deleted the feat/credit-balances-final branch June 29, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-note/ignore Ignore this change when generating release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants