Skip to content

refactor(charges): make state machine invoice effects explicit - #4886

Merged
turip merged 10 commits into
mainfrom
agent/charge-statemachine-callers
Aug 11, 2026
Merged

refactor(charges): make state machine invoice effects explicit#4886
turip merged 10 commits into
mainfrom
agent/charge-statemachine-callers

Conversation

@turip

@turip turip commented Aug 8, 2026

Copy link
Copy Markdown
Member

Summary

  • replace direct charge state-machine firing and invoice-patch draining with explicit invoice-aware and non-invoice advancement APIs
  • migrate credit-purchase, flat-fee, and usage-based callers to the new contract
  • preserve the historical AdvanceCharge no-op behavior when no continuation is available
  • remove the redundant credit-purchase fire-and-advance wrapper and document the effects-handling contract

Why

Charge state-machine callers could previously fire transitions independently from handling their invoice effects. That allowed callers to forget pending invoice patches or to advance through a different execution path.

The new interface makes the choice explicit: invoice-aware calls return the first complete patch batch, while non-invoice calls fail if a transition produces invoice patches. Callers can no longer inspect or drain the internal patch buffer directly.

The coordinator applies each returned invoice patch batch before resuming the charge. This allows lifecycle states to chain correctly when completing an operation requires multiple invoice patch rounds.

Behavioral impact

Patch and line-engine workflows now continue synchronously until they either produce invoice patches or reach a stable lifecycle state. AdvanceCharge retains its previous nil result when the charge cannot fire next.

Validation

  • go test ./openmeter/billing/charges/... -count=1
  • focused credit-purchase, flat-fee, usage-based, and generic state-machine tests
  • nix develop --impure .#ci -c make test-nocache — 6,919 tests passed, 9 skipped

Summary by CodeRabbit

  • New Features

    • Added clearer charge advancement results, including updated charges, invoice patches, and whether further advancement is available.
    • Invoice effects are now applied in batches before related charges continue through their lifecycle.
    • Usage-based charge advancement supports optional customer and feature-meter resolution hints.
  • Bug Fixes

    • Prevented invoice effects from being discarded during charge advancement.
    • Improved handling of finalized zero-value charges and mid-period cancellations, including more accurate lifecycle statuses and realizations.
    • Added safeguards against unbounded invoice-effect processing.

Greptile Summary

The PR makes charge advancement explicitly stop at invoice-effect boundaries so callers must apply each patch batch before resuming lifecycle processing.

  • Replaces direct state-machine firing and patch draining with invoice-aware and no-invoice advancement APIs.
  • Updates flat-fee, usage-based, and credit-purchase callers to the new contract.
  • Adds coordinated multi-round invoice-effect processing with progress validation and a bounded loop.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
openmeter/billing/charges/statemachine/machine.go Introduces explicit invoice-aware and no-invoice advancement operations while preserving stable-state no-op behavior.
openmeter/billing/charges/service/patch.go Coordinates applying invoice-effect batches and resuming affected charges with progress and round-limit safeguards.
openmeter/billing/charges/service/advance.go Migrates customer-scoped advancement to collect invoice effects and continue charges after those effects are applied.
openmeter/billing/charges/usagebased/service/triggers.go Adopts the advancement result contract and supports optional pre-resolved customer and feature-meter context.
openmeter/billing/charges/flatfee/service/triggers.go Returns explicit charge, invoice-patch, and continuation state from advancement and patch triggers.
openmeter/billing/charges/creditpurchase/service/invoice.go Migrates invoice lifecycle handling to fire and advance synchronously through the stable non-invoice path.

Sequence Diagram

sequenceDiagram
  participant C as Charge coordinator
  participant S as Charge state machine
  participant I as Invoice updater
  C->>S: Advance until patch boundary or stable
  alt Invoice patches emitted
    S-->>C: Patch batch and CanAdvance
    C->>I: Apply patch batch
    I-->>C: Updated invoice
    C->>S: Resume charge advancement
  else Stable lifecycle state
    S-->>C: No patches and cannot advance
  end
