Skip to content

feat: book invoice accrued for usagebased items#4144

Merged
turip merged 5 commits intomainfrom
feat/invoice-accrued-v2
Apr 16, 2026
Merged

feat: book invoice accrued for usagebased items#4144
turip merged 5 commits intomainfrom
feat/invoice-accrued-v2

Conversation

@turip
Copy link
Copy Markdown
Member

@turip turip commented Apr 15, 2026

Overview

Fixes #(issue)

Notes for reviewer

Summary by CodeRabbit

  • New Features

    • Added invoice-issued lifecycle hooks across line engines and billing to trigger post-issue workflows.
    • Persisted booking of accrued invoice usage for usage-based charges (zero amounts are no-ops).
  • Improvements

    • Stronger settlement-mode validation and safer ledger-backed accrual flows.
    • More accurate credit allocation, reconciliation, and state-machine transitions with retryable issuing states.
  • Documentation

    • Guidance on invoice accrual expectations and settlement-mode constraints.
  • Tests

    • New and updated tests covering invoice-issued behavior and credit/invoice lifecycles.

@turip turip changed the base branch from main to fix/usage-based-line-id-persisting April 15, 2026 12:30
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 15, 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: 14288e6a-497c-4251-8fb5-98d55d66ee05

📥 Commits

Reviewing files that changed from the base of the PR and between de54bdf and 53bb9e5.

📒 Files selected for processing (2)
  • openmeter/billing/stdinvoice.go
  • test/billing/lineengine_test.go
✅ Files skipped from review due to trivial changes (1)
  • test/billing/lineengine_test.go

📝 Walkthrough

Walkthrough

Adds an "invoice-issued" lifecycle across billing: new invoice-issued handlers and interface methods, state-machine transitions and hooks to finalize realization runs on invoice issuance, ledger-backed invoice-usage accrual and persistence, adapter/handler/API changes, and corresponding wiring and tests.

Changes

Cohort / File(s) Summary
Line engine lifecycle / issuing states
openmeter/billing/lineengine.go, openmeter/billing/lineengine/engine.go, openmeter/billing/service/stdinvoicestate.go, openmeter/billing/service/gatheringinvoicependinglines.go
Introduce OnInvoiceIssued input/interface; add new issuing states (issuing.charge_booking*) and state transitions; call line-engine OnInvoiceIssued during invoice issuing and handle engine failures with retry/failure states.
Usage-based handler & run workflows
openmeter/billing/charges/usagebased/handler.go, .../usagebased/service/run/invoice.go, .../service/lineengine.go, .../service/creditheninvoice.go, .../service/run/create.go, .../service/run/correct.go, .../service/creditsonly.go, .../realizationrun.go
Add OnInvoiceUsageAccrued DTO/handler method; implement BookAccruedInvoiceUsage workflow; make realization-run update fields optional (mo.Option) and callers use mo.Some; snapshot/finalize flows updated to trigger on invoice-issued.
Usage-based adapter extension
openmeter/billing/charges/usagebased/adapter.go, openmeter/billing/charges/usagebased/adapter/invoicedusage.go, .../adapter/realizationrun.go
Add RealizationRunInvoiceUsageAdapter and implement CreateRunInvoicedUsage to persist invoiced-usage records; make UpdateRealizationRun conditional on optional inputs.
Ledger charge adapter wiring & impl
openmeter/ledger/chargeadapter/usagebased.go, openmeter/ledger/chargeadapter/usagebased_test.go, app/common/charges.go, test/credits/sanity_test.go
Change NewUsageBasedHandler signature to accept ledger + resolver deps + collector; implement OnInvoiceUsageAccrued to resolve transactions and commit ledger groups; update tests and wiring.
Flat-fee & credit-purchase engines & service
openmeter/billing/charges/flatfee/..., openmeter/billing/charges/creditpurchase/lineengine/engine.go, openmeter/billing/charges/meta/triggers.go, openmeter/billing/charges/models/invoicedusage/stdinvoice.go
Refactor flat-fee flows to operate on StandardLine and call CalculateLines; adapt PostLineAssignedToInvoice to use StandardLine; add TriggerInvoiceIssued constant; add no-op OnInvoiceIssued in some engines.
Customer balance & app wiring
app/common/customerbalance.go, app/common/charges.go
Remove local charge-store wiring and custom customerBalanceChargeStore; pull charges/usage services from billing registry; update usage-based handler constructor wiring to include ledger and resolver services.
Tests & test utilities
openmeter/billing/charges/service/*.go, openmeter/billing/charges/testutils/handlers.go, test/billing/*, test/credits/sanity_test.go, test/billing/suite.go
Add helpers (grantPromotionalCredits, capped-credit allocator), extend mocks with OnInvoiceUsageAccrued/OnInvoiceIssued callbacks, add/adjust tests for invoice-issued lifecycle, and standardize totals assertions.
Misc / docs / statuses
.agents/skills/charges/SKILL.md, openmeter/billing/adapter/invoice.go, openmeter/billing/stdinvoice.go, openmeter/billing/charges/models/invoicedusage/stdinvoice.go
Documentation updates and guidance; treat new issuing statuses as non-final in app checks; add new invoice issuing status constants and include them in validations.

Sequence Diagram(s)

sequenceDiagram
    participant Invoice as Standard Invoice
    participant Billing as Billing Service
    participant LineEng as Line Engine
    participant StateMach as Charge StateMachine
    participant RunSvc as UsageRun Service
    participant Handler as UsageBased Handler
    participant Ledger as Ledger
    participant Adapter as Usage Adapter

    Invoice->>Billing: transition -> IssuingChargeBooking / Issued
    Billing->>Billing: onInvoiceIssued() (group lines by engine)
    Billing->>LineEng: OnInvoiceIssued(ctx, input)
    LineEng->>StateMach: Fire TriggerInvoiceIssued for each line
    StateMach->>StateMach: OnEntry -> FinalizeInvoiceRun / SnapshotInvoiceUsage
    StateMach->>RunSvc: BookAccruedInvoiceUsage(charge, run, line)
    RunSvc->>Handler: OnInvoiceUsageAccrued(input)
    Handler->>Ledger: Commit ledger transaction group
    Ledger-->>Handler: GroupReference{TransactionGroupID}
    Handler->>Adapter: CreateRunInvoicedUsage(runID, accruedUsage + txnRef)
    Adapter-->>RunSvc: persisted AccruedUsage
    RunSvc-->>StateMach: success -> state advances
    StateMach-->>LineEng: success
    LineEng-->>Billing: success
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested Reviewers

  • tothandras
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% 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: book invoice accrued for usagebased items' is directly related to the main change, which implements invoice accrual tracking for usage-based billing across multiple files.

✏️ 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/invoice-accrued-v2

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 force-pushed the fix/usage-based-line-id-persisting branch from 398ce07 to 31ebba1 Compare April 15, 2026 12:58
@turip turip changed the title Feat/invoice accrued v2 feat: invoice accrued v2 Apr 15, 2026
Base automatically changed from fix/usage-based-line-id-persisting to main April 15, 2026 15:26
@turip turip force-pushed the feat/invoice-accrued-v2 branch from e0be5ad to 2cb77ba Compare April 15, 2026 15:54
@turip turip marked this pull request as ready for review April 15, 2026 15:54
@turip turip requested a review from a team as a code owner April 15, 2026 15:54
@turip turip added release-note/feature Release note: Exciting New Features area/billing labels Apr 15, 2026
@turip turip changed the title feat: invoice accrued v2 feat: book invoice accrued for usagebased items Apr 15, 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: 6

🧹 Nitpick comments (2)
openmeter/billing/charges/service/base_test.go (1)

272-298: Nice test helper - follows the established pattern!

This implementation mirrors the approach in TestPromotionalCreditPurchase (context snippet 3), which is great for consistency. A couple of minor observations:

  1. Zero-length period (lines 281-284): Setting From: now, To: now is unusual since ClosedPeriod typically represents a service duration. However, promotional credits don't represent an actual service period—they're instant grants—so the zero-length period makes sense here. Consider adding a brief comment explaining this if you think it might trip up future readers.

  2. Float precision (line 280): alpacadecimal.NewFromFloat(amount) can lose precision with certain decimal values, but for a test helper this is totally fine and keeps things convenient.

Everything else looks solid—good use of s.T().Helper(), proper context propagation, clear assertions.

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

In `@openmeter/billing/charges/service/base_test.go` around lines 272 - 298, Add a
brief inline comment inside grantPromotionalCredits explaining why the
ClosedPeriod uses From: now, To: now (zero-length) to represent an instant
promotional credit grant rather than a service duration; leave
alpacadecimal.NewFromFloat(amount) as-is for test convenience but you may note
its potential precision caveat in the comment if desired.
test/billing/lineengine_test.go (1)

338-338: Prefer the test-scoped context here.

Using context.Background() drops cancellation/lifecycle from the suite test. s.T().Context() keeps this flow tied to the harness like the other newer tests do.

Suggested tweak
-		ctx                = context.Background()
+		ctx                = s.T().Context()

As per coding guidelines, "Use t.Context() when a testing.T or testing.TB is available instead of introducing context.Background() to keep cancellation and test-scoped lifecycle tied to the test harness".

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

In `@test/billing/lineengine_test.go` at line 338, Replace the use of
context.Background() for the test-scoped context so the test lifecycle and
cancellation are tied to the suite; locate the ctx variable assignment (ctx =
context.Background()) in the test and change it to use the suite's test context
via s.T().Context() (i.e., assign ctx = s.T().Context()) so the test inherits
cancellation and timeouts from the harness.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/common/customerbalance.go`:
- Around line 44-45: The code dereferences billingRegistry.Charges when wiring
ChargesService and UsageBasedService, causing a panic if billingRegistry.Charges
is nil; add a nil-guard for billingRegistry.Charges in the provider (where
ChargesService and UsageBasedService are set) — check if billingRegistry.Charges
== nil and return a clear wiring error (or wrap/propagate the existing error
type) instead of accessing Charges.Service or Charges.UsageBasedService; update
the constructor in customerbalance.go to validate billingRegistry.Charges before
assigning ChargesService and UsageBasedService and include that guard in any
related initialization path.

In `@openmeter/billing/charges/service/invoicable_test.go`:
- Around line 1074-1086: The test
TestUsageBasedCreditThenInvoiceFullyCreditedDoesNotAccrueInvoiceUsage in
InvoicableChargesTestSuite needs to explicitly set POSTGRES_HOST=127.0.0.1
before provisioning DB-backed fixtures; add a call at the start of the test
(e.g., via s.T().Setenv("POSTGRES_HOST", "127.0.0.1")) so the DB-backed flow
used by ProvisionBillingProfile and related helpers will not be skipped in
repo-loaded environments.

In `@openmeter/billing/charges/usagebased/adapter/realizationrun.go`:
- Around line 59-60: The Ent update builder call uses a nonexistent method
SetOrClearLineID on the ChargeUsageBasedRunsUpdateOne builder; change the
conditional update in the realization run update path to call
SetNillableBillingInvoiceLineID(input.LineID.OrEmpty()) instead so it matches
the create path and the generated methods (replace SetOrClearLineID with
SetNillableBillingInvoiceLineID on the update variable).

In `@openmeter/billing/charges/usagebased/handler.go`:
- Around line 78-80: The handler currently only rejects negative amounts (if
i.Amount.IsNegative()) while OnInvoiceUsageAccruedInput.Validate() allows zero;
update the validation in handler.go so it rejects zero amounts as well to match
the handler contract — change the condition in the block that appends the error
to treat zero as invalid (e.g., check i.Amount.IsZero() or require
i.Amount.GreaterThan(decimal.Zero)), and adjust the error message if needed;
reference the same validation in OnInvoiceUsageAccruedInput.Validate and the
handler function that currently uses i.Amount.IsNegative().

In `@openmeter/billing/charges/usagebased/service/lineengine.go`:
- Around line 73-79: This branch is hard-failing when a usage-based standard
line's settlement mode isn't CreditThenInvoice, which blocks valid modes like
invoice_only; locate the check that inspects
stateMachine.GetCharge().Intent.SettlementMode alongside stdLine.ID in
lineengine.go and replace the error return with behavior that skips
standard-invoice creation for non-CreditThenInvoice modes (e.g., return nil
without an error) so those charges can follow their own lifecycle; apply the
same change to the equivalent block that uses the same settlement-mode check
later in the file (the block around the other stdLine handling).

In `@openmeter/billing/service/stdinvoicestate.go`:
- Around line 240-243: The OnActive hook on StandardInvoiceStatusIssued
(out.onInvoiceIssued) can fail but Issued has no TriggerFailed path, so errors
from FireAndActivate will escape after finalizeInvoice() runs; move the
onInvoiceIssued activation work into a retryable pre-issued state (or add a new
intermediary state like StandardInvoiceStatusIssuing with Permit(TriggerFailed,
billing.StandardInvoiceStatusIssuingFailed) and have
OnActive(out.onInvoiceIssued) on that state) so failures can transition to
IssuingSyncFailed/failed path and be retried; update stateMachine.Configure
calls to attach OnActive to the new retryable state and ensure TriggerFailed
transitions and FireAndActivate handle the new state flow.

---

Nitpick comments:
In `@openmeter/billing/charges/service/base_test.go`:
- Around line 272-298: Add a brief inline comment inside grantPromotionalCredits
explaining why the ClosedPeriod uses From: now, To: now (zero-length) to
represent an instant promotional credit grant rather than a service duration;
leave alpacadecimal.NewFromFloat(amount) as-is for test convenience but you may
note its potential precision caveat in the comment if desired.

In `@test/billing/lineengine_test.go`:
- Line 338: Replace the use of context.Background() for the test-scoped context
so the test lifecycle and cancellation are tied to the suite; locate the ctx
variable assignment (ctx = context.Background()) in the test and change it to
use the suite's test context via s.T().Context() (i.e., assign ctx =
s.T().Context()) so the test inherits cancellation and timeouts from the
harness.
🪄 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: 4b5e0bb8-9ba0-4acd-b91a-ac5000dcea27

📥 Commits

Reviewing files that changed from the base of the PR and between 24a3be8 and 2cb77ba.

📒 Files selected for processing (32)
  • app/common/charges.go
  • app/common/customerbalance.go
  • openmeter/billing/charges/creditpurchase/lineengine/engine.go
  • openmeter/billing/charges/flatfee/lineengine/engine.go
  • openmeter/billing/charges/flatfee/service.go
  • openmeter/billing/charges/flatfee/service/invoice.go
  • openmeter/billing/charges/meta/triggers.go
  • openmeter/billing/charges/models/invoicedusage/stdinvoice.go
  • openmeter/billing/charges/service/base_test.go
  • openmeter/billing/charges/service/handlers_test.go
  • openmeter/billing/charges/service/invoicable_test.go
  • openmeter/billing/charges/testutils/handlers.go
  • openmeter/billing/charges/usagebased/adapter.go
  • openmeter/billing/charges/usagebased/adapter/invoicedusage.go
  • openmeter/billing/charges/usagebased/adapter/realizationrun.go
  • openmeter/billing/charges/usagebased/handler.go
  • openmeter/billing/charges/usagebased/realizationrun.go
  • openmeter/billing/charges/usagebased/service/creditheninvoice.go
  • openmeter/billing/charges/usagebased/service/creditsonly.go
  • openmeter/billing/charges/usagebased/service/lineengine.go
  • openmeter/billing/charges/usagebased/service/run/correct.go
  • openmeter/billing/charges/usagebased/service/run/create.go
  • openmeter/billing/charges/usagebased/service/run/invoice.go
  • openmeter/billing/lineengine.go
  • openmeter/billing/lineengine/engine.go
  • openmeter/billing/service/gatheringinvoicependinglines.go
  • openmeter/billing/service/stdinvoicestate.go
  • openmeter/ledger/chargeadapter/usagebased.go
  • openmeter/ledger/chargeadapter/usagebased_test.go
  • test/billing/lineengine_test.go
  • test/billing/suite.go
  • test/credits/sanity_test.go

Comment thread app/common/customerbalance.go
Comment thread openmeter/billing/charges/service/invoicable_test.go
Comment thread openmeter/billing/charges/usagebased/adapter/realizationrun.go
Comment thread openmeter/billing/charges/usagebased/handler.go
Comment thread openmeter/billing/charges/usagebased/service/lineengine.go
Comment thread openmeter/billing/service/stdinvoicestate.go Outdated
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/ledger/chargeadapter/usagebased_test.go (1)

179-192: Consider adding test coverage for the non-zero amount (happy path).

This test validates the zero-amount short-circuit nicely, but the happy path where OnInvoiceUsageAccrued actually commits a ledger transaction group isn't covered yet. Given that the caller (BookAccruedInvoiceUsage) returns an error if TransactionGroupID is empty for non-zero amounts (see openmeter/billing/charges/usagebased/service/run/invoice.go:78), having a test that verifies the full commit flow would add confidence.

If this is intentional scaffolding for a future PR, feel free to leave a TODO or ignore this suggestion.

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

In `@openmeter/ledger/chargeadapter/usagebased_test.go` around lines 179 - 192,
Add a happy-path subtest to TestOnUsageBasedInvoiceUsageAccrued that calls
env.handler.OnInvoiceUsageAccrued with a non-zero Amount (instead of
alpacadecimal.Zero) for the CreditThenInvoice settlement mode, then assert no
error and that the returned ref.TransactionGroupID is non-empty; also verify any
expected ledger commit behavior (e.g., that the ledger/transaction group was
created/committed or mocked commit method called) so this covers the flow that
BookAccruedInvoiceUsage relies on (see BookAccruedInvoiceUsage in invoice.go and
the TransactionGroupID contract).
openmeter/billing/charges/service/invoicable_test.go (1)

279-280: Small consistency note on context usage.

This test uses context.Background() while the newer test at line 1188 uses s.T().Context(). Consider updating to s.T().Context() for consistency with the preferred approach that ties the context lifecycle to the test harness.

✨ Optional fix
 func (s *InvoicableChargesTestSuite) TestFlatFeeCreditThenInvoiceFullyCreditedDoesNotAccrueInvoiceUsage() {
-	ctx := context.Background()
+	ctx := s.T().Context()
 	ns := s.GetUniqueNamespace("charges-service-flatfee-credit-then-invoice-fully-credited")

As per coding guidelines, "Use t.Context() when a testing.T or testing.TB is available instead of introducing context.Background() to keep cancellation and test-scoped lifecycle tied to the test harness".

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

In `@openmeter/billing/charges/service/invoicable_test.go` around lines 279 - 280,
Replace the use of context.Background() in the test function
TestFlatFeeCreditThenInvoiceFullyCreditedDoesNotAccrueInvoiceUsage with the
test-scoped context s.T().Context(): locate the test function
(TestFlatFeeCreditThenInvoiceFullyCreditedDoesNotAccrueInvoiceUsage) in
invoicable_test.go and change ctx := context.Background() to ctx :=
s.T().Context() so the test uses the testing harness' lifecycle-aware context.
🤖 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/service/invoicable_test.go`:
- Around line 279-280: Replace the use of context.Background() in the test
function TestFlatFeeCreditThenInvoiceFullyCreditedDoesNotAccrueInvoiceUsage with
the test-scoped context s.T().Context(): locate the test function
(TestFlatFeeCreditThenInvoiceFullyCreditedDoesNotAccrueInvoiceUsage) in
invoicable_test.go and change ctx := context.Background() to ctx :=
s.T().Context() so the test uses the testing harness' lifecycle-aware context.

In `@openmeter/ledger/chargeadapter/usagebased_test.go`:
- Around line 179-192: Add a happy-path subtest to
TestOnUsageBasedInvoiceUsageAccrued that calls env.handler.OnInvoiceUsageAccrued
with a non-zero Amount (instead of alpacadecimal.Zero) for the CreditThenInvoice
settlement mode, then assert no error and that the returned
ref.TransactionGroupID is non-empty; also verify any expected ledger commit
behavior (e.g., that the ledger/transaction group was created/committed or
mocked commit method called) so this covers the flow that
BookAccruedInvoiceUsage relies on (see BookAccruedInvoiceUsage in invoice.go and
the TransactionGroupID contract).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ac4e404d-84e8-407f-ba9e-0157b39134f4

📥 Commits

Reviewing files that changed from the base of the PR and between 2cb77ba and de54bdf.

⛔ Files ignored due to path filters (2)
  • openmeter/ent/db/billinginvoice/billinginvoice.go is excluded by !**/ent/db/**
  • openmeter/ent/db/migrate/schema.go is excluded by !**/ent/db/**
📒 Files selected for processing (9)
  • .agents/skills/charges/SKILL.md
  • openmeter/billing/adapter/invoice.go
  • openmeter/billing/charges/flatfee/service/invoice.go
  • openmeter/billing/charges/service/invoicable_test.go
  • openmeter/billing/service/stdinvoicestate.go
  • openmeter/billing/stdinvoice.go
  • openmeter/ledger/chargeadapter/usagebased.go
  • openmeter/ledger/chargeadapter/usagebased_test.go
  • test/billing/lineengine_test.go
✅ Files skipped from review due to trivial changes (2)
  • .agents/skills/charges/SKILL.md
  • test/billing/lineengine_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • openmeter/billing/charges/flatfee/service/invoice.go

Namespace: input.Charge.Namespace,
ID: input.Charge.Intent.CustomerID,
}
annotations := ledger.ChargeAnnotations(models.NamespacedID{
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.

nit: we have helpers for these in annotations.go lets use those or create one there if not present

@turip turip merged commit 69baee2 into main Apr 16, 2026
25 of 26 checks passed
@turip turip deleted the feat/invoice-accrued-v2 branch April 16, 2026 10:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/billing release-note/feature Release note: Exciting New Features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants