Skip to content

feat[charges]: common state machine operations#4145

Merged
turip merged 2 commits intomainfrom
feat/state-machine-common
Apr 17, 2026
Merged

feat[charges]: common state machine operations#4145
turip merged 2 commits intomainfrom
feat/state-machine-common

Conversation

@turip
Copy link
Copy Markdown
Member

@turip turip commented Apr 15, 2026

Overview

Unify base state machine. This is essential so that the Fire/CanFire etc. behaves consistently between charges components.

Given there are different flavors of charges, there were already drifts happening.

Notes for reviewer

Summary by CodeRabbit

  • Refactor

    • Introduced a generic, unified billing charge state machine for consistent behavior across charge types; standardized charge status/base accessors and mutators.
  • New Features

    • Exposed charge accessors and mutator-style methods for status and base on multiple charge types.
  • Bug Fixes

    • Improved error handling for charge deletion and post-delete refresh operations.
  • Tests

    • Added unit tests covering state transitions, persistence, activation, and refetch behavior.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 15, 2026

📝 Walkthrough

Walkthrough

Adds a generic, reusable charge state machine with tests, implements required Get/With accessors on flatfee and usagebased Charge types, migrates those services to the generic machine, and updates several service call sites to use the exported RefetchCharge with explicit error handling.

Changes

Cohort / File(s) Summary
Charge Type Accessors
openmeter/billing/charges/flatfee/charge.go, openmeter/billing/charges/usagebased/charge.go
Added GetStatus(), WithStatus(), GetBase(), WithBase() methods to Charge types to satisfy the generic state-machine contract.
Generic State Machine Implementation
openmeter/billing/charges/statemachine/machine.go
New generic Machine[CHARGE, BASE, STATUS], ChargeLike interface, Persistence hooks, Config validation, and methods: New, Configure, CanFire, FireAndActivate, AdvanceUntilStateStable, RefetchCharge.
Generic State Machine Tests
openmeter/billing/charges/statemachine/machine_test.go
Adds unit tests for transitions, unsupported-trigger errors, advancement loop, persistence interactions, refetch, activation callbacks, and error propagation.
Flatfee State Machine Migration
openmeter/billing/charges/flatfee/service/statemachine.go
Replaced flatfee-specific stateless wrapper with the generic chargestatemachine.Machine[...]; removed local wrapper code and custom error paths.
Usage-Based State Machine Migration
openmeter/billing/charges/usagebased/service/statemachine.go
Migrated usage-based state machine to the generic Machine[...], removed manual advance/refetch logic and local StateMachine interface.
Service Callsite Updates
openmeter/billing/charges/flatfee/service/creditsonly.go, openmeter/billing/charges/usagebased/service/creditheninvoice.go, openmeter/billing/charges/usagebased/service/creditsonly.go
Replaced internal refetchCharge tail calls with exported RefetchCharge() and added explicit error wrapping (fmt.Errorf("get charge: %w", err) / fmt.Errorf("refetch charge: %w", err)).

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant Machine as GenericMachine
  participant Persistence
  participant Adapter as DB/Adapter

  Caller->>Machine: FireAndActivate(trigger, args...)
  Machine->>Machine: Validate target STATUS
  alt valid transition
    Machine->>Persistence: UpdateBase(ctx, newBase)
    Persistence->>Adapter: Adapter.UpdateCharge(...)
    Adapter-->>Persistence: persistedBase
    Persistence-->>Machine: persistedBase
    Machine-->>Caller: nil (success)
  else unsupported
    Machine-->>Caller: ErrUnsupportedOperation
  end

  Note over Machine,Persistence: AdvanceUntilStateStable loops: Fire trigger(s) -> UpdateBase -> Refetch as needed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

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 directly describes the main change: introducing common state machine operations shared across charge implementations to unify behavior.

✏️ 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/state-machine-common

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 changed the base branch from main to feat/invoice-accrued-v2 April 16, 2026 07:23
@turip turip force-pushed the feat/state-machine-common branch from c1216f5 to 06bab4b Compare April 16, 2026 07:43
@turip turip added release-note/misc Miscellaneous changes area/billing labels Apr 16, 2026
Base automatically changed from feat/invoice-accrued-v2 to main April 16, 2026 10:45
@turip turip force-pushed the feat/state-machine-common branch from 06bab4b to 3e1fc80 Compare April 16, 2026 10:48
@turip turip changed the title Feat/state machine common feat[charges]: common state machine operations Apr 16, 2026
@turip turip marked this pull request as ready for review April 16, 2026 10:48
@turip turip requested a review from a team as a code owner April 16, 2026 10:48
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 (4)
openmeter/billing/charges/usagebased/service/creditsonly.go (1)

140-145: Keep the RefetchCharge error prefix consistent.

