Skip to content

feat(ledger): negative credit#4036

Merged
turip merged 4 commits intomainfrom
feat/negative-credit
Mar 31, 2026
Merged

feat(ledger): negative credit#4036
turip merged 4 commits intomainfrom
feat/negative-credit

Conversation

@GAlexIHU
Copy link
Copy Markdown
Contributor

@GAlexIHU GAlexIHU commented Mar 30, 2026

Overview

Credit advance (negative credit) & credit_only scenarios:

  • negative credits are funded from a 0 CB receivable account
  • funding flow first checks for CB attribution (up to negative amount)
    • CB conversions take place on payment initiated for logical consistency
      also
  • credit_only scenario added in sanity_test
  • FF & CreditPurchase are functionally covered

Notes for reviewer

Summary by CodeRabbit

  • New Features

    • Cost-basis tracking added across ledger balances, transfers, and settlements for finer attribution.
    • Credit‑Only settlement mode supported; credit advances are auto‑allocated and backfilled with attribution templates.
    • New operations to translate and attribute cost‑basis between buckets.
  • Improvements

    • Routing, validation and allocation now enforce cost‑basis-aware rules and split/route entries by cost basis.
    • Earnings recognition and realization flows honor cost‑basis attribution and skip unknown buckets.
  • Tests

    • Expanded test coverage for cost‑basis attribution, credit‑only flows, advance/backfill and translations.

@GAlexIHU GAlexIHU requested a review from a team as a code owner March 30, 2026 16:01
@GAlexIHU GAlexIHU added the release-note/misc Miscellaneous changes label Mar 30, 2026
@GAlexIHU GAlexIHU force-pushed the feat/negative-credit branch from 56fa779 to 5f30f96 Compare March 30, 2026 16:02
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 30, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Route-level cost-basis handling was made explicit: RouteFilter.CostBasis changed to mo.Option[*alpacadecimal.Decimal]. Routing normalization, route filters, routing rules, transaction templates, allocation/collection logic, charge adapters, and many tests were updated to distinguish absent / present-with-nil / present-with-value and to operate per-cost-basis.

Changes

Cohort / File(s) Summary
Core types & routing
openmeter/ledger/primitives.go, openmeter/ledger/routing.go, openmeter/ledger/accounts.go
RouteFilter.CostBasismo.Option[*alpacadecimal.Decimal]; Route.Filter()/Normalize() updated to use mo.Option APIs; CustomerAccruedRouteParams now carries CostBasis.
Route filters & adapters
openmeter/ledger/account/adapter/repo_test.go, openmeter/ledger/account/adapter/subaccount.go, openmeter/ledger/historical/adapter/sumentries_query.go, openmeter/ledger/historical/adapter/ledger_test.go
Tests and adapter code now build/use mo.Some and use IsPresent()/Get(); added branches to handle present-but-nil (CostBasisIsNil()) vs present-with-value (CostBasis(...)).
Routing rules & defaults
openmeter/ledger/routingrules/defaults.go, openmeter/ledger/routingrules/routingrules.go
Added RequireFBOCostBasisTranslationRule and RequireAccruedCostBasisTranslationRule; tightened RequireSameRouteRule to require RouteFieldCostBasis for several transitions; refactored matching/authorization checks.
Accrual & collection templates
openmeter/ledger/transactions/accrual.go, openmeter/ledger/transactions/collection.go, openmeter/ledger/transactions/testenv_test.go
Group FBO→accrued transfers by cost-basis, resolve accrued per cost-basis, added TransferCustomerFBOBucketToAccrued/TransferCustomerFBOToAccruedTemplate and TranslateCustomerAccruedCostBasisTemplate; refactored collectors to allocate from cost-basis-aware sources.
Customer transaction templates
openmeter/ledger/transactions/customer.go, openmeter/ledger/transactions/customer_test.go
Added AttributeCustomerAdvanceReceivableCostBasisTemplate to reclassify advances to cost-basis-specific receivable; test added.
Earnings recognition
openmeter/ledger/transactions/earnings.go, openmeter/ledger/transactions/earnings_test.go
Renamed template to RecognizeEarningsFromAttributableAccruedTemplate; recognition now collects attributable accrued buckets and recognizes earnings per cost-basis; tests updated/added.
Charge adapters (flatfee & creditpurchase)
openmeter/ledger/chargeadapter/flatfee.go, openmeter/ledger/chargeadapter/flatfee_test.go, openmeter/ledger/chargeadapter/creditpurchase.go, openmeter/ledger/chargeadapter/creditpurchase_test.go
Flat-fee: settlement-mode validation, implemented OnCreditsOnlyUsageAccrued, allocateCreditsToAccrued, cost-basis-aware realizations; Credit purchase: advance-aware issuance and dynamic template construction; tests for advance/backing behavior added.
Templates / transactions (other)
openmeter/ledger/transactions/...
Multiple templates updated/added for translating and transferring balances with cost-basis awareness; helpers to build per-cost-basis entry inputs.
Tests & test helpers / integration
openmeter/ledger/testutils/integration.go, openmeter/ledger/transactions/accrual_test.go, test/credits/sanity_test.go, various *_test.go files
Extended test env with *WithCostBasis helpers; many tests updated to assert cost-basis-specific sub-accounts/balances; added credit-only flow integration tests and mo.Option usage in tests.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant CreditPurchaseHandler
    participant Templates
    participant Ledger
    Client->>CreditPurchaseHandler: OnCreditPurchaseInitiated(amount, currency)
    CreditPurchaseHandler->>CreditPurchaseHandler: outstandingAdvanceBalance(customer, currency)
    alt outstanding advance > 0
        CreditPurchaseHandler->>Templates: build attribution templates (advance, accrued) + issuableAmount = amount - advance
    else no advance
        CreditPurchaseHandler->>Templates: build issuance template for full amount
    end
    Templates->>Ledger: Resolve & Commit templates (per-cost-basis routes)
    Ledger-->>Client: Commit result
