Skip to content

feat(billing): add period preserving usage rating#4299

Merged
turip merged 3 commits into
mainfrom
split/rating-period-preserving
May 8, 2026
Merged

feat(billing): add period preserving usage rating#4299
turip merged 3 commits into
mainfrom
split/rating-period-preserving

Conversation

@turip
Copy link
Copy Markdown
Member

@turip turip commented May 6, 2026

Stacked PR 3/3. Adds the period-preserving rating engine and its service dispatch/tests on top of the delta engine. The production preferred engine remains delta.

This incomplete engine should be only merged, to make sure that the tests are there, so any billingrating changes are tested against this algorithm.

Summary by CodeRabbit

  • New Features

    • Added a period-preserving rating mode for usage-based billing to keep corrections attributed to original service periods, preserve service-period boundaries, and avoid double-charging while handling late usage and repricing.
  • Documentation

    • Added comprehensive README explaining the period-preserving algorithm, validation rules, epoch flow, and worked examples.
  • Tests

    • Added extensive unit tests covering multi-period late events, repricing, discounts, minimum commitments, credits, and invalid-input scenarios.

@turip turip requested a review from a team as a code owner May 6, 2026 10:11
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 6, 2026

Review Change Stack
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: b4052b83-6f08-4fdc-b0e3-e4bed351eaf2

📥 Commits

Reviewing files that changed from the base of the PR and between ee8de94 and c825d85.

📒 Files selected for processing (2)
  • openmeter/billing/charges/usagebased/service/rating/periodpreserving/engine.go
  • openmeter/billing/charges/usagebased/service/rating/periodpreserving/engine_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • openmeter/billing/charges/usagebased/service/rating/periodpreserving/engine_test.go
  • openmeter/billing/charges/usagebased/service/rating/periodpreserving/engine.go

📝 Walkthrough

Walkthrough

Adds a Period-Preserving rating engine and routes GetDetailedRatingForUsage to it; the engine validates multi-period inputs, runs epoch-by-epoch rating preserving original service-period attribution, subtracts prior and already-billed lines, restamps and orders detailed lines, and includes README plus extensive tests.

Changes

Period-Preserving Rating Engine

Layer / File(s) Summary
Routing & details helper
openmeter/billing/charges/usagebased/service/rating/details.go
Adds imports, a PeriodPreserving branch in GetDetailedRatingForUsage, ratePeriodPreservingDetails and its input struct that assemble prior-period snapshots and call the rater.
Service wiring & integration test
openmeter/billing/charges/usagebased/service/rating/service.go, .../service_test.go
Service now constructs and stores both deltaRater and periodPreservingRater, adds GetPreferredRatingEngineFor(), and includes a test verifying PeriodPreserving outputs and CorrectsRunID.
Engine types & validation
openmeter/billing/charges/usagebased/service/rating/periodpreserving/engine.go
Adds Engine, New, public Input/CurrentPeriod/PriorPeriod/Result, Input.Validate(), and stable prior-period sorting.
Core epoch rating
openmeter/billing/charges/usagebased/service/rating/periodpreserving/engine.go
Engine.Rate() and buildDetailsByEpoch() perform per-epoch rating via injected rating service, subtract earlier epochs and already-billed lines, and stamp corrections with prior run IDs.
Flattening & ordering
openmeter/billing/charges/usagebased/service/rating/periodpreserving/engine.go
flattenDetailedLinesByEpoch() and compareEpochClosedPeriod() deterministically order epoch buckets, attach child unique reference IDs, sort, and reindex detailed lines.
Unique Reference ID Helpers
openmeter/billing/charges/usagebased/service/rating/periodpreserving/uniquereferenceid.go
Adds unexported generators to produce/format child-unique-reference IDs and booked-correction IDs with validation.
README & Examples
openmeter/billing/charges/usagebased/service/rating/periodpreserving/README.md
Documents algorithm, inputs, validation constraints, epoch flow, child-ID stamping rules, examples (late unit usage, volume repricing), warnings, and TODOs.
Tests & Helpers
openmeter/billing/charges/usagebased/service/rating/periodpreserving/engine_test.go
Extensive multi-phase tests for late events (unit-price, credits, volume tiers), negative validation tests, test-runner and helper utilities.

Sequence Diagram

sequenceDiagram
  participant Client
  participant Service
  participant DetailsHelper
  participant PeriodPreservingEngine
  participant RatingService
  participant BookedLinesStore
  Client->>Service: GetDetailedRatingForUsage(intent, mode=PeriodPreserving)
  Service->>DetailsHelper: prepare prior/current periods + booked lines
  DetailsHelper->>PeriodPreservingEngine: Rate(Input)
  PeriodPreservingEngine->>RatingService: rate epoch inputs (per epoch)
  RatingService-->>PeriodPreservingEngine: epoch detailed lines
  PeriodPreservingEngine->>BookedLinesStore: fetch prior booked lines
  PeriodPreservingEngine->>PeriodPreservingEngine: subtract prior/epoch lines, stamp corrections
  PeriodPreservingEngine-->>Service: DetailedLines + Totals
  Service-->>Client: return combined detailed lines and totals
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

release-note/feature

Suggested reviewers

  • tothandras
  • GAlexIHU
  • chrisgacsal
🚥 Pre-merge checks | ✅ 4 | ❌ 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 (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(billing): add period preserving usage rating' directly and accurately summarizes the main change—introducing a new period-preserving rating engine for usage-based billing.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch split/rating-period-preserving

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.

🧹 Nitpick comments (2)
openmeter/billing/charges/usagebased/service/rating/periodpreserving/engine_test.go (1)

1233-1296: 💤 Low value

Tiny note on the phased harness — purely optional. 💭

runLateEventRatingTestCase registers all phase subtests inside the same outer test via t.Run, and because the inner subtests don't call t.Parallel(), they run sequentially and the shared bookedDetailedLinesByPhase / phaseRunIDs stay safe. That's the intended design. If a future contributor ever sprinkles t.Parallel() inside the inner t.Run, the shared mutable state would silently break under -race. A one-line comment near the loop noting "phases must run sequentially because each phase consumes the previous phase's booked detailed lines" would prevent that footgun without changing behavior.

🤖 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/charges/usagebased/service/rating/periodpreserving/engine_test.go`
around lines 1233 - 1296, The phased test harness runLateEventRatingTestCase
uses shared mutable state (bookedDetailedLinesByPhase and phaseRunIDs) across
subtests and relies on them running sequentially; add a concise one-line comment
above the for loop that iterates tc.phases explaining that subtests must not
call t.Parallel() because each phase consumes prior phases' bookedDetailedLines
and phaseRunIDs (reference runLateEventRatingTestCase,
bookedDetailedLinesByPhase, phaseRunIDs, and t.Run/t.Parallel) to prevent future
contributors from making the inner subtests parallel and breaking the harness
under -race.
openmeter/billing/charges/usagebased/service/rating/periodpreserving/engine.go (1)

140-157: 💤 Low value

Heads-up on the second-granularity assumption. ⏱️

epochClosedPeriod stores From/To as Unix() seconds and AsClosedPeriod reconstructs them via time.Unix(e.From, 0).In(time.UTC). That round-trip silently drops sub-second precision and TZ info, and it's also what stamps line.ServicePeriod in the rated output (via NewDetailedLinesFromBilling). In practice this is fine because Validate() requires prior periods to be non-empty when truncated to streaming.MinimumWindowSizeDuration, and production periods are second-aligned anyway — so the Unix-second key is consistent across the input bucket and the stamped output bucket.

The footgun is for any future caller that hands in a priorPeriod.ServicePeriod with sub-second precision (say 12:00:00.500..12:00:02.000): the validation would still pass, but the stamped line period and the runIDByServicePeriod lookup would both use [12:00:00..12:00:02], quietly losing the .500. Either:

  • a one-line comment near epochClosedPeriod explicitly stating "service periods are normalized to second precision; sub-second input is intentionally dropped", or
  • using UnixNano() for symmetric precision,

would prevent future surprises. Not blocking — this is a defensive nudge.

🤖 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/charges/usagebased/service/rating/periodpreserving/engine.go`
around lines 140 - 157, epochClosedPeriod currently stores From/To as seconds
via Unix(), and AsClosedPeriod/closedPeriodToEpochClosedPeriod round-trip using
time.Unix(...,0), which silently drops sub-second precision and TZ info; to fix,
either (A) update epochClosedPeriod.From/To to be nanoseconds (use time.Unix(0,
ns) in AsClosedPeriod and period.From.UnixNano()/UnixNano() in
closedPeriodToEpochClosedPeriod) so sub-second precision is preserved, or (B)
add a clear one-line comment on epochClosedPeriod stating that service periods
are intentionally normalized to second precision and sub-second input will be
dropped; modify the functions epochClosedPeriod.AsClosedPeriod and
closedPeriodToEpochClosedPeriod accordingly to match the chosen approach.
🤖 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/billing/charges/usagebased/service/rating/periodpreserving/engine_test.go`:
- Around line 1233-1296: The phased test harness runLateEventRatingTestCase uses
shared mutable state (bookedDetailedLinesByPhase and phaseRunIDs) across
subtests and relies on them running sequentially; add a concise one-line comment
above the for loop that iterates tc.phases explaining that subtests must not
call t.Parallel() because each phase consumes prior phases' bookedDetailedLines
and phaseRunIDs (reference runLateEventRatingTestCase,
bookedDetailedLinesByPhase, phaseRunIDs, and t.Run/t.Parallel) to prevent future
contributors from making the inner subtests parallel and breaking the harness
under -race.

In
`@openmeter/billing/charges/usagebased/service/rating/periodpreserving/engine.go`:
- Around line 140-157: epochClosedPeriod currently stores From/To as seconds via
Unix(), and AsClosedPeriod/closedPeriodToEpochClosedPeriod round-trip using
time.Unix(...,0), which silently drops sub-second precision and TZ info; to fix,
either (A) update epochClosedPeriod.From/To to be nanoseconds (use time.Unix(0,
ns) in AsClosedPeriod and period.From.UnixNano()/UnixNano() in
closedPeriodToEpochClosedPeriod) so sub-second precision is preserved, or (B)
add a clear one-line comment on epochClosedPeriod stating that service periods
are intentionally normalized to second precision and sub-second input will be
dropped; modify the functions epochClosedPeriod.AsClosedPeriod and
closedPeriodToEpochClosedPeriod accordingly to match the chosen approach.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 941bde20-ccda-480d-b664-db7cce0f50d6

📥 Commits

Reviewing files that changed from the base of the PR and between ac171e3 and ea108e9.

📒 Files selected for processing (7)
  • openmeter/billing/charges/usagebased/service/rating/details.go
  • openmeter/billing/charges/usagebased/service/rating/periodpreserving/README.md
  • openmeter/billing/charges/usagebased/service/rating/periodpreserving/engine.go
  • openmeter/billing/charges/usagebased/service/rating/periodpreserving/engine_test.go
  • openmeter/billing/charges/usagebased/service/rating/periodpreserving/uniquereferenceid.go
  • openmeter/billing/charges/usagebased/service/rating/service.go
  • openmeter/billing/charges/usagebased/service/rating/service_test.go

@turip turip force-pushed the split/rating-delta branch 2 times, most recently from d473363 to fa7912c Compare May 6, 2026 15:39
Base automatically changed from split/rating-delta to main May 8, 2026 14:27
@turip turip force-pushed the split/rating-period-preserving branch from ea108e9 to 44c8bc6 Compare May 8, 2026 14:45
@turip turip added release-note/ignore Ignore this change when generating release notes area/billing labels May 8, 2026
@turip turip enabled auto-merge (squash) May 8, 2026 15:54
@turip turip merged commit cf0bae8 into main May 8, 2026
25 checks passed
@turip turip deleted the split/rating-period-preserving branch May 8, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/billing 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