These two sites wrap the same helper with different messages ("get charge" vs "refetch charge"). Standardizing that wording will make logs and test expectations easier to grep and keeps the shared-machine paths a bit more uniform.

Also applies to: 225-226

🤖 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 140
- 145, Standardize the RefetchCharge error wrap: replace the inconsistent
fmt.Errorf("get charge: %w", err) (and the other occurrence using "get charge")
with fmt.Errorf("refetch charge: %w", err) so both call sites that wrap
s.RefetchCharge(ctx) use the same "refetch charge" prefix; locate the calls to
s.RefetchCharge and update the error message strings accordingly.
openmeter/billing/charges/statemachine/machine.go (3)

131-164: Consider adding a max iterations guard (optional).

The loop relies on the state machine being correctly configured to eventually stop (no cycles via TriggerNext). That's reasonable, but a defensive max iterations check could prevent runaway loops from misconfigured state machines.

Something like:

const maxIterations = 100
for i := 0; i < maxIterations; i++ {
    // ... existing loop body
}
return nil, fmt.Errorf("exceeded max state transitions")

This is purely defensive - if your state machines are well-tested, the current implementation is fine.

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

In `@openmeter/billing/charges/statemachine/machine.go` around lines 131 - 164,
The loop in Machine.AdvanceUntilStateStable can spin indefinitely if the state
machine cycles; add a defensive max-iterations guard to break and return an
error after a reasonable limit (e.g. 100) to avoid runaway loops. Modify the for
loop that calls CanFire(ctx, meta.TriggerNext) / FireAndActivate to count
iterations (i) and if i >= maxIterations return an error like "exceeded max
state transitions"; keep existing behavior for CanFire false and normal success
paths, and ensure the guard increments on each successful transition (after
m.Charge = m.Charge.WithBase(updatedBase)). Use the same error wrapping style as
other errors returned from this method.

166-176: Consider wrapping the refetch error with context.

Adding the charge ID to the error would help with debugging when refetch fails:

 func (m *Machine[CHARGE, BASE, STATUS]) RefetchCharge(ctx context.Context) error {
 	chargeID := m.Charge.GetChargeID()
 
 	charge, err := m.config.Persistence.Refetch(ctx, chargeID)
 	if err != nil {
-		return err
+		return fmt.Errorf("refetch charge [id=%s]: %w", chargeID.ID, err)
 	}
 
 	m.Charge = charge
 	return nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/statemachine/machine.go` around lines 166 - 176, In
RefetchCharge, wrap the error returned by m.config.Persistence.Refetch with
contextual information including the charge ID to aid debugging; change the
error return to include the chargeID (e.g. use fmt.Errorf or errors.Wrapf) such
as returning fmt.Errorf("RefetchCharge: failed to refetch charge %s: %w",
chargeID, err) so callers see which charge failed when
m.config.Persistence.Refetch returns an error.

58-62: Consider making Charge unexported or removing GetCharge().

The Charge field is exported while there's also a GetCharge() method (line 102). This creates two ways to access the same data. Having an exported field allows external code to modify it directly, potentially bypassing state machine invariants.

If direct field access is intentional for performance or simplicity in trusted callers, that's fine - just wanted to flag the redundancy.

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

In `@openmeter/billing/charges/statemachine/machine.go` around lines 58 - 62, The
Machine struct exposes the Charge field while also providing a GetCharge()
accessor, creating redundant and potentially unsafe access; choose one: either
make the field unexported by renaming Charge to charge and update all internal
and external references (constructors, methods, tests) to use GetCharge() where
necessary, or remove the GetCharge() method and keep the exported Charge field;
update any callers that currently use the removed/renamed symbol to the
remaining API (Machine.Charge or Machine.GetCharge()) and run tests to ensure no
visibility regressions.
🤖 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/billing/charges/statemachine/machine.go`:
- Around line 131-164: The loop in Machine.AdvanceUntilStateStable can spin
indefinitely if the state machine cycles; add a defensive max-iterations guard
to break and return an error after a reasonable limit (e.g. 100) to avoid
runaway loops. Modify the for loop that calls CanFire(ctx, meta.TriggerNext) /
FireAndActivate to count iterations (i) and if i >= maxIterations return an
error like "exceeded max state transitions"; keep existing behavior for CanFire
false and normal success paths, and ensure the guard increments on each
successful transition (after m.Charge = m.Charge.WithBase(updatedBase)). Use the
same error wrapping style as other errors returned from this method.
- Around line 166-176: In RefetchCharge, wrap the error returned by
m.config.Persistence.Refetch with contextual information including the charge ID
to aid debugging; change the error return to include the chargeID (e.g. use
fmt.Errorf or errors.Wrapf) such as returning fmt.Errorf("RefetchCharge: failed
to refetch charge %s: %w", chargeID, err) so callers see which charge failed
when m.config.Persistence.Refetch returns an error.
- Around line 58-62: The Machine struct exposes the Charge field while also
providing a GetCharge() accessor, creating redundant and potentially unsafe
access; choose one: either make the field unexported by renaming Charge to
charge and update all internal and external references (constructors, methods,
tests) to use GetCharge() where necessary, or remove the GetCharge() method and
keep the exported Charge field; update any callers that currently use the
removed/renamed symbol to the remaining API (Machine.Charge or
Machine.GetCharge()) and run tests to ensure no visibility regressions.

In `@openmeter/billing/charges/usagebased/service/creditsonly.go`:
- Around line 140-145: Standardize the RefetchCharge error wrap: replace the
inconsistent fmt.Errorf("get charge: %w", err) (and the other occurrence using
"get charge") with fmt.Errorf("refetch charge: %w", err) so both call sites that
wrap s.RefetchCharge(ctx) use the same "refetch charge" prefix; locate the calls
to s.RefetchCharge and update the error message strings accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 68308a1d-aae5-45b0-9c6f-fdfb7e4fa6e6

📥 Commits

Reviewing files that changed from the base of the PR and between 69baee2 and 3e1fc80.

📒 Files selected for processing (9)
  • openmeter/billing/charges/flatfee/charge.go
  • openmeter/billing/charges/flatfee/service/creditsonly.go
  • openmeter/billing/charges/flatfee/service/statemachine.go
  • openmeter/billing/charges/statemachine/machine.go
  • openmeter/billing/charges/statemachine/machine_test.go
  • openmeter/billing/charges/usagebased/charge.go
  • openmeter/billing/charges/usagebased/service/creditheninvoice.go
  • openmeter/billing/charges/usagebased/service/creditsonly.go
  • openmeter/billing/charges/usagebased/service/statemachine.go

@turip turip enabled auto-merge (squash) April 16, 2026 11:31
@turip turip force-pushed the feat/state-machine-common branch from 84d9b18 to 0898d28 Compare April 16, 2026 11:31
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 (1)
openmeter/billing/charges/usagebased/service/creditsonly.go (1)

225-227: Minor: Inconsistent error message wording.

DeleteCharge at line 141 wraps the error as "get charge: %w" while here it's "refetch charge: %w". Not a bug, but standardizing on one phrase (looks like "get charge" is used elsewhere in creditheninvoice.go) would make error logs more consistent.

💡 Optional fix for consistency
 	if err := s.RefetchCharge(ctx); err != nil {
-		return fmt.Errorf("refetch charge: %w", err)
+		return fmt.Errorf("get charge: %w", err)
 	}
🤖 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 225
- 227, Standardize the error message wording by replacing the "refetch charge:
%w" wrap in the s.RefetchCharge error return inside creditsonly.go with the same
phrase used elsewhere ("get charge: %w"); update the return that currently does
`return fmt.Errorf("refetch charge: %w", err)` to `return fmt.Errorf("get
charge: %w", err)` so it matches DeleteCharge and creditheninvoice.go and keeps
logs consistent when referencing s.RefetchCharge failures.
🤖 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/billing/charges/usagebased/service/creditsonly.go`:
- Around line 225-227: Standardize the error message wording by replacing the
"refetch charge: %w" wrap in the s.RefetchCharge error return inside
creditsonly.go with the same phrase used elsewhere ("get charge: %w"); update
the return that currently does `return fmt.Errorf("refetch charge: %w", err)` to
`return fmt.Errorf("get charge: %w", err)` so it matches DeleteCharge and
creditheninvoice.go and keeps logs consistent when referencing s.RefetchCharge
failures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cb23f6fc-d10a-4bee-9e0c-2f813668d6fc

📥 Commits

Reviewing files that changed from the base of the PR and between 84d9b18 and 0898d28.

📒 Files selected for processing (9)
  • openmeter/billing/charges/flatfee/charge.go
  • openmeter/billing/charges/flatfee/service/creditsonly.go
  • openmeter/billing/charges/flatfee/service/statemachine.go
  • openmeter/billing/charges/statemachine/machine.go
  • openmeter/billing/charges/statemachine/machine_test.go
  • openmeter/billing/charges/usagebased/charge.go
  • openmeter/billing/charges/usagebased/service/creditheninvoice.go
  • openmeter/billing/charges/usagebased/service/creditsonly.go
  • openmeter/billing/charges/usagebased/service/statemachine.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • openmeter/billing/charges/usagebased/service/creditheninvoice.go
  • openmeter/billing/charges/flatfee/charge.go
  • openmeter/billing/charges/statemachine/machine.go

@turip turip merged commit 6163a01 into main Apr 17, 2026
35 of 36 checks passed
@turip turip deleted the feat/state-machine-common branch April 17, 2026 08:00
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