Loading

Reviews (6): Last reviewed commit: "Update openmeter/billing/charges/usageba..." | Re-trigger Greptile

Context used:

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR replaces legacy charge activation APIs with explicit stable-state and invoice-patch-boundary advancement. Flat-fee and usage-based services now return invoice-aware results. Charge orchestration applies invoice patches in rounds before continuing lifecycle advancement.

Changes

Charge advancement and invoice patch flow

Layer / File(s) Summary
State-machine advancement contracts
openmeter/billing/charges/statemachine/*, openmeter/billing/charges/README.md
The state machine separates invoice-aware and stable-only advancement. Stable-only methods reject unhandled invoice patches.
Trigger results and service contracts
openmeter/billing/charges/meta/patch.go, openmeter/billing/charges/flatfee/service/*, openmeter/billing/charges/usagebased/service*, openmeter/billing/charges/service/helpers.go
Advancement returns charges, invoice patches, and CanAdvance. Usage-based inputs accept optional customer and feature-meter hints.
Round-based patch progression
openmeter/billing/charges/service/{advance.go,patch.go,patch_test.go}
Charge advancement applies invoice patches in customer-level batches and resumes eligible charges until stabilization.
Lifecycle integration and regression coverage
openmeter/billing/charges/creditpurchase/*, openmeter/billing/charges/flatfee/*, openmeter/billing/charges/usagebased/*, openmeter/billing/charges/worker/*, openmeter/ledger/*, test/credits/*
Lifecycle paths use combined advancement operations. Tests update patch handling, zero-fiat realization, feature-meter resolution, cancellation, statuses, and nested trigger results.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ChargeService
  participant StateMachine
  participant InvoiceUpdater
  ChargeService->>StateMachine: Advance until patches or stable
  StateMachine-->>ChargeService: Charge and invoice patches
  ChargeService->>InvoiceUpdater: Apply customer-level patch batch
  InvoiceUpdater-->>ChargeService: Patch batch applied
  ChargeService->>StateMachine: Resume charge advancement
  StateMachine-->>ChargeService: Final charge or next invoice patches
Loading

Possibly related PRs

Suggested labels: release-note/misc, area/billing

Suggested reviewers: tothandras

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main refactor: making invoice effects explicit in the charge state machine.
✨ 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 agent/charge-statemachine-callers

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.

@turip turip added the kind/refactor Code refactor, cleanup or minor improvement label Aug 9, 2026
@turip
turip marked this pull request as ready for review August 9, 2026 14:22
@turip
turip requested a review from a team as a code owner August 9, 2026 14:22

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🧹 Nitpick comments (6)
openmeter/billing/charges/service/patch.go (1)

125-152: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Nice round-based design. Consider a safety cap on the number of rounds.

The loop is well behaved for the current lifecycles: a charge only re-enters nextPendingAdvancement when it both emits patches and reports CanAdvance, so the loop drains naturally. There is no upper bound though, and the whole thing runs inside the enclosing transaction.Run. If a future state machine ever emits an invoice patch on a transition that loops back, this spins while holding the transaction open.

A cheap maxRounds counter that returns a descriptive error would turn that failure mode into a clear error instead of a stuck transaction.

🛡️ Sketch: bound the rounds
+// maxInvoiceEffectRounds bounds lifecycle progression so a charge that keeps
+// emitting invoice effects cannot hold the enclosing transaction open forever.
+const maxInvoiceEffectRounds = 32
+
 func (s *service) advanceChargesAndApplyInvoicePatches(
 	ctx context.Context,
 	customerID customer.CustomerID,
 	pendingAdvancement map[string]InvocableCharge,
 	invoicePatches invoiceupdater.Patches,
 ) (map[string]TriggerPatchResult, error) {
 	latestResults := make(map[string]TriggerPatchResult, len(pendingAdvancement))
+	rounds := 0
 
 	for len(invoicePatches) > 0 {
+		rounds++
+		if rounds > maxInvoiceEffectRounds {
+			return nil, fmt.Errorf("charge advancement exceeded %d invoice-effect rounds for customer %s", maxInvoiceEffectRounds, customerID.ID)
+		}
+
 		if err := s.invoiceUpdater.ApplyPatches(ctx, customerID, invoicePatches); err != nil {
🤖 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/service/patch.go` around lines 125 - 152, Bound the
round-based loop in the surrounding charge patch flow with a maxRounds counter.
Increment it for each iteration of the loop and return a descriptive error when
the configured limit is exceeded, while preserving the existing patch
application and advancement behavior below the limit.
openmeter/billing/charges/statemachine/machine.go (1)

228-234: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: collapse the pass-through wrapper.

AdvanceUntilInvoicePatchesOrStable only forwards to advanceUntilInvoicePatchesOrStable. You can move the loop body into the exported method and let the internal callers use it directly. That removes one indirection without changing behavior. Totally fine to leave as-is if you prefer the symmetry with FireAndAdvanceUntilInvoicePatchesOrStable.

As per coding guidelines: "Do not extract trivial or single-use helpers unless the name captures non-obvious domain intent; inline pass-through wrappers."

🤖 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/statemachine/machine.go` around lines 228 - 234,
Optionally remove the trivial pass-through method
AdvanceUntilInvoicePatchesOrStable by moving the implementation of
advanceUntilInvoicePatchesOrStable into the exported method and updating
internal callers to use it directly. Preserve existing behavior and retain
FireAndAdvanceUntilInvoicePatchesOrStable symmetry only if preferred.

Source: Coding guidelines

openmeter/billing/charges/statemachine/machine_test.go (2)

215-264: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the pending-patch fire guard.

fireAndActivate now rejects a trigger when m.invoicePatches is not empty (machine.go lines 149-151). No test exercises that branch. A small subtest that calls AddInvoicePatch and then FireAndAdvanceUntilInvoicePatchesOrStable would lock in the ErrUnhandledInvoicePatches contract and the "no transition fired" guarantee.

Want me to draft it?

🤖 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/statemachine/machine_test.go` around lines 215 -
264, Add a subtest in TestMachine_FireAndAdvanceUntilStable that queues an
invoice patch with AddInvoicePatch before calling
FireAndAdvanceUntilInvoicePatchesOrStable. Assert the call returns
ErrUnhandledInvoicePatches, preserves the initial charge status, and does not
fire the requested transition, covering the pending-patch guard in
fireAndActivate.

394-421: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Nit: these two subtests sit under the wrong parent.

Both subtests call AdvanceUntilInvoicePatchesOrStable, and their own when: comments say so, but they live inside TestMachine_AdvanceUntilStable. There is already a TestMachine_AdvanceUntilInvoicePatchesOrStable at line 346. Moving them there makes failure output point at the right API. Purely cosmetic, so feel free to defer.

Also applies to: 454-481

🤖 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/statemachine/machine_test.go` around lines 394 -
421, Move the subtests at the shown location, including the case around lines
454–481, from TestMachine_AdvanceUntilStable into the existing
TestMachine_AdvanceUntilInvoicePatchesOrStable test. Preserve their bodies and
assertions unchanged so failure output is grouped under the API they exercise.
openmeter/billing/charges/service/patch_test.go (1)

87-93: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for the "can advance without a boundary" guard.

The new invariant in openmeter/billing/charges/service/patch.go (lines 102-107 and 143-148) rejects a result that sets CanAdvance with no invoice patches. scriptedInvocableCharge already makes that trivial to script: return TriggerPatchResult{CanAdvance: true} with no patches and assert the error. That locks in a rule the rest of this PR depends on.

Happy to write it if useful.

🤖 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/service/patch_test.go` around lines 87 - 93, Add a
test case in the patch service tests using scriptedInvocableCharge that returns
TriggerPatchResult with CanAdvance true and no invoice patches, then assert the
patch operation returns an error. Cover the guard in the patch flow and preserve
the existing batch and call-count assertions for valid cases.

Source: Path instructions

openmeter/billing/charges/service/advance.go (1)

69-77: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: the "can advance without a boundary" invariant is written three times.

The same guard appears here, at lines 114-117 for usage-based charges, and twice in openmeter/billing/charges/service/patch.go (lines 102-107 and 143-148). A small helper that takes the charge ID and the result would keep the invariant and its message in one place. Nice-to-have only.

🤖 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/service/advance.go` around lines 69 - 77, The
invoice-effect boundary guard is duplicated across charge advancement and
patching flows. Extract a shared helper that accepts the charge ID and mapped
result, performs the InvoicePatches validation, and returns the existing error;
replace the guards near the flat-fee and usage-based advancement logic and both
locations in patch.go while preserving current behavior.
🤖 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.

Inline comments:
In `@openmeter/billing/charges/service/helpers.go`:
- Around line 153-160: Carry the already-resolved customer override and
feature-meter snapshots from the construction site in advanceCharges into
usageBasedInvocableCharge, and pass them as hints from AdvanceCharge when
calling usageBasedService.AdvanceCharge. Update the corresponding fields and
construction in advance.go and preserve the no-hints behavior for charges
created by applyInvocableChargePatches.

In `@openmeter/billing/worker/subscriptionsync/service/creditsonly_test.go`:
- Around line 974-975: Update the assertions for finalRun.MeteredQuantity and
finalRun.CreditsAllocated.Sum() to use require.Equal with each Decimal converted
via InexactFloat64(), following the repository convention for one-off Decimal
comparisons.

---

Nitpick comments:
In `@openmeter/billing/charges/service/advance.go`:
- Around line 69-77: The invoice-effect boundary guard is duplicated across
charge advancement and patching flows. Extract a shared helper that accepts the
charge ID and mapped result, performs the InvoicePatches validation, and returns
the existing error; replace the guards near the flat-fee and usage-based
advancement logic and both locations in patch.go while preserving current
behavior.

In `@openmeter/billing/charges/service/patch_test.go`:
- Around line 87-93: Add a test case in the patch service tests using
scriptedInvocableCharge that returns TriggerPatchResult with CanAdvance true and
no invoice patches, then assert the patch operation returns an error. Cover the
guard in the patch flow and preserve the existing batch and call-count
assertions for valid cases.

In `@openmeter/billing/charges/service/patch.go`:
- Around line 125-152: Bound the round-based loop in the surrounding charge
patch flow with a maxRounds counter. Increment it for each iteration of the loop
and return a descriptive error when the configured limit is exceeded, while
preserving the existing patch application and advancement behavior below the
limit.

In `@openmeter/billing/charges/statemachine/machine_test.go`:
- Around line 215-264: Add a subtest in TestMachine_FireAndAdvanceUntilStable
that queues an invoice patch with AddInvoicePatch before calling
FireAndAdvanceUntilInvoicePatchesOrStable. Assert the call returns
ErrUnhandledInvoicePatches, preserves the initial charge status, and does not
fire the requested transition, covering the pending-patch guard in
fireAndActivate.
- Around line 394-421: Move the subtests at the shown location, including the
case around lines 454–481, from TestMachine_AdvanceUntilStable into the existing
TestMachine_AdvanceUntilInvoicePatchesOrStable test. Preserve their bodies and
assertions unchanged so failure output is grouped under the API they exercise.

In `@openmeter/billing/charges/statemachine/machine.go`:
- Around line 228-234: Optionally remove the trivial pass-through method
AdvanceUntilInvoicePatchesOrStable by moving the implementation of
advanceUntilInvoicePatchesOrStable into the exported method and updating
internal callers to use it directly. Preserve existing behavior and retain
FireAndAdvanceUntilInvoicePatchesOrStable symmetry only if preferred.
🪄 Autofix

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 Plus

Run ID: 28ef681f-9689-495a-8e1d-795557cc8cc1

📥 Commits

Reviewing files that changed from the base of the PR and between a9a7283 and 7522a74.

📒 Files selected for processing (34)
  • openmeter/billing/charges/README.md
  • openmeter/billing/charges/creditpurchase/service/create.go
  • openmeter/billing/charges/creditpurchase/service/external.go
  • openmeter/billing/charges/creditpurchase/service/external_test.go
  • openmeter/billing/charges/creditpurchase/service/invoice.go
  • openmeter/billing/charges/creditpurchase/service/promotional_test.go
  • openmeter/billing/charges/creditpurchase/service/statemachine.go
  • openmeter/billing/charges/flatfee/service.go
  • openmeter/billing/charges/flatfee/service/creditheninvoice.go
  • openmeter/billing/charges/flatfee/service/lineengine.go
  • openmeter/billing/charges/flatfee/service/triggers.go
  • openmeter/billing/charges/meta/patch.go
  • openmeter/billing/charges/service/advance.go
  • openmeter/billing/charges/service/flatfee_costbasis_test.go
  • openmeter/billing/charges/service/helpers.go
  • openmeter/billing/charges/service/invoicable_test.go
  • openmeter/billing/charges/service/patch.go
  • openmeter/billing/charges/service/patch_test.go
  • openmeter/billing/charges/statemachine/machine.go
  • openmeter/billing/charges/statemachine/machine_test.go
  • openmeter/billing/charges/usagebased/service.go
  • openmeter/billing/charges/usagebased/service/creditheninvoice_test.go
  • openmeter/billing/charges/usagebased/service/creditsonly_test.go
  • openmeter/billing/charges/usagebased/service/lineengine.go
  • openmeter/billing/charges/usagebased/service/payments.go
  • openmeter/billing/charges/usagebased/service/triggers.go
  • openmeter/billing/charges/usagebased/service/triggers_test.go
  • openmeter/billing/charges/usagebased/service_test.go
  • openmeter/billing/worker/subscriptionsync/service/creditsonly_test.go
  • openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go
  • openmeter/billing/worker/subscriptionsync/service/sync_regression_test.go
  • openmeter/ledger/customerbalance/service_test.go
  • openmeter/ledger/customerbalance/testenv_test.go
  • test/credits/credit_then_invoice_test.go
💤 Files with no reviewable changes (1)
  • openmeter/billing/charges/creditpurchase/service/statemachine.go

Comment thread openmeter/billing/charges/service/helpers.go
Comment thread openmeter/billing/worker/subscriptionsync/service/creditsonly_test.go Outdated
@turip
turip force-pushed the agent/charge-statemachine-callers branch from d8cd82c to bf4c3d2 Compare August 10, 2026 12:08
@turip
turip enabled auto-merge (squash) August 11, 2026 12:01
@turip
turip disabled auto-merge August 11, 2026 12:16
Comment thread openmeter/billing/charges/flatfee/service/triggers.go Outdated
Comment thread openmeter/billing/charges/statemachine/machine.go Outdated
Comment thread openmeter/billing/charges/usagebased/service/triggers.go Outdated
Comment thread openmeter/billing/charges/usagebased/service/triggers.go Outdated
turip and others added 4 commits August 11, 2026 14:38
Co-authored-by: Robert Boros <robert.boros@konghq.com>
Signed-off-by: Peter Turi <peter.turi@konghq.com>
Co-authored-by: Robert Boros <robert.boros@konghq.com>
Signed-off-by: Peter Turi <peter.turi@konghq.com>
Co-authored-by: Robert Boros <robert.boros@konghq.com>
Signed-off-by: Peter Turi <peter.turi@konghq.com>
Co-authored-by: Robert Boros <robert.boros@konghq.com>
Signed-off-by: Peter Turi <peter.turi@konghq.com>
@turip
turip requested a review from borosr August 11, 2026 12:39
@turip
turip enabled auto-merge (squash) August 11, 2026 12:43
@turip
turip merged commit 170e66c into main Aug 11, 2026
27 checks passed
@turip
turip deleted the agent/charge-statemachine-callers branch August 11, 2026 12:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/refactor Code refactor, cleanup or minor improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants