Skip to content

feat(customcurrencies): support flat fee credit then invoice - #4794

Merged
turip merged 2 commits into
mainfrom
feat/flatfee-split-invoice-run-realization
Jul 27, 2026
Merged

feat(customcurrencies): support flat fee credit then invoice#4794
turip merged 2 commits into
mainfrom
feat/flatfee-split-invoice-run-realization

Conversation

@turip

@turip turip commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

Add custom-currency support to flat-fee charges using credit-then-invoice settlement.

Flat-fee realization runs and credit allocations remain denominated in the charge currency. Any uncovered value is converted with the charge's persisted cost basis and represented on the invoice as a single fiat overage line. Gathering previews remain zero-value scheduling placeholders until a draft invoice realization exists.

Charge-managed custom-currency lines reject API-originated updates and deletes so invoice edits cannot diverge from the charge lifecycle. Accrual validation is retry-safe: invalid ledger results persist neither accrued usage nor payment, and the invoice retry action completes normally after the ledger recovers.

The real ledger implementation for custom-currency overage booking remains a separate integration concern; this change defines and exercises the charge-side lifecycle through the mocked ledger handler.

Validation

  • POSTGRES_HOST=127.0.0.1 go test -tags=dynamic -count=1 ./openmeter/billing/charges/service
  • POSTGRES_HOST=127.0.0.1 go test -tags=dynamic -count=1 ./openmeter/ledger/chargeadapter
  • POSTGRES_HOST=127.0.0.1 go test -tags=dynamic -count=1 ./test/credits
  • make lint-go-fast

Summary by CodeRabbit

  • New Features
    • Added custom-currency flat-fee overage support, including fiat conversion and custom-currency overage invoice line mapping.
    • Introduced a new overage accrual callback to drive invoice/ledger workflows.
  • Bug Fixes
    • Improved reconciliation by reconciling runs directly to intent.
    • Payment authorization and settlement now require a positive fiat amount.
    • Prevented mutable invoice line edits/deletions for custom-currency flat-fee managed lines.
  • Tests
    • Expanded coverage for custom-currency flat-fee credit-then-invoice lifecycle and updated payment-related assertions.

Greptile Summary

This PR adds custom-currency support for credit-then-invoice flat-fee charges.

  • Converts uncovered custom-currency value to fiat using the charge’s persisted cost basis.
  • Represents overage as a single fiat invoice line while keeping gathering previews as zero-value placeholders.
  • Adds ledger callbacks and payment handling for custom-currency overage accrual.
  • Reconciles mutable realization runs and protects charge-managed invoice lines from API edits or deletion.
  • Adds lifecycle, retry, reconciliation, and zero-fiat rounding coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported zero-fiat reconciliation defect is corrected by deriving transaction necessity from the rounded fiat overage, with focused rerating coverage.

Important Files Changed

Filename Overview
openmeter/billing/charges/flatfee/service/realizations/credittheninvoice.go Adds custom-currency-aware realization and reconciliation, including correct zero-fiat transaction-state derivation.
openmeter/billing/charges/flatfee/service/linemapper.go Maps custom-currency realization totals into a single converted fiat overage invoice line.
openmeter/billing/charges/flatfee/service/creditheninvoice.go Integrates rerated realization runs into mutable invoice-line reconciliation and lifecycle handling.
openmeter/billing/charges/flatfee/service/payment.go Updates payment callbacks to use realization-run fiat amounts and skip zero-value transactions.
openmeter/ledger/chargeadapter/flatfee.go Extends the flat-fee ledger adapter contract for custom-currency overage accrual.

Sequence Diagram

sequenceDiagram
    participant Billing
    participant Charge as Flat-fee charge
    participant Credits as Credit ledger
    participant Invoice
    Billing->>Charge: Start realization
    Charge->>Credits: Allocate charge-currency credits
    Credits-->>Charge: Uncovered custom-currency total
    Charge->>Charge: Convert overage using persisted cost basis
    Charge->>Invoice: Populate one fiat overage line
    Invoice->>Charge: Accrue and process payment when fiat total is positive
Loading

Reviews (2): Last reviewed commit: "fix(flatfee): preserve zero-fiat reconci..." | Re-trigger Greptile