Loading
sequenceDiagram
    participant FlatFeeAdapter
    participant Allocator
    participant Templates
    participant Ledger
    FlatFeeAdapter->>Allocator: allocateCreditsToAccrued(charge, mode)
    Allocator->>Templates: TransferCustomerFBOBucketToAccruedTemplate for each cost-basis
    Templates->>Ledger: Commit per-cost-basis transfers
    FlatFeeAdapter->>Templates: RecognizeEarningsFromAttributableAccruedTemplate
    Templates->>Ledger: Debit accrued[costBasis], Credit earnings[costBasis] (loop)
    Ledger-->>FlatFeeAdapter: Commit result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

release-note/feature, area/billing

Suggested reviewers

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

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(ledger): negative credit' clearly and specifically summarizes the main feature being implemented: support for negative credits (credit advances) in the ledger system.

✏️ 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/negative-credit

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.

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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
openmeter/ledger/chargeadapter/creditpurchase.go (1)

79-84: ⚠️ Potential issue | 🟠 Major

Bug: Should use issuableAmount instead of charge.Intent.CreditAmount.

The code correctly calculates issuableAmount at line 64 (reduced by advance), but then the template at line 81 still uses the original charge.Intent.CreditAmount. This means even when there's a partial advance, the full credit amount gets issued instead of just the excess.

Compare with OnCreditPurchaseInitiated at line 149 which correctly uses issuableAmount.

🐛 Proposed fix
 	inputs, err := transactions.ResolveTransactions(
 		ctx,
 		h.resolverDependencies(),
 		transactions.ResolutionScope{
 			CustomerID: customerID,
 			Namespace:  charge.Namespace,
 		},
 		transactions.IssueCustomerReceivableTemplate{
 			At:        charge.CreatedAt,
-			Amount:    charge.Intent.CreditAmount,
+			Amount:    issuableAmount,
 			Currency:  charge.Intent.Currency,
 			CostBasis: &costBasis,
 		},
 	)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/ledger/chargeadapter/creditpurchase.go` around lines 79 - 84, The
IssueCustomerReceivableTemplate currently uses charge.Intent.CreditAmount which
ignores the previously computed issuableAmount (reduced by advance); update the
template to use issuableAmount instead of charge.Intent.CreditAmount so the
issued receivable reflects the net credit; ensure the variable issuableAmount
(from the earlier calculation) is in scope where
transactions.IssueCustomerReceivableTemplate is constructed (same pattern as in
OnCreditPurchaseInitiated).
openmeter/ledger/routingrules/routingrules.go (1)

237-290: ⚠️ Potential issue | 🟠 Major

These translation rules don’t line up with the templates they’re validating.

Right now both rules only accept same-account moves where one side is cost_basis=nil and the other side is known. That makes TranslateCustomerFBOCostBasisTemplate in openmeter/ledger/transactions/customer.go impossible to commit under DefaultValidator, and it also rejects the known→known moves that TranslateCustomerAccruedCostBasisTemplate in openmeter/ledger/transactions/accrual.go still allows through Validate(). Please make the rule and template semantics match in one place, otherwise these transactions will fail at commit time with unknown_cost_basis_required.

Also applies to: 332-352

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

In `@openmeter/ledger/routingrules/routingrules.go` around lines 237 - 290, The
rules RequireAccruedCostBasisTranslationRule.Validate and
RequireFBOCostBasisTranslationRule.Validate currently only allow same-account
moves where one side has cost_basis=nil and the other known; update them to
accept either (a) same-account translations where one side is known and the
other nil (keep calling requireKnownToUnknownCostBasisTranslation as now) OR (b)
same-account known→known cost-basis translations (allow when both entries’
cost_basis are non-nil and consistent). Concretely: in
RequireAccruedCostBasisTranslationRule.Validate and
RequireFBOCostBasisTranslationRule.Validate, after you split entries
(negativeEntries, positiveEntries) detect whether both sides have non-nil
cost_basis values and if so permit the transaction (return nil) — otherwise call
requireKnownToUnknownCostBasisTranslation exactly as you do today; this will
make the rules align with TranslateCustomerFBOCostBasisTemplate and
TranslateCustomerAccruedCostBasisTemplate.
🧹 Nitpick comments (3)
openmeter/ledger/historical/adapter/ledger_test.go (1)

351-398: Consider adding explicit absent-vs-nil cost-basis filter cases.

These tests already cover canonicalized numeric cost basis nicely. Adding cases for “cost basis absent” and “cost basis explicitly nil” would lock in the new semantics at both query and SQL expectation levels.

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

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

In `@openmeter/ledger/historical/adapter/ledger_test.go` around lines 351 - 398,
Add two explicit test cases covering (1) absent cost-basis filter and (2)
cost-basis explicitly nil: update the existing tests around
TestSumEntriesQuery_SQL and the earlier SumEntries usage to create queries using
ledger.Query / ledger.Filters / ledger.RouteFilter where CostBasis is omitted
entirely, and where CostBasis is set to an explicit nil option (e.g., mo.None or
the package's nil representation). For each case assert both the generated SQL
from sumEntriesQuery.SQL() and the args slice match the expected semantics (no
cost_basis predicate when absent, and an explicit NULL check or parameter when
cost-basis is explicitly nil) so the test suite locks in both behaviors.
Reference sumEntriesQuery, ledger.Query, ledger.Filters, and
RouteFilter.CostBasis when locating where to add the new assertions.
openmeter/ledger/transactions/customer_test.go (1)

137-175: Please add one negative-path test for missing CostBasis.

Great happy-path coverage here. It would be good to also assert that FundCustomerAdvanceReceivableTemplate fails when CostBasis is nil, since that contract is central to this feature.

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

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

In `@openmeter/ledger/transactions/customer_test.go` around lines 137 - 175, Add a
negative-path test named like
TestFundCustomerAdvanceReceivableTemplate_MissingCostBasis that constructs a new
transactions test env, calls env.resolveAndCommit with a
FundCustomerAdvanceReceivableTemplate whose CostBasis is nil, and assert the
operation fails (use require.Error on the resolve call or assert no inputs were
returned) and that balances remain unchanged by checking env.SumBalance for
env.ReceivableSubAccountWithCostBasis and env.ReceivableSubAccount; reference
FundCustomerAdvanceReceivableTemplate, env.resolveAndCommit, env.SumBalance,
ReceivableSubAccountWithCostBasis, and ReceivableSubAccount to locate the
relevant helpers.
openmeter/ledger/transactions/collection.go (1)

116-142: Consider extracting common allocation logic.

collectFromPrioritizedSources and collectFromBalanceSources have nearly identical allocation logic. If you wanted to DRY this up, you could use a common function with a balance-getter interface. That said, the duplication is pretty minimal (~20 lines), so this is totally optional.

Also applies to: 194-220

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

In `@openmeter/ledger/transactions/collection.go` around lines 116 - 142, The two
functions collectFromPrioritizedSources and collectFromBalanceSources duplicate
allocation logic; extract a single allocator (e.g., allocateFromSources) that
accepts a slice of a small interface or accessor functions for Balance() and
SubAccount() (or two function params: getBalance(src) and getSubAccount(src))
plus the target Decimal, and reuse it from both collectFromPrioritizedSources
and collectFromBalanceSources so the loop, positive checks, amount
min(remaining,balance), append and remaining.Sub(amount) logic is centralized.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openmeter/ledger/accounts.go`:
- Around line 104-109: The struct comment for CustomerAccruedRouteParams is
outdated—update it to state that routing is by Currency and an optional
CostBasis (pointer to alpacadecimal.Decimal) rather than currency-only; mention
that CostBasis is optional (nil when not used) so readers understand the pointer
semantics and routing behavior for the CustomerAccruedRouteParams type.

---

Outside diff comments:
In `@openmeter/ledger/chargeadapter/creditpurchase.go`:
- Around line 79-84: The IssueCustomerReceivableTemplate currently uses
charge.Intent.CreditAmount which ignores the previously computed issuableAmount
(reduced by advance); update the template to use issuableAmount instead of
charge.Intent.CreditAmount so the issued receivable reflects the net credit;
ensure the variable issuableAmount (from the earlier calculation) is in scope
where transactions.IssueCustomerReceivableTemplate is constructed (same pattern
as in OnCreditPurchaseInitiated).

In `@openmeter/ledger/routingrules/routingrules.go`:
- Around line 237-290: The rules RequireAccruedCostBasisTranslationRule.Validate
and RequireFBOCostBasisTranslationRule.Validate currently only allow
same-account moves where one side has cost_basis=nil and the other known; update
them to accept either (a) same-account translations where one side is known and
the other nil (keep calling requireKnownToUnknownCostBasisTranslation as now) OR
(b) same-account known→known cost-basis translations (allow when both entries’
cost_basis are non-nil and consistent). Concretely: in
RequireAccruedCostBasisTranslationRule.Validate and
RequireFBOCostBasisTranslationRule.Validate, after you split entries
(negativeEntries, positiveEntries) detect whether both sides have non-nil
cost_basis values and if so permit the transaction (return nil) — otherwise call
requireKnownToUnknownCostBasisTranslation exactly as you do today; this will
make the rules align with TranslateCustomerFBOCostBasisTemplate and
TranslateCustomerAccruedCostBasisTemplate.

---

Nitpick comments:
In `@openmeter/ledger/historical/adapter/ledger_test.go`:
- Around line 351-398: Add two explicit test cases covering (1) absent
cost-basis filter and (2) cost-basis explicitly nil: update the existing tests
around TestSumEntriesQuery_SQL and the earlier SumEntries usage to create
queries using ledger.Query / ledger.Filters / ledger.RouteFilter where CostBasis
is omitted entirely, and where CostBasis is set to an explicit nil option (e.g.,
mo.None or the package's nil representation). For each case assert both the
generated SQL from sumEntriesQuery.SQL() and the args slice match the expected
semantics (no cost_basis predicate when absent, and an explicit NULL check or
parameter when cost-basis is explicitly nil) so the test suite locks in both
behaviors. Reference sumEntriesQuery, ledger.Query, ledger.Filters, and
RouteFilter.CostBasis when locating where to add the new assertions.

In `@openmeter/ledger/transactions/collection.go`:
- Around line 116-142: The two functions collectFromPrioritizedSources and
collectFromBalanceSources duplicate allocation logic; extract a single allocator
(e.g., allocateFromSources) that accepts a slice of a small interface or
accessor functions for Balance() and SubAccount() (or two function params:
getBalance(src) and getSubAccount(src)) plus the target Decimal, and reuse it
from both collectFromPrioritizedSources and collectFromBalanceSources so the
loop, positive checks, amount min(remaining,balance), append and
remaining.Sub(amount) logic is centralized.

In `@openmeter/ledger/transactions/customer_test.go`:
- Around line 137-175: Add a negative-path test named like
TestFundCustomerAdvanceReceivableTemplate_MissingCostBasis that constructs a new
transactions test env, calls env.resolveAndCommit with a
FundCustomerAdvanceReceivableTemplate whose CostBasis is nil, and assert the
operation fails (use require.Error on the resolve call or assert no inputs were
returned) and that balances remain unchanged by checking env.SumBalance for
env.ReceivableSubAccountWithCostBasis and env.ReceivableSubAccount; reference
FundCustomerAdvanceReceivableTemplate, env.resolveAndCommit, env.SumBalance,
ReceivableSubAccountWithCostBasis, and ReceivableSubAccount to locate the
relevant helpers.
🪄 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: 97479a08-5fc3-462d-9b7d-0bb6505778e4

📥 Commits

Reviewing files that changed from the base of the PR and between 2b4ade6 and 5f30f96.

📒 Files selected for processing (23)
  • openmeter/ledger/account/adapter/repo_test.go
  • openmeter/ledger/account/adapter/subaccount.go
  • openmeter/ledger/accounts.go
  • openmeter/ledger/chargeadapter/creditpurchase.go
  • openmeter/ledger/chargeadapter/creditpurchase_test.go
  • openmeter/ledger/chargeadapter/flatfee.go
  • openmeter/ledger/chargeadapter/flatfee_test.go
  • openmeter/ledger/historical/adapter/ledger_test.go
  • openmeter/ledger/historical/adapter/sumentries_query.go
  • openmeter/ledger/primitives.go
  • openmeter/ledger/routing.go
  • openmeter/ledger/routingrules/defaults.go
  • openmeter/ledger/routingrules/routingrules.go
  • openmeter/ledger/testutils/integration.go
  • openmeter/ledger/transactions/accrual.go
  • openmeter/ledger/transactions/accrual_test.go
  • openmeter/ledger/transactions/collection.go
  • openmeter/ledger/transactions/customer.go
  • openmeter/ledger/transactions/customer_test.go
  • openmeter/ledger/transactions/earnings.go
  • openmeter/ledger/transactions/earnings_test.go
  • openmeter/ledger/transactions/testenv_test.go
  • test/credits/sanity_test.go

Comment on lines 104 to 109
// CustomerAccruedRouteParams are routing parameters specific to customer accrued sub-accounts.
// Routed by currency only for now.
type CustomerAccruedRouteParams struct {
Currency currencyx.Code
Currency currencyx.Code
CostBasis *alpacadecimal.Decimal
}
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

Comment is stale after adding CostBasis routing.

The struct now routes by currency and optional cost basis, but the comment still says currency-only.

Suggested doc fix
-// CustomerAccruedRouteParams are routing parameters specific to customer accrued sub-accounts.
-// Routed by currency only for now.
+// CustomerAccruedRouteParams are routing parameters specific to customer accrued sub-accounts.
+// Routed by currency and optional cost basis.
📝 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
// CustomerAccruedRouteParams are routing parameters specific to customer accrued sub-accounts.
// Routed by currency only for now.
type CustomerAccruedRouteParams struct {
Currency currencyx.Code
Currency currencyx.Code
CostBasis *alpacadecimal.Decimal
}
// CustomerAccruedRouteParams are routing parameters specific to customer accrued sub-accounts.
// Routed by currency and optional cost basis.
type CustomerAccruedRouteParams struct {
Currency currencyx.Code
CostBasis *alpacadecimal.Decimal
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/ledger/accounts.go` around lines 104 - 109, The struct comment for
CustomerAccruedRouteParams is outdated—update it to state that routing is by
Currency and an optional CostBasis (pointer to alpacadecimal.Decimal) rather
than currency-only; mention that CostBasis is optional (nil when not used) so
readers understand the pointer semantics and routing behavior for the
CustomerAccruedRouteParams type.

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.

🧹 Nitpick comments (2)
openmeter/ledger/chargeadapter/flatfee_test.go (1)

710-713: Mirrors invoiceCostBasis() from the main code.

This test helper duplicates the logic from flatfee.go. While acceptable for test isolation, if the value changes in one place, it needs to change in both.

Consider exporting the constant or function to avoid drift, or add a comment noting the dependency:

 func testInvoiceCostBasis() *alpacadecimal.Decimal {
+	// Must match invoiceCostBasis() in flatfee.go
 	value := alpacadecimal.NewFromInt(1)
 	return &value
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/ledger/chargeadapter/flatfee_test.go` around lines 710 - 713, The
test helper testInvoiceCostBasis duplicates logic from invoiceCostBasis in
flatfee.go which can drift; either export and reuse the shared constant/function
from flatfee (e.g., make invoiceCostBasis or a named constant public and call it
from testInvoiceCostBasis) or add a clear TODO/comment above
testInvoiceCostBasis documenting it must mirror invoiceCostBasis and reference
that function so future changes stay synced; update references to use the
exported symbol (or add the comment) and remove the duplicated construction in
testInvoiceCostBasis.
openmeter/ledger/chargeadapter/flatfee.go (1)

414-417: Consider documenting why invoice cost basis is 1.

The magic number 1 for invoice cost basis might benefit from a brief comment explaining the semantic meaning (e.g., "// Invoice-backed transactions use cost basis 1 to distinguish from credit-backed (0)").

📝 Suggested documentation
 func invoiceCostBasis() *alpacadecimal.Decimal {
+	// Invoice-backed transactions use cost basis 1 to distinguish from credit-backed (0).
 	value := alpacadecimal.NewFromInt(1)
 	return &value
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/ledger/chargeadapter/flatfee.go` around lines 414 - 417, Add a
short explanatory comment to invoiceCostBasis explaining why it returns
alpacadecimal.NewFromInt(1) (e.g., that invoice-backed transactions use cost
basis 1 to distinguish from credit-backed 0), and place the comment directly
above the invoiceCostBasis function so future readers see the semantic meaning;
reference the function invoiceCostBasis and the call to
alpacadecimal.NewFromInt(1) when updating the doc comment.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@openmeter/ledger/chargeadapter/flatfee_test.go`:
- Around line 710-713: The test helper testInvoiceCostBasis duplicates logic
from invoiceCostBasis in flatfee.go which can drift; either export and reuse the
shared constant/function from flatfee (e.g., make invoiceCostBasis or a named
constant public and call it from testInvoiceCostBasis) or add a clear
TODO/comment above testInvoiceCostBasis documenting it must mirror
invoiceCostBasis and reference that function so future changes stay synced;
update references to use the exported symbol (or add the comment) and remove the
duplicated construction in testInvoiceCostBasis.

In `@openmeter/ledger/chargeadapter/flatfee.go`:
- Around line 414-417: Add a short explanatory comment to invoiceCostBasis
explaining why it returns alpacadecimal.NewFromInt(1) (e.g., that invoice-backed
transactions use cost basis 1 to distinguish from credit-backed 0), and place
the comment directly above the invoiceCostBasis function so future readers see
the semantic meaning; reference the function invoiceCostBasis and the call to
alpacadecimal.NewFromInt(1) when updating the doc comment.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 236065fe-938b-433b-99f1-a1f3f81e4996

📥 Commits

Reviewing files that changed from the base of the PR and between 5f30f96 and 79448ba.

📒 Files selected for processing (23)
  • openmeter/ledger/account/adapter/repo_test.go
  • openmeter/ledger/account/adapter/subaccount.go
  • openmeter/ledger/accounts.go
  • openmeter/ledger/chargeadapter/creditpurchase.go
  • openmeter/ledger/chargeadapter/creditpurchase_test.go
  • openmeter/ledger/chargeadapter/flatfee.go
  • openmeter/ledger/chargeadapter/flatfee_test.go
  • openmeter/ledger/historical/adapter/ledger_test.go
  • openmeter/ledger/historical/adapter/sumentries_query.go
  • openmeter/ledger/primitives.go
  • openmeter/ledger/routing.go
  • openmeter/ledger/routingrules/defaults.go
  • openmeter/ledger/routingrules/routingrules.go
  • openmeter/ledger/testutils/integration.go
  • openmeter/ledger/transactions/accrual.go
  • openmeter/ledger/transactions/accrual_test.go
  • openmeter/ledger/transactions/collection.go
  • openmeter/ledger/transactions/customer.go
  • openmeter/ledger/transactions/customer_test.go
  • openmeter/ledger/transactions/earnings.go
  • openmeter/ledger/transactions/earnings_test.go
  • openmeter/ledger/transactions/testenv_test.go
  • test/credits/sanity_test.go
✅ Files skipped from review due to trivial changes (5)
  • openmeter/ledger/account/adapter/repo_test.go
  • openmeter/ledger/transactions/customer_test.go
  • openmeter/ledger/transactions/earnings.go
  • openmeter/ledger/chargeadapter/creditpurchase.go
  • openmeter/ledger/transactions/customer.go
🚧 Files skipped from review as they are similar to previous changes (13)
  • openmeter/ledger/historical/adapter/sumentries_query.go
  • openmeter/ledger/primitives.go
  • openmeter/ledger/historical/adapter/ledger_test.go
  • openmeter/ledger/routing.go
  • openmeter/ledger/accounts.go
  • openmeter/ledger/transactions/earnings_test.go
  • openmeter/ledger/transactions/accrual_test.go
  • openmeter/ledger/routingrules/defaults.go
  • openmeter/ledger/transactions/collection.go
  • openmeter/ledger/chargeadapter/creditpurchase_test.go
  • openmeter/ledger/testutils/integration.go
  • test/credits/sanity_test.go
  • openmeter/ledger/routingrules/routingrules.go

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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/credits/sanity_test.go (1)

1003-1038: ⚠️ Potential issue | 🟠 Major

Add a test case for mo.Some(nil) cost-basis routing to ensure the query predicates don't regress.

Your concern is spot-on. The helpers mustCustomerAccruedBalanceWithCostBasis and mustCustomerReceivableRouteBalance call GetSubAccountForRoute, which accepts the raw cost-basis pointer directly and bypasses the routeFilterCostBasis converter. Meanwhile, mustCustomerReceivableBalance and mustCustomerAuthorizedReceivableBalance use GetBalance with a RouteFilter that goes through routeFilterCostBasis(nil)mo.None().

The issue: in the ledger's query normalization and predicate building, mo.Some(nil) and mo.None() generate different SQL:

  • mo.Some(nil)CostBasisIsNil() predicate (explicit match for null cost basis)
  • mo.None() → no predicate at all (matches any cost basis)

The test suite currently never exercises the mo.Some(nil) path. Add a dedicated helper that explicitly constructs RouteFilter{CostBasis: mo.Some(nil)} and use it for at least one accrued or receivable assertion to cover this branch.

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

In `@test/credits/sanity_test.go` around lines 1003 - 1038, The current helpers
mustCustomerAccruedBalanceWithCostBasis and mustCustomerReceivableRouteBalance
call GetSubAccountForRoute with the raw cost-basis pointer and never exercise
the mo.Some(nil) path produced by routeFilterCostBasis, so add a new helper
(e.g., mustCustomerAccruedBalanceWithSomeNilCostBasis) that builds a RouteFilter
with CostBasis: mo.Some(nil) (not mo.None()) and uses
customerAccounts.AccruedAccount.GetBalance/GetSubAccountForRoute or the existing
mustCustomerReceivableRouteBalance flow to assert a balance so at least one test
exercises the mo.Some(nil) predicate branch; update one existing test to use
this new helper to cover the routeFilterCostBasis(nil) → mo.Some(nil) SQL path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/credits/sanity_test.go`:
- Around line 677-695: Add explicit assertions immediately after the advance
that verify the promo vs purchased accrued buckets were recorded under the
correct cost bases: compare flatFeeStart.promoAccrued to
s.mustCustomerAccruedBalanceWithCostBasis(cust.GetID(), USD, &promoCostBasis),
and compare flatFeeStart.externalAccrued to
s.mustCustomerAccruedBalanceWithCostBasis(cust.GetID(), USD,
&externalCostBasis); also assert the nil/unattributed accrued route
(s.mustCustomerAccruedBalanceWithCostBasis(cust.GetID(), USD, nil)) reflects the
expected remaining accrued (e.g., equals flatFeeStart.accrued minus
promo+external) so a swap between promo and purchased buckets would fail.

---

Outside diff comments:
In `@test/credits/sanity_test.go`:
- Around line 1003-1038: The current helpers
mustCustomerAccruedBalanceWithCostBasis and mustCustomerReceivableRouteBalance
call GetSubAccountForRoute with the raw cost-basis pointer and never exercise
the mo.Some(nil) path produced by routeFilterCostBasis, so add a new helper
(e.g., mustCustomerAccruedBalanceWithSomeNilCostBasis) that builds a RouteFilter
with CostBasis: mo.Some(nil) (not mo.None()) and uses
customerAccounts.AccruedAccount.GetBalance/GetSubAccountForRoute or the existing
mustCustomerReceivableRouteBalance flow to assert a balance so at least one test
exercises the mo.Some(nil) predicate branch; update one existing test to use
this new helper to cover the routeFilterCostBasis(nil) → mo.Some(nil) SQL path.
🪄 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: 913f237d-c290-4155-b6d5-789e67b7ff15

📥 Commits

Reviewing files that changed from the base of the PR and between 79448ba and b4e8796.

📒 Files selected for processing (1)
  • test/credits/sanity_test.go

Comment on lines +677 to +695
assertDelta("promo FBO after credit-only advance", flatFeeStart.promoFBO, alpacadecimal.NewFromInt(-30), s.mustCustomerFBOBalance(cust.GetID(), USD, &promoCostBasis))
assertDelta("external FBO after credit-only advance", flatFeeStart.externalFBO, alpacadecimal.NewFromInt(-50), s.mustCustomerFBOBalance(cust.GetID(), USD, &externalCostBasis))
assertDelta("unknown FBO after credit-only advance", flatFeeStart.unknownFBO, alpacadecimal.Zero, s.mustCustomerFBOBalance(cust.GetID(), USD, nil))
assertDelta("promo receivable after credit-only advance", flatFeeStart.promoReceivable, alpacadecimal.Zero, s.mustCustomerReceivableBalance(cust.GetID(), USD, &promoCostBasis))
assertDelta("external receivable after credit-only advance", flatFeeStart.externalReceivable, alpacadecimal.Zero, s.mustCustomerReceivableBalance(cust.GetID(), USD, &externalCostBasis))
assertDelta("total open receivable after credit-only advance", flatFeeStart.totalOpenReceivable, alpacadecimal.NewFromInt(-20), s.mustCustomerReceivableBalance(cust.GetID(), USD, nil))
assertDelta("authorized receivable after credit-only advance", flatFeeStart.authorizedReceivable, alpacadecimal.Zero, s.mustCustomerAuthorizedReceivableBalance(cust.GetID(), USD, nil))
s.True(
s.mustCustomerReceivableRouteBalance(cust.GetID(), USD, nil, ledger.TransactionAuthorizationStatusOpen).Equal(alpacadecimal.NewFromInt(-20)),
"the uncovered credit_only shortfall should live in the exact open advance receivable route",
)
s.True(
s.mustCustomerAccruedBalanceWithCostBasis(cust.GetID(), USD, nil).Equal(alpacadecimal.NewFromInt(20)),
"the uncovered shortfall should also remain in unattributed accrued until a later purchase backfills it",
)
assertDelta("accrued after credit-only advance", flatFeeStart.accrued, alpacadecimal.NewFromInt(100), s.mustCustomerAccruedBalance(cust.GetID(), USD))
assertDelta("total wash after credit-only advance", flatFeeStart.totalWash, alpacadecimal.Zero, s.mustWashBalance(ns, USD, nil))
assertDelta("external wash after credit-only advance", flatFeeStart.externalWash, alpacadecimal.Zero, s.mustWashBalance(ns, USD, &externalCostBasis))
assertDelta("earnings after credit-only advance", flatFeeStart.earnings, alpacadecimal.Zero, s.mustEarningsBalance(ns, USD))
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

Assert the promo vs purchased accrued buckets right after advance.

This block checks the nil-cost-basis shortfall, but it never proves that the original 30 promo credits and 50 purchased credits were accrued under the right non-nil routes. A swap between those two buckets would still pass, because start.externalAccrued is captured later from whatever state advance produced.

💡 Suggested assertions
+		s.True(
+			s.mustCustomerAccruedBalanceWithCostBasis(cust.GetID(), USD, &promoCostBasis).Equal(alpacadecimal.NewFromInt(30)),
+			"promotional credits should stay in the zero cost-basis accrued bucket",
+		)
+		s.True(
+			s.mustCustomerAccruedBalanceWithCostBasis(cust.GetID(), USD, &externalCostBasis).Equal(alpacadecimal.NewFromInt(50)),
+			"purchased credits should stay in the purchased cost-basis accrued bucket",
+		)
 		s.True(
 			s.mustCustomerAccruedBalanceWithCostBasis(cust.GetID(), USD, nil).Equal(alpacadecimal.NewFromInt(20)),
 			"the uncovered shortfall should also remain in unattributed accrued until a later purchase backfills it",
 		)

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

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

In `@test/credits/sanity_test.go` around lines 677 - 695, Add explicit assertions
immediately after the advance that verify the promo vs purchased accrued buckets
were recorded under the correct cost bases: compare flatFeeStart.promoAccrued to
s.mustCustomerAccruedBalanceWithCostBasis(cust.GetID(), USD, &promoCostBasis),
and compare flatFeeStart.externalAccrued to
s.mustCustomerAccruedBalanceWithCostBasis(cust.GetID(), USD,
&externalCostBasis); also assert the nil/unattributed accrued route
(s.mustCustomerAccruedBalanceWithCostBasis(cust.GetID(), USD, nil)) reflects the
expected remaining accrued (e.g., equals flatFeeStart.accrued minus
promo+external) so a swap between promo and purchased buckets would fail.


// TranslateCustomerFBOCostBasisTemplate moves customer FBO value between cost-basis buckets
// while keeping the same priority bucket.
type TranslateCustomerFBOCostBasisTemplate struct {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: this is unused

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

thanks, will clean up in a sec

turip
turip previously approved these changes Mar 31, 2026
@turip turip enabled auto-merge (squash) March 31, 2026 13:15
@turip turip merged commit 6a2c190 into main Mar 31, 2026
22 of 24 checks passed
@turip turip deleted the feat/negative-credit branch March 31, 2026 13:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-note/misc Miscellaneous changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants