Skip to content

feat: round currencies to smallest denominator#4063

Merged
turip merged 3 commits intomainfrom
feat/charges-currency-rounding
Apr 2, 2026
Merged

feat: round currencies to smallest denominator#4063
turip merged 3 commits intomainfrom
feat/charges-currency-rounding

Conversation

@turip
Copy link
Copy Markdown
Member

@turip turip commented Apr 2, 2026

Summary

This change makes currency normalization a charge-domain responsibility across the charge lifecycle.

It ensures that:

  • charge inputs are rounded with the currency calculator at the appropriate charge-layer boundaries
  • ledger-facing charge handlers receive already-normalized amounts
  • ledger-derived allocation/correction outputs are normalized in charges before persistence
  • correction flows allow zero-valued corrections after rounding and treat them as no-ops

What Changed

  • Normalized credit purchase amounts during charge creation.
  • Kept flat-fee proration amounts rounded at calculation time, so AmountAfterProration is persisted as already-normalized state.
  • Normalized charge handler allocation outputs in charge services before storing credit realizations.
  • Centralized correction-output normalization in shared creditrealization Correct / CorrectAll helpers.
  • Relaxed correction validation to allow zero-valued corrections after rounding.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added normalization capabilities to ensure consistent currency precision across all charge operations (credit purchases, flat fees, and usage-based charges).
  • Bug Fixes

    • Fixed currency rounding inconsistencies in credit allocation, invoice assignments, and final realization calculations.
    • Zero-valued corrections are now permitted and treated as no-ops instead of returning errors.
  • Documentation

    • Added guidelines clarifying currency rounding responsibilities throughout the charge lifecycle.

@turip turip requested a review from a team as a code owner April 2, 2026 10:33
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 2, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 33391c02-5372-443f-ba80-74d0f50d4d53

📥 Commits

Reviewing files that changed from the base of the PR and between bd12e89 and f86e78e.

📒 Files selected for processing (5)
  • .agents/skills/charges/SKILL.md
  • openmeter/billing/charges/flatfee/service/creditsonly.go
  • openmeter/billing/charges/flatfee/service/invoice.go
  • openmeter/billing/charges/models/creditrealization/correction_test.go
  • openmeter/billing/charges/usagebased/service/creditsonly.go
✅ Files skipped from review due to trivial changes (2)
  • openmeter/billing/charges/usagebased/service/creditsonly.go
  • .agents/skills/charges/SKILL.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • openmeter/billing/charges/flatfee/service/invoice.go
  • openmeter/billing/charges/models/creditrealization/correction_test.go
  • openmeter/billing/charges/flatfee/service/creditsonly.go

📝 Walkthrough

Walkthrough

This PR adds currency normalization (rounding) throughout the billing charges domain. It introduces Normalized() methods on intent types and normalization helpers on correction models, then applies rounding at key entry and persistence boundaries—intent creation, charge service operations, and credit realization flows. The changes ensure all monetary values are rounded to their currency's precision by the appropriate component, preventing downstream rounding inconsistencies.

Changes

Cohort / File(s) Summary
Documentation
.agents/skills/charges/SKILL.md
Added "Currency Normalization" section documenting rounding responsibilities, specifying component boundaries where normalization should occur, and clarifying that zero-valued corrections after rounding are treated as no-ops rather than errors.
Intent Normalization
openmeter/billing/charges/creditpurchase/chargecreditpurchase.go, openmeter/billing/charges/flatfee/charge.go
Added Normalized() method to Intent types that rounds embedded CreditAmount and AmountBeforeProration to currency precision via currency calculator.
Credit Purchase Service
openmeter/billing/charges/creditpurchase/service/create.go
Normalizes input intent before validation and rounds computed totalCost to currency precision when building invoice gathering lines.
Flat Fee Service
openmeter/billing/charges/flatfee/service/creditsonly.go, openmeter/billing/charges/flatfee/service/invoice.go
Rounds AmountAfterProration to currency precision in credit allocation and invoice-assignment flows, with explicit error handling for calculator creation.
Credit Realization Corrections
openmeter/billing/charges/models/creditrealization/correction.go, openmeter/billing/charges/models/creditrealization/models.go, openmeter/billing/charges/models/creditrealization/realizations.go, openmeter/billing/charges/models/creditrealization/correction_test.go
Changed validation to allow zero amounts after rounding (instead of strictly rejecting non-negative), added NormalizeWith() helpers across correction types, updated Correct flow to normalize returned corrections before validation, and refreshed tests to reflect zero-amount acceptance.
Usage-based Service
openmeter/billing/charges/usagebased/service/creditsonly.go
Rounds multiple amount fields (AmountToAllocate, Totals, additionalAmount) to currency precision in credits-only state machine methods before validation and persistence.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Suggested labels

release-note/feature, area/billing

Suggested reviewers

  • GAlexIHU
  • tothandras
🚥 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: round currencies to smallest denominator' accurately captures the primary change—adding currency normalization/rounding throughout the charge lifecycle.

✏️ 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/charges-currency-rounding

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.

@turip turip requested a review from tothandras April 2, 2026 10:36
@turip turip added the release-note/bug-fix Release note: Bug Fixes label Apr 2, 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

Caution

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

⚠️ Outside diff range comments (1)
openmeter/billing/charges/flatfee/service/invoice.go (1)

37-42: ⚠️ Potential issue | 🟠 Major

Re-check the rounded total before persisting these allocations.

Unlike openmeter/billing/charges/flatfee/service/creditsonly.go, this path normalizes the handler output and then stores it without verifying that the rounded allocations still add up to PreTaxTotalAmount. With the new per-row rounding, a one-cent drift can now get persisted silently.

💡 Suggested guard
 		creditAllocations = creditAllocations.NormalizeWith(currencyCalculator)
+		if !creditAllocations.Sum().Equal(input.PreTaxTotalAmount) {
+			return nil, fmt.Errorf(
+				"credit allocations do not match total [charge_id=%s, total=%s, allocations_sum=%s]",
+				charge.ID, input.PreTaxTotalAmount.String(), creditAllocations.Sum().String(),
+			)
+		}
 
 		if len(creditAllocations) == 0 {
 			return nil, nil
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/flatfee/service/invoice.go` around lines 37 - 42,
After calling s.handler.OnAssignedToInvoice and normalizing via
creditAllocations.NormalizeWith(currencyCalculator), re-calc the rounded sum of
creditAllocations (using the same rounding/currency rules) and compare it to the
invoice PreTaxTotalAmount before persisting; if there is a cent-level drift,
adjust (e.g., distribute the rounding remainder or return an error) so the
persisted allocations exactly equal PreTaxTotalAmount. Ensure you reference the
handler method OnAssignedToInvoice, the variable creditAllocations, the
NormalizeWith(currencyCalculator) call, and the invoice PreTaxTotalAmount when
adding this guard.
🤖 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/billing/charges/models/creditrealization/correction_test.go`:
- Around line 317-329: Add a new subtest in correction_test.go that verifies
tiny negatives that round to zero are treated as a no-op: use
newAllocationBuilder() to build the allocation, call
Realizations{alloc}.CreateCorrectionRequest with
alpacadecimal.NewFromFloat(-0.004) and testCurrency(t), then require no error
and assert the returned slice is length 0 (no corrections). Place it alongside
the existing "amount is rounded to currency precision before planning" test to
lock in the "zero after rounding" contract.

In `@openmeter/billing/charges/models/creditrealization/realizations.go`:
- Around line 53-60: The code rounds the aggregate correction target but
compares it against unrounded stored realization balances, causing
ErrInsufficientFunds for pre-PR rows with sub-cent values; update CorrectAll
(and any logic that uses RemainingAmount) to normalize stored realization
balances by applying currency.RoundToPrecision to each per-allocation
RemainingAmount before aggregation and before any comparison, or alternatively
perform per-allocation rounding when building the aggregate target so that
comparisons use consistently rounded values; adjust any places that read
RemainingAmount to use the rounded value so the CorrectionRequest/CorrectAll
flow uses normalized balances.

---

Outside diff comments:
In `@openmeter/billing/charges/flatfee/service/invoice.go`:
- Around line 37-42: After calling s.handler.OnAssignedToInvoice and normalizing
via creditAllocations.NormalizeWith(currencyCalculator), re-calc the rounded sum
of creditAllocations (using the same rounding/currency rules) and compare it to
the invoice PreTaxTotalAmount before persisting; if there is a cent-level drift,
adjust (e.g., distribute the rounding remainder or return an error) so the
persisted allocations exactly equal PreTaxTotalAmount. Ensure you reference the
handler method OnAssignedToInvoice, the variable creditAllocations, the
NormalizeWith(currencyCalculator) call, and the invoice PreTaxTotalAmount when
adding this guard.
🪄 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: 22a43b5a-155b-4886-9717-d3a7e56a9e46

📥 Commits

Reviewing files that changed from the base of the PR and between 32b5260 and bd12e89.

📒 Files selected for processing (12)
  • .agents/skills/charges/SKILL.md
  • openmeter/billing/charges/creditpurchase/chargecreditpurchase.go
  • openmeter/billing/charges/creditpurchase/service/create.go
  • openmeter/billing/charges/flatfee/charge.go
  • openmeter/billing/charges/flatfee/service/creditsonly.go
  • openmeter/billing/charges/flatfee/service/invoice.go
  • openmeter/billing/charges/models/creditrealization/allocation.go
  • openmeter/billing/charges/models/creditrealization/correction.go
  • openmeter/billing/charges/models/creditrealization/correction_test.go
  • openmeter/billing/charges/models/creditrealization/models.go
  • openmeter/billing/charges/models/creditrealization/realizations.go
  • openmeter/billing/charges/usagebased/service/creditsonly.go

Comment thread openmeter/billing/charges/models/creditrealization/allocation.go Outdated
Comment thread openmeter/billing/charges/models/creditrealization/realizations.go
@turip turip merged commit 6cb7b31 into main Apr 2, 2026
36 of 37 checks passed
@turip turip deleted the feat/charges-currency-rounding branch April 2, 2026 11:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-note/bug-fix Release note: Bug Fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants