Skip to content

feat: usagebased payment flows#4179

Merged
turip merged 3 commits intomainfrom
feat/usagebased-payment-flows
Apr 20, 2026
Merged

feat: usagebased payment flows#4179
turip merged 3 commits intomainfrom
feat/usagebased-payment-flows

Conversation

@turip
Copy link
Copy Markdown
Member

@turip turip commented Apr 20, 2026

Overview

Usage-based credit_then_invoice charges now model payment as part of the active lifecycle instead of hiding it inside final. The state machine was updated so finalized usage moves into active.payment_pending on invoice issuance, then into active.authorized on payment authorization, and only reaches final once payment is settled. This makes the charge status reflect the real economic lifecycle and keeps the final meta-state reserved for fully settled charges.

Note: Current states are temporary until we have progressive billing in place.

To support that flow, usage-based payment handling was added at the run level. The branch introduces run payment persistence for usage-based realization runs, expands the usage-based handler contract to cover invoice payment authorization and settlement, and reuses the flat-fee ledger handling pattern for authorized and settled events. The billing invoice/line flow now drives those usage-based payment transitions so late-
arriving payment events can update already-finalized usage runs correctly.

The generic charge state machine persistence behavior was also tightened up. FireAndActivate(...) now persists successful transitions directly, and AdvanceUntilStateStable(...) no longer performs an extra write afterward.

Notes for reviewer

Summary by CodeRabbit

  • New Features

    • Invoice-backed charges now remain in active states during payment authorization and settlement and only reach final once payment is fully settled.
    • Introduced explicit payment-authorized and payment-settled handling so invoiced usage and ledger bookings occur at the correct lifecycle points.
  • Tests

    • Expanded unit and end-to-end tests covering authorization, settlement, direct-paid flows, and related state transitions.

@turip turip requested a review from a team as a code owner April 20, 2026 08:29
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 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: 928b8ab4-38db-484d-af7f-2360e24b295a

📥 Commits

Reviewing files that changed from the base of the PR and between 50fba8d and 0f309ad.

📒 Files selected for processing (2)
  • openmeter/ledger/chargeadapter/usagebased.go
  • openmeter/ledger/chargeadapter/usagebased_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • openmeter/ledger/chargeadapter/usagebased_test.go
  • openmeter/ledger/chargeadapter/usagebased.go

📝 Walkthrough

Walkthrough

Adds invoice payment authorization and settlement to usage-based invoice lifecycle: new triggers and statuses, handler callbacks and adapter persistence for run-level invoiced payments, state-machine transitions for payment flows, ledger booking/settlement wiring, validations, and tests; also documents the lifecycle constraint.

Changes

Cohort / File(s) Summary
Triggers & Statuses
openmeter/billing/charges/meta/triggers.go, openmeter/billing/charges/usagebased/statemachine.go
Added TriggerInvoicePaymentAuthorized and TriggerInvoicePaymentSettled; introduced active.payment_pending and active.authorized statuses and included them in Status values.
State machine core
openmeter/billing/charges/statemachine/machine.go, openmeter/billing/charges/statemachine/machine_test.go
FireAndActivate now persists charge base after successful activation; tests added/updated to assert persistence and non-persistence on activation failure.
Usage-based service: credit→invoice flow
openmeter/billing/charges/usagebased/service/creditheninvoice.go, openmeter/billing/charges/usagebased/service/lineengine.go
Split post-invoice finalization into payment-pending → authorized → final; added RecordPaymentAuthorized/RecordPaymentSettled, getRunForLine, and centralized per-line trigger firing (fireLineTrigger); implemented OnPaymentAuthorized/OnPaymentSettled to trigger new events.
Run-level payment service & tests
openmeter/billing/charges/usagebased/service/run/payment.go, openmeter/billing/charges/usagebased/service/run/payment_test.go
Added booking and settlement operations (BookInvoicedPaymentAuthorized, SettleInvoicedPayment) with strict validation, handler delegation, adapter calls, and unit tests for input validation.
Adapters & persistence
openmeter/billing/charges/usagebased/adapter.go, openmeter/billing/charges/usagebased/adapter/payment.go
Expanded Adapter to embed RealizationRunPaymentAdapter; implemented CreateRunPayment and UpdateRunPayment with validation and transactional persistence mapping to DB entities.
Handler interfaces & mocks
openmeter/billing/charges/usagebased/handler.go, openmeter/billing/charges/service/handlers_test.go, openmeter/billing/charges/testutils/handlers.go
Added RunEventInput and new handler methods OnPaymentAuthorized/OnPaymentSettled (with Unimplemented stubs); extended test handler doubles and provided mock implementations for new callbacks.
Ledger integration & tests
openmeter/ledger/chargeadapter/usagebased.go, openmeter/ledger/chargeadapter/usagebased_test.go
Implemented ledger handlers for authorization and settlement flows to resolve/commit receivable funding and settlement transactions; added tests and helpers to assert ledger bucket movements and timestamps.
Line/invoice validation
openmeter/billing/invoicelinesplitgroup.go
Added Validate() for GatheringLineWithInvoiceHeader and StandardLineWithInvoiceHeader to enforce line validity and invoice ID presence.
Invoicable & lifecycle tests / E2E sanity
openmeter/billing/charges/service/invoicable_test.go, test/credits/sanity_test.go
Updated tests to use suite context; added/modified tests covering payment-authorized and payment-settled transitions and a full invoice payment lifecycle sanity test.
Docs / Skill guidance
.agents/skills/charges/SKILL.md
Documented rule: invoice-backed charges remain in active.* detailed statuses until payment lifecycle settles before entering final.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    actor Client
    participant LineEngine
    participant StateMachine
    participant Handler
    participant Adapter as PaymentAdapter
    participant Ledger

    Client->>LineEngine: OnPaymentAuthorized(invoice, run, line)
    LineEngine->>StateMachine: FireAndActivate(TriggerInvoicePaymentAuthorized, payload)
    StateMachine->>Handler: OnPaymentAuthorized(charge, run)
    Handler->>Ledger: CommitGroup(fund receivable)
    Ledger-->>Handler: GroupReference
    StateMachine->>Adapter: CreateRunPayment(authorized)
    Adapter-->>StateMachine: payment.Invoiced
    StateMachine-->>LineEngine: status -> active.authorized

    Client->>LineEngine: OnPaymentSettled(invoice, run, line)
    LineEngine->>StateMachine: FireAndActivate(TriggerInvoicePaymentSettled, payload)
    StateMachine->>Handler: OnPaymentSettled(charge, run)
    Handler->>Ledger: CommitGroup(settle receivable)
    Ledger-->>Handler: GroupReference
    StateMachine->>Adapter: UpdateRunPayment(settled)
    Adapter-->>StateMachine: payment.Invoiced
    StateMachine-->>LineEngine: status -> final
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 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 summarizes the main change: introducing payment flows for usage-based charges, which is the primary focus across all modified 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/usagebased-payment-flows

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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openmeter/billing/charges/service/invoicable_test.go`:
- Around line 1560-1562: The test freezes global time with
clock.FreezeTime(createAt) but never restores it, causing later tests to inherit
a frozen clock; update the test to call the corresponding unfreeze method (e.g.,
clock.UnFreeze() or clock.Unfreeze()) before the flow returns or in a defer
immediately after FreezeTime to ensure time is restored; reference the existing
createAt and clock.FreezeTime(...) call and add the matching clock.UnFreeze()
(or appropriate UnfreezeTime) to guarantee global clock state is reset.

In `@openmeter/billing/charges/usagebased/adapter/payment.go`:
- Around line 14-28: CreateRunPayment currently validates runID and in
separately but can persist a payment where run and payment namespaces differ;
add an explicit namespace equality guard before the transaction: check that
runID.Namespace (or equivalent field on usagebased.RealizationRunID) equals
in.Namespace and return a descriptive error if they mismatch. Place this check
in CreateRunPayment just after the Validate() calls and before
entutils.TransactingRepo is invoked so CreateInvoiced and SetRunID cannot create
a cross-namespace record.

In `@openmeter/billing/charges/usagebased/service/creditheninvoice.go`:
- Around line 111-115: The state machine currently permits re-entry via
Configure(...).PermitReentry(meta.TriggerInvoicePaymentAuthorized) into
usagebased.StatusActiveAuthorized but re-running RecordPaymentAuthorized can
attempt to persist run.Payment again and be rejected by
BookInvoicedPaymentAuthorized; either remove the PermitReentry for
TriggerInvoicePaymentAuthorized or make RecordPaymentAuthorized idempotent by
checking run.Payment (or via BookInvoicedPaymentAuthorized) and returning early
when the payment is already authorized; update the same pattern in the other
Configure block that mirrors this behavior (the second Configure around the
275-300 region) so retried webhooks become no-ops instead of failing.

In `@openmeter/ledger/chargeadapter/usagebased.go`:
- Line 7: The ledger booking code currently uses input.Charge.Intent.InvoiceAt
for transaction timestamps which backdates events; change each payment handler
to capture eventTime := clock.Now() once at the start of handling the payment
event and use eventTime for all ledger bookings instead of
input.Charge.Intent.InvoiceAt (replace occurrences of
input.Charge.Intent.InvoiceAt in the handlers and in the blocks around the
occurrences at the other spots referenced). Locate uses of
input.Charge.Intent.InvoiceAt (including the blocks around lines 130-135 and
191-196) and update them to use the newly captured eventTime variable so
authorization/settlement entries reflect the actual event time.

In `@test/credits/sanity_test.go`:
- Around line 854-875: The "late arriving usage is ignored once the invoice
finalization cutoff has passed" test is inconsistent: the added event's StoredAt
(2026-02-02T12:00:00Z) is before the collection cutoff so it gets included; to
fix, update the test so the StoredAt is after
invoice.DefaultCollectionAtForStandardInvoice() (e.g. set StoredAt to a
timestamp > invoice.DefaultCollectionAtForStandardInvoice()) and assert the
invoice totals remain the original values (no +25), or alternatively rename the
subtest to indicate the event is included before cutoff; changes should be made
where s.MockStreamingConnector.AddSimpleEvent is called and in the subsequent
assertions that check invoice.Lines/Totals.
🪄 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: 816e9297-66ed-41f6-99ae-5ace0f455596

📥 Commits

Reviewing files that changed from the base of the PR and between 13d183b and ca70af1.

⛔ Files ignored due to path filters (2)
  • openmeter/ent/db/chargeusagebased/chargeusagebased.go is excluded by !**/ent/db/**
  • openmeter/ent/db/migrate/schema.go is excluded by !**/ent/db/**
📒 Files selected for processing (19)
  • .agents/skills/charges/SKILL.md
  • openmeter/billing/charges/meta/triggers.go
  • openmeter/billing/charges/service/handlers_test.go
  • openmeter/billing/charges/service/invoicable_test.go
  • openmeter/billing/charges/statemachine/machine.go
  • openmeter/billing/charges/statemachine/machine_test.go
  • openmeter/billing/charges/testutils/handlers.go
  • openmeter/billing/charges/usagebased/adapter.go
  • openmeter/billing/charges/usagebased/adapter/payment.go
  • openmeter/billing/charges/usagebased/handler.go
  • openmeter/billing/charges/usagebased/service/creditheninvoice.go
  • openmeter/billing/charges/usagebased/service/lineengine.go
  • openmeter/billing/charges/usagebased/service/run/payment.go
  • openmeter/billing/charges/usagebased/service/run/payment_test.go
  • openmeter/billing/charges/usagebased/statemachine.go
  • openmeter/billing/invoicelinesplitgroup.go
  • openmeter/ledger/chargeadapter/usagebased.go
  • openmeter/ledger/chargeadapter/usagebased_test.go
  • test/credits/sanity_test.go

Comment thread openmeter/billing/charges/service/invoicable_test.go
Comment thread openmeter/billing/charges/usagebased/adapter/payment.go
Comment thread openmeter/billing/charges/usagebased/service/creditheninvoice.go
Comment thread openmeter/ledger/chargeadapter/usagebased.go
Comment thread test/credits/sanity_test.go Outdated
@turip turip added release-note/feature Release note: Exciting New Features area/billing labels Apr 20, 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.

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

274-336: RecordPaymentAuthorized/RecordPaymentSettled + getRunForLine look good.

Flow is tidy: validate input → find the run by LineID → delegate to Runs.Book*/Settle* → persist the updated run. Since LineID on realizations is only populated by StartInvoiceCreatedRun for the final-realization run in this settlement mode, the first-match lookup in getRunForLine is unambiguous in practice.

One tiny nit you can take or leave: the error wrapping in RecordPaymentSettled says "settle invoice payment: %w" and in RecordPaymentAuthorized says "authorize invoice payment: %w" — fine, just make sure these line up with how you want error logs to read up the stack. No change needed if that's intentional.

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

In `@openmeter/billing/charges/usagebased/service/creditheninvoice.go` around
lines 274 - 336, The error wrapping strings in RecordPaymentAuthorized and
RecordPaymentSettled are inconsistent; standardize them so logs read uniformly
(pick one phrasing, e.g., "authorize invoiced payment: %w" or "settle invoiced
payment: %w") by updating the fmt.Errorf calls in methods
RecordPaymentAuthorized and RecordPaymentSettled (and keep the existing "update
realization run: %w" wrappers unchanged for SetRealizationRun errors); ensure
the chosen wording is used in both BookInvoicedPaymentAuthorized and
SettleInvoicedPayment error wraps.
openmeter/billing/charges/service/invoicable_test.go (1)

1531-1674: TestUsageBasedCreditThenInvoiceDirectPaidFlow is a solid addition.

This nails the TriggerPaid-straight-from-PaymentProcessingPending path, correctly asserts both onPaymentAuthorized + onPaymentSettled fire exactly once, and confirms the run ends on StatusFinal with both Payment.Authorized/Payment.Settled transaction group IDs persisted. The defer clock.UnFreeze() on line 1562 is a nice cleanup too. ✨

One small consistency note: line 1589 calls res[0].GetChargeID() with a two-return form (valid since res[0] is type charges.Charge), while most other tests in the file go AsUsageBasedCharge() first and then call GetChargeID() with a single return (e.g., lines 1062–1063). Both work fine, but aligning with the file's dominant pattern would improve readability.

🤖 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 1531 -
1674, The test TestUsageBasedCreditThenInvoiceDirectPaidFlow currently calls
res[0].GetChargeID() using the two-value form; to match the file's dominant
pattern and improve readability, call AsUsageBasedCharge() on res[0] first and
then call GetChargeID() (i.e., use res[0].AsUsageBasedCharge().GetChargeID() or
assign the result of AsUsageBasedCharge() to a variable and call GetChargeID()
on it) so the single-return GetChargeID() form is used consistently with other
tests like those around lines 1062–1063.
🤖 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 1531-1674: The test TestUsageBasedCreditThenInvoiceDirectPaidFlow
currently calls res[0].GetChargeID() using the two-value form; to match the
file's dominant pattern and improve readability, call AsUsageBasedCharge() on
res[0] first and then call GetChargeID() (i.e., use
res[0].AsUsageBasedCharge().GetChargeID() or assign the result of
AsUsageBasedCharge() to a variable and call GetChargeID() on it) so the
single-return GetChargeID() form is used consistently with other tests like
those around lines 1062–1063.

In `@openmeter/billing/charges/usagebased/service/creditheninvoice.go`:
- Around line 274-336: The error wrapping strings in RecordPaymentAuthorized and
RecordPaymentSettled are inconsistent; standardize them so logs read uniformly
(pick one phrasing, e.g., "authorize invoiced payment: %w" or "settle invoiced
payment: %w") by updating the fmt.Errorf calls in methods
RecordPaymentAuthorized and RecordPaymentSettled (and keep the existing "update
realization run: %w" wrappers unchanged for SetRealizationRun errors); ensure
the chosen wording is used in both BookInvoicedPaymentAuthorized and
SettleInvoicedPayment error wraps.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: eed9f857-cc5f-41dc-849c-6043a5a8783d

📥 Commits

Reviewing files that changed from the base of the PR and between ca70af1 and 50fba8d.

📒 Files selected for processing (4)
  • openmeter/billing/charges/service/invoicable_test.go
  • openmeter/billing/charges/usagebased/adapter/payment.go
  • openmeter/billing/charges/usagebased/service/creditheninvoice.go
  • test/credits/sanity_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/credits/sanity_test.go

Copy link
Copy Markdown
Contributor

@GAlexIHU GAlexIHU left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@turip turip merged commit 2efdd3a into main Apr 20, 2026
25 of 26 checks passed
@turip turip deleted the feat/usagebased-payment-flows branch April 20, 2026 11:14
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