@turip
turip requested a review from a team as a code owner July 27, 2026 11:37
@turip turip added area/billing release-note/feature Release note: Exciting New Features labels Jul 27, 2026
Comment thread openmeter/billing/charges/flatfee/service/realizations/credittheninvoice.go Outdated
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Flat-fee custom currencies now create overage placeholders, convert overage to fiat during line mapping, reconcile mutable runs against intents, accrue fiat transactions through a new callback, and pass fiat amounts through payment authorization and settlement. Integration and adapter tests cover the updated lifecycle.

Changes

Custom-currency flat-fee lifecycle

Layer / File(s) Summary
Custom-currency contracts and line mapping
openmeter/billing/charges/flatfee/{charge.go,handler.go}, openmeter/billing/charges/flatfee/service/{create,lineengine,linemapper}.go
Adds overage-to-fiat conversion, accrual callback types, custom-currency placeholder lines, fiat overage mapping, and restrictions on API edits and deletes.
Run reconciliation and overage accrual
openmeter/billing/charges/flatfee/service/{creditheninvoice.go,realizations/*}
Reconciles runs directly to intents, derives fiat transaction requirements, and routes invoice usage accrual through standard or custom-currency handlers.
Fiat payment event handling
openmeter/billing/charges/flatfee/service/payment.go, openmeter/ledger/chargeadapter/flatfee.go
Replaces Amount with validated Run and FiatAmount, and uses invoice-line fiat totals for authorization and settlement.
Lifecycle and adapter validation
openmeter/billing/charges/service/*test.go, openmeter/billing/charges/testutils/handlers.go, openmeter/ledger/chargeadapter/flatfee_test.go
Adds custom-currency lifecycle coverage, callback mocks, exact preview-line assertions, and zero-fiat payment rejection tests.

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

Sequence Diagram(s)

sequenceDiagram
  participant Charge
  participant FlatFeeLineMapper
  participant AccrueInvoiceUsage
  participant FlatFeeHandler
  Charge->>FlatFeeLineMapper: Convert custom-currency overage to fiat
  FlatFeeLineMapper->>AccrueInvoiceUsage: provide mapped fiat overage line
  AccrueInvoiceUsage->>FlatFeeHandler: OnCustomCurrencyOverageAccrued
  FlatFeeHandler-->>AccrueInvoiceUsage: return fiat total and transaction group
Loading

Possibly related PRs

Suggested labels: kind/feature

Suggested reviewers: tothandras, chrisgacsal

🚥 Pre-merge checks | ✅ 4 | ❌ 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 (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 matches the main change: adding custom-currency support for flat-fee credit-then-invoice flow.
✨ 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/flatfee-split-invoice-run-realization

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.

@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.

Caution

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

⚠️ Outside diff range comments (1)
openmeter/billing/charges/flatfee/service/realizations/credittheninvoice.go (1)

271-279: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

ReconcileRunToIntent skips the custom-currency fiat check that StartCreditThenInvoiceRun now does.

StartCreditThenInvoiceRun (Lines 146-154) derives NoFiatTransactionRequired from the converted fiat amount for custom currencies, but reconciliation still uses runTotals.Total.IsZero() in the charge currency. After a shrink/extend on a custom-currency charge whose remaining overage converts to zero fiat (the exact "fiat overage rounds to zero" case covered in invoicable_test.go), the run keeps NoFiatTransactionRequired = false while populateCustomCurrencyOverageFromRun maps the line to a zero fiat total. AccrueInvoiceUsageInput.Validate in realizations/invoiceaccrued.go (Lines 52-54) then rejects that combination and invoice issuing fails.

Worth extracting the derivation so both paths stay in sync.

🐛 Suggested fix
 		runTotals := detailedLines.SumTotals().RoundToPrecision(currency)
+		noFiatTransactionRequired, err := noFiatTransactionRequiredForRun(in.Charge, currency, runTotals)
+		if err != nil {
+			return ReconcileRunToIntentResult{}, err
+		}
 
 		runBase, err := s.adapter.UpdateRealizationRun(ctx, flatfee.UpdateRealizationRunInput{
 			ID:                        run.ID,
 			ServicePeriod:             mo.Some(rateableIntent.ServicePeriod),
 			AmountAfterProration:      mo.Some(rateableIntent.AmountAfterProration),
 			Totals:                    mo.Some(runTotals),
-			NoFiatTransactionRequired: mo.Some(runTotals.Total.IsZero()),
+			NoFiatTransactionRequired: mo.Some(noFiatTransactionRequired),
 		})

with the shared helper (also used by StartCreditThenInvoiceRun):

func noFiatTransactionRequiredForRun(charge flatfee.Charge, currency currencies.Currency, runTotals totals.Totals) (bool, error) {
	if runTotals.Total.IsZero() {
		return true, nil
	}

	if !currency.IsCustom() {
		return false, nil
	}

	fiatOverage, err := charge.ConvertCustomCurrencyOverageToFiat(runTotals)
	if err != nil {
		return false, fmt.Errorf("converting custom currency overage to fiat: %w", err)
	}

	return fiatOverage.Amount.IsZero(), 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/flatfee/service/realizations/credittheninvoice.go`
around lines 271 - 279, Extract the shared no-fiat derivation used by
StartCreditThenInvoiceRun into a helper such as noFiatTransactionRequiredForRun,
handling zero totals, non-custom currencies, and custom-currency conversion
errors. Update both StartCreditThenInvoiceRun and ReconcileRunToIntent to use
it, replacing the reconciliation check based only on runTotals.Total.IsZero()
while preserving conversion error propagation.
🧹 Nitpick comments (3)
openmeter/billing/charges/flatfee/service/create.go (1)

189-192: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Centralize the overage line naming rule. Both the gathering placeholder and the realized fiat overage line build the display name with the same "overage" / "%s (overage)" logic. Since the two names must stay identical for the placeholder-to-realization transition to look coherent, a single helper (e.g. overageLineName(name string) string in the flat-fee service package) keeps them from drifting.

  • openmeter/billing/charges/flatfee/service/create.go#L189-L192: replace the inline name derivation with a call to the shared helper.
  • openmeter/billing/charges/flatfee/service/linemapper.go#L149-L153: use the same helper for name before assigning stdLine.Name and the detailed line name.
🤖 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/flatfee/service/create.go` around lines 189 - 192,
The overage display-name logic is duplicated between the gathering placeholder
and realized fiat line. Add a shared overageLineName helper in the flat-fee
service package, then replace the inline derivation in
openmeter/billing/charges/flatfee/service/create.go lines 189-192 and use the
helper for name before assigning stdLine.Name and the detailed line name in
openmeter/billing/charges/flatfee/service/linemapper.go lines 149-153.
openmeter/billing/charges/flatfee/handler.go (1)

111-139: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider validating the fiat currency here too.

Validate() checks the custom currency and the resolved cost basis, but not GetFiatCurrency() — yet both the ledger adapter and the test handler call it right after Validate() succeeds. Folding it in keeps the "input is fully usable after Validate" contract intact.

♻️ Suggested addition
 	if _, err := i.GetCostBasis(); err != nil {
 		errs = append(errs, fmt.Errorf("cost basis: %w", err))
 	}
+
+	if _, err := i.GetFiatCurrency(); err != nil {
+		errs = append(errs, fmt.Errorf("fiat currency: %w", err))
+	}
🤖 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/flatfee/handler.go` around lines 111 - 139, Update
OnCustomCurrencyOverageAccruedInput.Validate to also call
GetFiatCurrency().Validate(), appending any failure with the “fiat currency”
context before returning the validation error, so callers can safely use the
resolved fiat currency after validation succeeds.
openmeter/billing/charges/service/base_test.go (1)

283-291: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Nice helper — could lean on Annotations.GetString here.

The other overage assertions in invoicable_test.go (e.g. lines 323-325) use line.Annotations.GetString(billing.AnnotationKeyReason), which already handles the string extraction. Using it here drops the *string branch and keeps the assertions consistent.

♻️ Suggested simplification
-	switch reason := in.line.Annotations[billing.AnnotationKeyReason].(type) {
-	case string:
-		s.Equal(billing.AnnotationValueReasonOverage, reason)
-	case *string:
-		s.Require().NotNil(reason)
-		s.Equal(billing.AnnotationValueReasonOverage, *reason)
-	default:
-		s.Fail("overage reason annotation has an unexpected type")
-	}
+	reason, ok := in.line.Annotations.GetString(billing.AnnotationKeyReason)
+	s.Require().True(ok, "overage reason annotation is missing or not a string")
+	s.Equal(billing.AnnotationValueReasonOverage, reason)
🤖 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/base_test.go` around lines 283 - 291, In
the overage reason assertion, replace the manual type switch on
in.line.Annotations with Annotations.GetString(billing.AnnotationKeyReason),
then assert the returned value equals billing.AnnotationValueReasonOverage. Keep
the assertion behavior consistent with the existing overage checks in
invoicable_test.go.
🤖 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.

Outside diff comments:
In `@openmeter/billing/charges/flatfee/service/realizations/credittheninvoice.go`:
- Around line 271-279: Extract the shared no-fiat derivation used by
StartCreditThenInvoiceRun into a helper such as noFiatTransactionRequiredForRun,
handling zero totals, non-custom currencies, and custom-currency conversion
errors. Update both StartCreditThenInvoiceRun and ReconcileRunToIntent to use
it, replacing the reconciliation check based only on runTotals.Total.IsZero()
while preserving conversion error propagation.

---

Nitpick comments:
In `@openmeter/billing/charges/flatfee/handler.go`:
- Around line 111-139: Update OnCustomCurrencyOverageAccruedInput.Validate to
also call GetFiatCurrency().Validate(), appending any failure with the “fiat
currency” context before returning the validation error, so callers can safely
use the resolved fiat currency after validation succeeds.

In `@openmeter/billing/charges/flatfee/service/create.go`:
- Around line 189-192: The overage display-name logic is duplicated between the
gathering placeholder and realized fiat line. Add a shared overageLineName
helper in the flat-fee service package, then replace the inline derivation in
openmeter/billing/charges/flatfee/service/create.go lines 189-192 and use the
helper for name before assigning stdLine.Name and the detailed line name in
openmeter/billing/charges/flatfee/service/linemapper.go lines 149-153.

In `@openmeter/billing/charges/service/base_test.go`:
- Around line 283-291: In the overage reason assertion, replace the manual type
switch on in.line.Annotations with
Annotations.GetString(billing.AnnotationKeyReason), then assert the returned
value equals billing.AnnotationValueReasonOverage. Keep the assertion behavior
consistent with the existing overage checks in invoicable_test.go.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 127df9ab-5b3d-4ee9-bfbb-364067fa81cb

📥 Commits

Reviewing files that changed from the base of the PR and between 1210e1a and dc2acde.

📒 Files selected for processing (18)
  • openmeter/billing/charges/flatfee/charge.go
  • openmeter/billing/charges/flatfee/handler.go
  • openmeter/billing/charges/flatfee/service/create.go
  • openmeter/billing/charges/flatfee/service/creditheninvoice.go
  • openmeter/billing/charges/flatfee/service/lineengine.go
  • openmeter/billing/charges/flatfee/service/linemapper.go
  • openmeter/billing/charges/flatfee/service/payment.go
  • openmeter/billing/charges/flatfee/service/payment_test.go
  • openmeter/billing/charges/flatfee/service/realizations/credittheninvoice.go
  • openmeter/billing/charges/flatfee/service/realizations/invoiceaccrued.go
  • openmeter/billing/charges/service/base_test.go
  • openmeter/billing/charges/service/gathering_preview_test.go
  • openmeter/billing/charges/service/handlers_test.go
  • openmeter/billing/charges/service/invoicable_test.go
  • openmeter/billing/charges/service/usagebased_test.go
  • openmeter/billing/charges/testutils/handlers.go
  • openmeter/ledger/chargeadapter/flatfee.go
  • openmeter/ledger/chargeadapter/flatfee_test.go
💤 Files with no reviewable changes (1)
  • openmeter/billing/charges/flatfee/service/payment_test.go

@turip
turip merged commit 9a09c0a into main Jul 27, 2026
26 of 27 checks passed
@turip
turip deleted the feat/flatfee-split-invoice-run-realization branch July 27, 2026 12:55
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