Skip to content

chore: normalize charge timestamps#4060

Merged
turip merged 3 commits intomainfrom
chore/normalize-charges-timestamps
Apr 2, 2026
Merged

chore: normalize charge timestamps#4060
turip merged 3 commits intomainfrom
chore/normalize-charges-timestamps

Conversation

@turip
Copy link
Copy Markdown
Member

@turip turip commented Apr 2, 2026

Overview

This change introduces centralized timestamp normalization for charge persistence by truncating persisted intent, state, and realization-run timestamps to streaming.MinimumWindowSizeDuration through shared helpers in openmeter/billing/charges/meta.

Billing already had this for flat fees and usage based lines, so this is feature parity.

Normalization is now applied at the domain layer before validation and calculation, and at persistence write sites for fields like InvoiceAt, AdvanceAfter, AsOf, and CollectionEnd, so stored values are consistently second-aligned.

Shrink, extend, and delete patches were tightened up by adding constructors, making patch payload fields private, and introducing validated input structs plus accessors for shrink and extend patch data.

The temporary shrink/extend remap and the subscription sync reconciler were updated to use the new patch constructors so normalized patch timestamps are created at construction time instead of being assembled ad hoc.

Notes for reviewer

Summary by CodeRabbit

  • New Features

    • Consistent timestamp normalization applied across flat-fee and usage-based charge flows; normalization helpers and intent/state Normalized() methods added.
    • Validation constructors for patch operations (shrink, extend, delete) introduced.
  • Bug Fixes

    • Prevented sub-second precision from affecting billing/proration, persisted timestamps, and state transitions.
  • Tests

    • Added tests verifying timestamp truncation across create, advance, and patch remap paths.
  • Documentation

    • Expanded charges skill docs with timestamp normalization guidance and testing guidance.

@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: bcb21ddb-a015-462c-a0e2-5956b31d22b2

📥 Commits

Reviewing files that changed from the base of the PR and between 14e427e and ab72d07.

📒 Files selected for processing (1)
  • openmeter/billing/charges/meta/timestamps.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • openmeter/billing/charges/meta/timestamps.go

📝 Walkthrough

Walkthrough

Systematic timestamp normalization was added across the charges subsystem: new normalization helpers, Intent/State Normalized() methods, normalization at service/adapter/persistence boundaries, input constructors and getters for patch types, and tests verifying sub-second truncation behavior.

Changes

Cohort / File(s) Summary
Docs & core utils
\.agents/skills/charges/SKILL.md, openmeter/billing/charges/meta/timestamps.go
Documentation expanded with timestamp normalization guidance. New helpers NormalizeTimestamp, NormalizeOptionalTimestamp, NormalizeClosedPeriod, and Intent.Normalized() added to enforce UTC + truncation to streaming.MinimumWindowSizeDuration.
Charge type Normalization
openmeter/billing/charges/flatfee/charge.go, openmeter/billing/charges/usagebased/charge.go, openmeter/billing/charges/usagebased/realizationrun.go
Added Normalized() methods to Intent and State (flat-fee and usage-based) and normalization helpers for realization-run types to normalize InvoiceAt, AdvanceAfter, AsOf, and CollectionEnd.
Service-layer application
openmeter/billing/charges/flatfee/service/create.go, openmeter/billing/charges/flatfee/service/creditsonly.go, openmeter/billing/charges/usagebased/service/create.go, openmeter/billing/charges/usagebased/service/creditsonly.go, openmeter/billing/charges/usagebased/service/statemachine.go, openmeter/billing/charges/service/patchtmp.go
Intents are normalized early in create flows; computed/derived timestamps (proration inputs, advance-after, collection period calculations) are normalized before assignment/return. Patch remapping now normalizes patched intents and uses patch constructors/getters.
Adapter & persistence boundaries
openmeter/billing/charges/flatfee/adapter/charge.go, openmeter/billing/charges/usagebased/adapter/charge.go, openmeter/billing/charges/usagebased/adapter/realizationrun.go, openmeter/billing/charges/models/chargemeta/mixin.go
Adapters and shared write helpers normalize timestamps (using NormalizeTimestamp / NormalizeOptionalTimestamp) before setting fields or persisting (e.g., InvoiceAt, AdvanceAfter, AsOf, CollectionEnd).
Meta patch refactor & constructors
openmeter/billing/charges/meta/patchdelete.go, openmeter/billing/charges/meta/patchextend.go, openmeter/billing/charges/meta/patchshrink.go
Made several patch internals private, added constructors and input types (NewPatchExtendInput, NewPatchShrinkInput), added setters/getters that normalize timestamps on assignment, and updated validation to use getters. PatchDelete now uses an unexported policy with NewPatchDelete/accessors.
Tmp patch application & reconciler
openmeter/billing/charges/service/patchtmp.go, openmeter/billing/worker/subscriptionsync/service/reconciler/patchcharge.go
Switched to new patch constructors (NewPatchDelete, NewPatchShrink, NewPatchExtend) and replaced direct field access with getter methods; patched intents are normalized before creating replacement intents.
Tests
openmeter/billing/charges/service/truncation_test.go
Added ChargeTimestampTruncationTestSuite with three tests asserting sub-second inputs are truncated to second boundaries across creation, advancement, and tmp-patch remap flows.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • tothandras
  • GAlexIHU
🚥 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 clearly and directly summarizes the main change: adding timestamp normalization for charge persistence across the codebase.

✏️ 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 chore/normalize-charges-timestamps

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 added release-note/misc Miscellaneous changes area/billing labels Apr 2, 2026
@turip turip marked this pull request as ready for review April 2, 2026 07:17
@turip turip requested a review from a team as a code owner April 2, 2026 07:17
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.

Caution

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

⚠️ Outside diff range comments (3)
openmeter/billing/charges/usagebased/charge.go (1)

137-155: ⚠️ Potential issue | 🟡 Minor

Reject non-nil zero AdvanceAfter values.

NormalizeOptionalTimestamp keeps &time.Time{} as a non-nil zero timestamp, and this state still validates successfully. Flat-fee already guards the same shape, so usage-based can quietly carry an invalid AdvanceAfter.

💡 Suggested fix
 func (s State) Validate() error {
 	var errs []error
 
 	if s.CurrentRealizationRunID != nil && *s.CurrentRealizationRunID == "" {
 		errs = append(errs, fmt.Errorf("current realization run ID must be non-empty"))
 	}
+
+	if s.AdvanceAfter != nil && s.AdvanceAfter.IsZero() {
+		errs = append(errs, fmt.Errorf("advance after is required"))
+	}
 
 	return errors.Join(errs...)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/usagebased/charge.go` around lines 137 - 155,
State.Normalized currently calls meta.NormalizeOptionalTimestamp but that helper
leaves a non-nil &time.Time{} as a valid pointer; update normalization and/or
validation so zero timestamps are treated as nil and rejected: in
State.Normalized (and/or in Validate) ensure s.AdvanceAfter is set to nil when
it is zero (time.Time.IsZero()), and in State.Validate add a check that if
s.AdvanceAfter != nil and s.AdvanceAfter.IsZero() then return an error (or
append to errs) stating advanceAfter must be a non-zero timestamp; reference
functions/fields: State, Normalized(), Validate(), AdvanceAfter, and
meta.NormalizeOptionalTimestamp.
openmeter/billing/charges/meta/patchextend.go (1)

104-123: ⚠️ Potential issue | 🟠 Major

Normalize intent before these extend comparisons.

The new patch values are already truncated in the constructor, but ValidateWith still compares them against the raw intent periods. During the rollout, a pre-existing charge with sub-second ...To values can fail a same-second extend here after truncation. Normalizing intent up front keeps the comparison stable.

Worth mirroring the same first line in PatchShrink.ValidateWith, too.

💡 Suggested fix
 func (p PatchExtend) ValidateWith(intent Intent) error {
+	intent = intent.Normalized()
+
 	var errs []error
 
 	if err := p.Validate(); err != nil {
 		errs = append(errs, err)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/meta/patchextend.go` around lines 104 - 123,
ValidateWith on PatchExtend compares truncated new times against the raw intent,
causing false failures; before the comparisons in PatchExtend.ValidateWith you
should normalize/truncate the intent's time fields (ServicePeriod.To,
FullServicePeriod.To, BillingPeriod.To) to the same granularity used in the
constructor (e.g., truncate to seconds) so comparisons with
GetNewServicePeriodTo, GetNewFullServicePeriodTo, and GetNewBillingPeriodTo are
consistent; apply the same initial normalization line in
PatchShrink.ValidateWith to mirror behavior.
openmeter/billing/charges/usagebased/service/creditsonly.go (1)

175-186: ⚠️ Potential issue | 🟠 Major

Normalization is shifting the usage query cutoff and can drop events.

Line 175 and Line 276 now normalize storedAtOffset before calling getRatingForUsage. Since the query uses an exclusive < StoredAtOffset filter, this can exclude events between the raw timestamp and the truncated timestamp.

Suggested fix (keep raw cutoff for query, keep normalized for persistence)
- storedAtOffset := meta.NormalizeTimestamp(clock.Now().Add(-usagebased.InternalCollectionPeriod))
+ rawStoredAtOffset := clock.Now().Add(-usagebased.InternalCollectionPeriod)
+ storedAtOffset := meta.NormalizeTimestamp(rawStoredAtOffset)

  ratingResult, err := s.Service.getRatingForUsage(ctx, getRatingForUsageInput{
      Charge:         s.Charge,
      Customer:       s.CustomerOverride,
      FeatureMeter:   s.FeatureMeter,
-     StoredAtOffset: storedAtOffset,
+     StoredAtOffset: rawStoredAtOffset,
  })

Apply the same pattern in FinalizeRealizationRun as well.

Also applies to: 276-283

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

In `@openmeter/billing/charges/usagebased/service/creditsonly.go` around lines 175
- 186, The code normalizes storedAtOffset before calling
s.Service.getRatingForUsage which uses an exclusive "< StoredAtOffset" filter
and may drop events; change to compute rawStoredAt :=
clock.Now().Add(-usagebased.InternalCollectionPeriod), then compute
normalizedStoredAt := meta.NormalizeTimestamp(rawStoredAt) and pass rawStoredAt
to getRatingForUsage via getRatingForUsageInput.StoredAtOffset while using
normalizedStoredAt only for persistence/metadata (e.g., any fields or stores
that require truncated timestamps). Apply the same change in
FinalizeRealizationRun (the block around lines 276-283) so the query cutoff uses
the raw timestamp and normalization is only used for persisted offsets.
🧹 Nitpick comments (1)
openmeter/billing/charges/flatfee/adapter/charge.go (1)

47-47: Redundant .In(time.UTC) call after NormalizeTimestamp.

meta.NormalizeTimestamp(...) already returns a UTC time, so the .In(time.UTC) conversion is unnecessary. Same applies to line 227. Not a bug, just a bit of extra work.

✨ Optional cleanup
-			SetInvoiceAt(meta.NormalizeTimestamp(intent.InvoiceAt).In(time.UTC)).
+			SetInvoiceAt(meta.NormalizeTimestamp(intent.InvoiceAt)).

And at line 227:

-		SetInvoiceAt(meta.NormalizeTimestamp(intent.InvoiceAt).In(time.UTC)).
+		SetInvoiceAt(meta.NormalizeTimestamp(intent.InvoiceAt)).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/flatfee/adapter/charge.go` at line 47, The call to
.In(time.UTC) after meta.NormalizeTimestamp(...) is redundant because
NormalizeTimestamp already returns a UTC time; remove the .In(time.UTC) chaining
wherever it appears (e.g., the SetInvoiceAt(...) call and the second occurrence
around the other timestamp at the same file) so the code simply uses
meta.NormalizeTimestamp(...) directly; look for usages of SetInvoiceAt and any
other calls that currently do meta.NormalizeTimestamp(...).In(time.UTC) and drop
the .In(time.UTC) suffix.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@openmeter/billing/charges/meta/patchextend.go`:
- Around line 104-123: ValidateWith on PatchExtend compares truncated new times
against the raw intent, causing false failures; before the comparisons in
PatchExtend.ValidateWith you should normalize/truncate the intent's time fields
(ServicePeriod.To, FullServicePeriod.To, BillingPeriod.To) to the same
granularity used in the constructor (e.g., truncate to seconds) so comparisons
with GetNewServicePeriodTo, GetNewFullServicePeriodTo, and GetNewBillingPeriodTo
are consistent; apply the same initial normalization line in
PatchShrink.ValidateWith to mirror behavior.

In `@openmeter/billing/charges/usagebased/charge.go`:
- Around line 137-155: State.Normalized currently calls
meta.NormalizeOptionalTimestamp but that helper leaves a non-nil &time.Time{} as
a valid pointer; update normalization and/or validation so zero timestamps are
treated as nil and rejected: in State.Normalized (and/or in Validate) ensure
s.AdvanceAfter is set to nil when it is zero (time.Time.IsZero()), and in
State.Validate add a check that if s.AdvanceAfter != nil and
s.AdvanceAfter.IsZero() then return an error (or append to errs) stating
advanceAfter must be a non-zero timestamp; reference functions/fields: State,
Normalized(), Validate(), AdvanceAfter, and meta.NormalizeOptionalTimestamp.

In `@openmeter/billing/charges/usagebased/service/creditsonly.go`:
- Around line 175-186: The code normalizes storedAtOffset before calling
s.Service.getRatingForUsage which uses an exclusive "< StoredAtOffset" filter
and may drop events; change to compute rawStoredAt :=
clock.Now().Add(-usagebased.InternalCollectionPeriod), then compute
normalizedStoredAt := meta.NormalizeTimestamp(rawStoredAt) and pass rawStoredAt
to getRatingForUsage via getRatingForUsageInput.StoredAtOffset while using
normalizedStoredAt only for persistence/metadata (e.g., any fields or stores
that require truncated timestamps). Apply the same change in
FinalizeRealizationRun (the block around lines 276-283) so the query cutoff uses
the raw timestamp and normalization is only used for persisted offsets.

---

Nitpick comments:
In `@openmeter/billing/charges/flatfee/adapter/charge.go`:
- Line 47: The call to .In(time.UTC) after meta.NormalizeTimestamp(...) is
redundant because NormalizeTimestamp already returns a UTC time; remove the
.In(time.UTC) chaining wherever it appears (e.g., the SetInvoiceAt(...) call and
the second occurrence around the other timestamp at the same file) so the code
simply uses meta.NormalizeTimestamp(...) directly; look for usages of
SetInvoiceAt and any other calls that currently do
meta.NormalizeTimestamp(...).In(time.UTC) and drop the .In(time.UTC) suffix.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 18e3c4e2-2509-403f-962e-1572837eb1ba

📥 Commits

Reviewing files that changed from the base of the PR and between a4a2fa1 and 14e427e.

📒 Files selected for processing (20)
  • .agents/skills/charges/SKILL.md
  • openmeter/billing/charges/flatfee/adapter/charge.go
  • openmeter/billing/charges/flatfee/charge.go
  • openmeter/billing/charges/flatfee/service/create.go
  • openmeter/billing/charges/flatfee/service/creditsonly.go
  • openmeter/billing/charges/meta/patchdelete.go
  • openmeter/billing/charges/meta/patchextend.go
  • openmeter/billing/charges/meta/patchshrink.go
  • openmeter/billing/charges/meta/timestamps.go
  • openmeter/billing/charges/models/chargemeta/mixin.go
  • openmeter/billing/charges/service/patchtmp.go
  • openmeter/billing/charges/service/truncation_test.go
  • openmeter/billing/charges/usagebased/adapter/charge.go
  • openmeter/billing/charges/usagebased/adapter/realizationrun.go
  • openmeter/billing/charges/usagebased/charge.go
  • openmeter/billing/charges/usagebased/realizationrun.go
  • openmeter/billing/charges/usagebased/service/create.go
  • openmeter/billing/charges/usagebased/service/creditsonly.go
  • openmeter/billing/charges/usagebased/service/statemachine.go
  • openmeter/billing/worker/subscriptionsync/service/reconciler/patchcharge.go

@turip turip enabled auto-merge (squash) April 2, 2026 10:04
@turip turip merged commit ea2d1f3 into main Apr 2, 2026
24 checks passed
@turip turip deleted the chore/normalize-charges-timestamps branch April 2, 2026 10:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants