Skip to content

fix(billing): [OM-434] delete empty custom-currency overage lines - #4843

Merged
turip merged 5 commits into
mainfrom
fix/om-434-delete-empty-custom-currency-overage-lines
Aug 5, 2026
Merged

fix(billing): [OM-434] delete empty custom-currency overage lines#4843
turip merged 5 commits into
mainfrom
fix/om-434-delete-empty-custom-currency-overage-lines

Conversation

@turip

@turip turip commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

Custom-currency credit-then-invoice charges no longer keep an overage line when the resolved fiat amount is zero.

This applies when:

  • credits fully cover the charge;
  • no billable usage remains;
  • a positive custom-currency overage rounds to zero in fiat.

Why

The fiat invoice represents only the overage remaining after the charge has been realized in its custom currency. Given that this is just an overage being billed, we do not want to add it to a fiat invoice unless there is an actual fiat amount due.

The deletion decision follows Totals.Amount rather than Totals.Total semantics. If fiat credit allocation for overage lines is added later, a line with a positive fiat amount but a zero final total must remain on the invoice so the customer can see the fiat credit usage.

Behavioral changes

  • Zero-fiat overage lines are removed during invoice collection.
  • Gathering invoice previews omit zero-value custom-currency overage placeholders.
  • The charge completes without invoice accrual or payment activity for that overage.
  • Realization history, credit allocations, and resolved cost-basis information remain preserved.
  • Usage-based progressive and final realizations follow the same behavior.
  • Flat-fee and usage-based charges now behave consistently.
  • Ordinary zero-total lines that are not custom-currency overages remain unchanged.

Known limitation

An invoice whose only line is the removed overage line remains present as an empty invoice. Deleting empty invoices will be handled separately.

Validation

  • go test ./openmeter/billing/charges/...
  • make lint-go-fast

The database-backed lifecycle suite compiles but was skipped locally because Docker/Postgres was unavailable.

Summary by CodeRabbit

  • New Features
    • Added handling for zero-value overage charges, allowing completion without invoices or payment settlement.
    • Added clearer lifecycle tracking for charge lines across previews, invoicing, collection, and manual attachment.
  • Bug Fixes
    • Prevented zero-value placeholder lines from appearing in invoice previews.
    • Preserved billing history while avoiding unnecessary cleanup of completed overage records.
  • Tests
    • Expanded coverage for custom-currency overages, deleted lines, persisted completion, previews, and collection workflows.

Greptile Summary

The PR removes custom-currency overage lines whose resolved fiat amount is zero while preserving realization and credit history.

  • Adds dedicated zero-fiat completion states for flat-fee and usage-based charges.
  • Marks zero-value presentation lines deleted during previews and collection.
  • Expands historical detailed-line loading and updates lifecycle coverage.
  • Extends generated charge-status enum metadata for the new completion state.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
openmeter/billing/charges/flatfee/service/creditheninvoice.go Adds a terminal zero-fiat overage path that preserves the immutable flat-fee realization while bypassing invoice issuance and payment.
openmeter/billing/charges/flatfee/service/lineengine.go Remaps completed runs by line ID and prevents deleted zero-fiat presentation lines from triggering destructive realization cleanup.
openmeter/billing/charges/flatfee/service/linemapper.go Makes line population lifecycle-aware and soft-deletes zero-fiat custom-currency overages during preview and collection.
openmeter/billing/charges/usagebased/service/creditheninvoice.go Adds zero-fiat completion routing and treats terminal non-invoiced realizations as settled where appropriate.
openmeter/billing/charges/usagebased/service/lineengine.go Supports run lookup after finalization and retains realization history when its zero-fiat invoice line is deleted.
openmeter/billing/charges/usagebased/service/payments.go Extends settlement evaluation to include qualifying zero-fiat realizations that intentionally have no invoice usage.
openmeter/billing/charges/flatfee/adapter/detailedline.go Loads detailed lines for both current and prior realization runs so preserved history remains available.
openmeter/ent/db/migrate/schema.go Updates generated schema metadata to recognize the new intermediate charge status.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Custom-currency charge realization] --> B[Allocate credits and resolve fiat amount]
  B --> C{Fiat Amount is zero?}
  C -->|No| D[Issue invoice and settle payment]
  C -->|Yes| E[Mark overage line deleted]
  E --> F[Finalize realization without fiat transaction]
  F --> G[Preserve run, credit allocations, and detailed history]
  D --> H[Complete charge lifecycle]
  G --> H
Loading

Reviews (4): Last reviewed commit: "test(billing): cover usage overage fiat ..." | Re-trigger Greptile

Context used:

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds zero-fiat custom-currency overage completion states, finalization, settlement handling, stage-aware line population, deleted-line behavior, cleanup exceptions, broader detailed-line retrieval, and regression coverage.

Changes

Zero-fiat overage lifecycle

Layer / File(s) Summary
Completion and settlement state handling
openmeter/billing/charges/{flatfee,usagebased}/...
State machines finalize zero-fiat overage runs without invoices or payment settlement. Settlement checks now include all realization runs.
Stage-aware line population and cleanup
openmeter/billing/charges/{flatfee,usagebased}/service/{linemapper,lineengine,creditheninvoice}.go
Population calls record lifecycle stages. Zero-fiat overage lines are deleted during preview and collection completion. Cleanup preserves zero-fiat overage records.
Detailed-line retrieval across runs
openmeter/billing/charges/flatfee/adapter/...
Detailed-line fetching now loads current and prior realization runs and supports charges without runs.
Lifecycle and regression validation
openmeter/billing/charges/service/*_test.go, openmeter/billing/charges/{flatfee,usagebased}/service/*_test.go
Tests cover deleted lines, preview counts, stage validation, persisted completion, settlement, detailed-line retrieval, and enabled lifecycle cases.

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

Possibly related PRs

Suggested reviewers: tothandras

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.26% 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 summarizes the main change: deleting empty custom-currency overage lines in billing.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/om-434-delete-empty-custom-currency-overage-lines

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 release-note/feature Release note: Exciting New Features area/billing labels Aug 3, 2026
@turip
turip marked this pull request as ready for review August 3, 2026 15:17
@turip
turip requested a review from a team as a code owner August 3, 2026 15:17

@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/service/usagebased_test.go (1)

185-355: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add a usage-based rounding-to-zero lifecycle case.

The matrix covers zero overage from credits or absent usage. It does not cover a positive TOKENS overage that rounds to zero in USD.

Add a fractional-usage case with a positive run total and zero fiat invoice total. This helper runs both final and progressive variants, so it will protect both paths.

As per path instructions, “Make sure the tests are comprehensive and cover the changes.” The PR objective includes positive custom-currency overage that rounds to zero in fiat.

🤖 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/usagebased_test.go` around lines 185 - 355,
Add a usage-based lifecycle case to the tests table with fractional usage
producing a positive TOKENS run total while the converted USD invoice total
rounds to zero. Set expectations for both run creation and collection phases,
including no payment settlement and preserving the positive overage line, so the
shared helper exercises final and progressive paths.

Source: Path instructions

🧹 Nitpick comments (3)
openmeter/billing/charges/flatfee/service/linemapper.go (1)

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

Document why only these two stages mark the overage line deleted.

The condition restricts zero-fiat-overage deletion to standardLinePopulationStageGatheringPreview and standardLinePopulationStageCollectionCompleted, excluding invoiceCreated, manualAttachment, and intentReconciliation. This is a meaningful lifecycle rule, but nothing here explains why these two stages are the right places to delete the line and why the others are excluded.

Add a short comment above the condition stating the domain reason, for example: the actual overage amount is not known until collection completes credit allocation, so deletion cannot be decided earlier than collectionCompleted (and gatheringPreview is a display-only stage with no accrual).

As per coding guidelines: "Comments and docstrings should explain non-obvious intent, domain constraints, lifecycle state, and failure consequences; do not narrate visible conditions" and "Document domain helpers whose names compress important business semantics, including observable behavior and why cases are excluded."

🤖 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/linemapper.go` around lines 215 -
220, Add a concise domain comment immediately above the stage condition in the
line-mapping logic, explaining that overage deletion requires the actual amount
after collection completes credit allocation, while gatheringPreview is
display-only and has no accrual; clarify why the other lifecycle stages are
excluded without merely restating the condition.

Source: Coding guidelines

openmeter/billing/charges/usagebased/service/linemapper.go (2)

158-166: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider validating Charge and Run here too.

populateStandardLineFromRunInput.Validate() only checks Stage. The flat-fee sibling, populateFlatFeeStandardLineFromRunInput.Validate(), also validates Charge, validates Run, and confirms the run's service period matches the charge's effective service period.

Since this is the first validation added to this struct, extending it now to cover Charge/Run catches malformed input earlier and keeps the two packages' population contracts consistent.

🤖 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/usagebased/service/linemapper.go` around lines 158
- 166, Extend populateStandardLineFromRunInput.Validate to validate both Charge
and Run in addition to Stage, appending each validation error to errs before
constructing the combined validation error. Match
populateFlatFeeStandardLineFromRunInput.Validate by also checking that the run
service period matches the charge effective service period, preserving the
existing error aggregation behavior.

325-330: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document why only these two stages mark the overage line deleted.

Same gap as openmeter/billing/charges/flatfee/service/linemapper.go: the condition scopes deletion to standardLinePopulationStageGatheringPreview and standardLinePopulationStageCollectionCompleted without explaining why, or why invoiceCreated/manualAttachment are excluded.

As per coding guidelines: "Comments and docstrings should explain non-obvious intent, domain constraints, lifecycle state, and failure consequences; do not narrate visible conditions."

🤖 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/usagebased/service/linemapper.go` around lines 325
- 330, In the overage-line deletion block, add a concise comment explaining the
lifecycle/domain reason deletion applies only during
standardLinePopulationStageGatheringPreview and
standardLinePopulationStageCollectionCompleted, including why invoiceCreated and
manualAttachment are excluded. Anchor the comment to the condition using
isZeroFiatAmountOverageRun and do not merely restate the stage names.

Source: Coding guidelines

🤖 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/service/usagebased_test.go`:
- Around line 185-355: Add a usage-based lifecycle case to the tests table with
fractional usage producing a positive TOKENS run total while the converted USD
invoice total rounds to zero. Set expectations for both run creation and
collection phases, including no payment settlement and preserving the positive
overage line, so the shared helper exercises final and progressive paths.

---

Nitpick comments:
In `@openmeter/billing/charges/flatfee/service/linemapper.go`:
- Around line 215-220: Add a concise domain comment immediately above the stage
condition in the line-mapping logic, explaining that overage deletion requires
the actual amount after collection completes credit allocation, while
gatheringPreview is display-only and has no accrual; clarify why the other
lifecycle stages are excluded without merely restating the condition.

In `@openmeter/billing/charges/usagebased/service/linemapper.go`:
- Around line 158-166: Extend populateStandardLineFromRunInput.Validate to
validate both Charge and Run in addition to Stage, appending each validation
error to errs before constructing the combined validation error. Match
populateFlatFeeStandardLineFromRunInput.Validate by also checking that the run
service period matches the charge effective service period, preserving the
existing error aggregation behavior.
- Around line 325-330: In the overage-line deletion block, add a concise comment
explaining the lifecycle/domain reason deletion applies only during
standardLinePopulationStageGatheringPreview and
standardLinePopulationStageCollectionCompleted, including why invoiceCreated and
manualAttachment are excluded. Anchor the comment to the condition using
isZeroFiatAmountOverageRun and do not merely restate the stage names.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6e8f1641-a8a0-4938-84e9-dfa3828ae071

📥 Commits

Reviewing files that changed from the base of the PR and between 04651cb and 7085b8e.

📒 Files selected for processing (15)
  • 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/statemachine.go
  • openmeter/billing/charges/service/base_test.go
  • openmeter/billing/charges/service/gathering_preview_test.go
  • openmeter/billing/charges/service/invoicable_test.go
  • openmeter/billing/charges/service/usagebased_test.go
  • openmeter/billing/charges/usagebased/service/creditheninvoice.go
  • openmeter/billing/charges/usagebased/service/creditheninvoice_test.go
  • openmeter/billing/charges/usagebased/service/lineengine.go
  • openmeter/billing/charges/usagebased/service/linemapper.go
  • openmeter/billing/charges/usagebased/service/linemapper_test.go
  • openmeter/billing/charges/usagebased/service/payments.go
  • openmeter/billing/charges/usagebased/statemachine.go

@turip
turip force-pushed the fix/om-434-delete-empty-custom-currency-overage-lines branch from 7085b8e to 9e1c81e Compare August 3, 2026 16:31
@turip

turip commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Bot feedback disposition:

  • Addressed the usage-based positive-overage/zero-fiat coverage in 482464844. The existing shared lifecycle harness now exercises both final and progressive realizations: the positive TOKENS run is preserved, the USD amount rounds to zero, the overage line is deleted, and no payment is booked.
  • I did not apply the suggested flat-fee/usage-based stage-comment wording because its explanation is incomplete: the placeholder must survive invoice creation so it can participate in collection; gathering preview is display-only, while collection completion provides the stable post-allocation result used for deletion.
  • I did not copy the flat-fee service-period equality check into usage-based validation because progressive usage runs legitimately cover only a subperiod of the charge.
  • The docstring-coverage item is a non-blocking generic warning rather than an actionable review finding.

CodeRabbit's refreshed review passes. GitHub exposes no unresolved inline review threads for these findings because they were posted in the review summary.

@turip
turip force-pushed the fix/om-434-delete-empty-custom-currency-overage-lines branch from 4824648 to 2359e94 Compare August 4, 2026 15:58
@turip
turip merged commit 769279e into main Aug 5, 2026
27 checks passed
@turip
turip deleted the fix/om-434-delete-empty-custom-currency-overage-lines branch August 5, 2026 08:48

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

Pre-landing review

Scope looks clean — the diff does what the description says, for both engines. go build, go vet, and all runnable unit tests pass. Two notes before the findings:

  • No migration needed, confirmed: status_detailed is character varying, not a Postgres enum type (20260325084446_charges-refactor-tables.up.sql:23), so the new enum value only regenerates validators. The usual new-enum-value trap doesn't apply.
  • The Postgres lifecycle suites could not run in my environment either, so everything below is source-level analysis.

9 findings — 4 I'd want resolved before landing.


1. Soft-deleted zero-fiat line is still handed to OnInvoiceIssued

onCollectionCompleted mutates the in-memory line set via m.Invoice.Lines.ReplaceLinesByID(lines...) (openmeter/billing/service/stdinvoicestate.go:1001). onInvoiceIssued then reads m.Invoice.Lines.OrEmpty() (stdinvoicestate.go:1014), and groupStandardLinesByEngine (openmeter/billing/service/lineengine.go:167-170) applies no DeletedAt filter. Neither engine skips deleted lines — flat-fee OnInvoiceIssued (openmeter/billing/charges/flatfee/service/lineengine.go:931-942) calls FireAndActivate(TriggerInvoiceIssued) with no CanFire guard, and the charge is by then in StatusFinal, which permits no such trigger (flatfee/service/creditheninvoice.go:169-174).

This is new behavior: before this PR every expectLineDeleted: true lifecycle case was s.T().Skip(...)ed, so nothing produced a soft-deleted line during collection. The tests pass because they stop at DraftManualApprovalNeeded and re-issue in a separate call that refetches without deleted lines. An invoice that walks collecting → issuing in a single pass (auto-approval, zero draft period) looks like it would fail issuance.

Worth a test that advances collecting → issuing without an intermediate reload.

2. The flat-fee zero-fiat skip in cleanupDeletedStandardLines is unreachable

openmeter/billing/charges/flatfee/service/lineengine.go:775 sits after the guard at line 768:

if charge.Realizations.CurrentRun != nil && charge.Realizations.CurrentRun.ID.ID == run.ID.ID {
    return fmt.Errorf("flat fee standard line[%s] cannot be deleted because realization run[%s] is still current for charge[%s]", ...)
}

FinalizeZeroFiatAmountOverageRun only sets Immutable: mo.Some(true) and never detaches the run. The PR's own test asserts exactly that:

s.Equal(flatfee.StatusFinal, charge.Status)
s.Require().NotNil(charge.Realizations.CurrentRun)
s.Empty(charge.Realizations.PriorRuns)

So the run is always still current when cleanup runs, the guard hard-errors first, and the skip below never executes. The usage-based side clears CurrentRealizationRunID (usagebased/service/creditheninvoice.go:604) — the asymmetry also leaves a flat-fee charge in StatusFinal with a current, immutable run, and StatusFinal still permits Extend/Shrink.

Either detach the run the way usage-based does, or move the zero-fiat check above the current-run guard — but as written the branch is dead.

3. The skip in OnMutableStandardLinesDeletedBySystem is unconditional and can leak credit allocations

openmeter/billing/charges/usagebased/service/lineengine.go:770 bypasses deleteMutableStandardLineRealization, which is the only caller of CorrectAllCredits and markMutableStandardLineRunDeleted (lineengine.go:826-838).

A usage-based custom-currency run is flagged NoFiatTransactionRequired at run creation (usagebased/service/run/create.go:219-229), well before collection. So a system delete of that still-mutable line for an unrelated reason — subscription change, invoice delete — now skips credit correction entirely. In the "fully covered by credits" case that leaves 10 TOKENS booked against the customer with the line gone, and CurrentRealizationRunID still pointing at a run whose line no longer exists.

The comment says the skip is for the collection-time deletion, but the condition doesn't distinguish that from any other system delete. Same shape at flatfee/service/lineengine.go:775.

4. No backfill story for charges already in active.realization.processing

StatusActiveRealizationProcessing previously only exited via TriggerInvoiceIssued. It now also permits TriggerNext → ZeroFiatAmountOverageCompleted gated purely on the persisted NoFiatTransactionRequired flag (flatfee/service/creditheninvoice.go:127-131, usagebased/service/creditheninvoice.go:132-136).

Charges sitting in processing at deploy time already have that flag set, but their invoice line was populated pre-deploy and is not marked deleted. On the next advance they take the new transition with a live line still on the invoice — and then run into finding #1 when that invoice is issued.


Smaller items

5. Dead branch in areAllRealizationRunsSettledopenmeter/billing/charges/usagebased/service/payments.go:128. isZeroFiatAmountOverageRun requires run.NoFiatTransactionRequired, and line 136 already does if run.NoFiatTransactionRequired { continue }. The new continue at 128-130 changes nothing; only the (run.InvoiceUsage != nil || isZeroFiatAmountOverage) clause at line 124 is load-bearing. Suggest dropping the branch and moving the comment onto line 124.

6. openmeter/billing/charges/flatfee/service/ has zero test files. Every new flat-fee path — FinalizeZeroFiatAmountOverageRun, IsCurrentRunZeroFiatAmountOverage, isZeroFiatAmountOverageRun, standardLinePopulationStage.Validate, the mapper deletion, the cleanup skip — is verifiable only through the Postgres suites. The usage-based twin has unit tests for all of these. And flat-fee isn't a mirror: it performs a DB write and goes straight to StatusFinal, bypassing AwaitingPaymentSettlement. Given #2 above, unit coverage here would have caught the dead branch.

7. The gathering_preview half of the flat-fee delete condition is unreachableflatfee/service/linemapper.go:215. BuildStandardLinesForGatheringPreview short-circuits every custom-currency line at lineengine.go:87-95 (DeletedAt, Validate, continue) before any populate call, and populateCustomCurrencyOverageFromRun only runs when IsCustom(). So that stage can't reach line 215 — the condition can be reduced to collection_completed.

8. Preview semantics now diverge between the engines. Flat-fee omits all custom-currency preview lines; usage-based keeps its line and deletes only when the pre-credit total is zero (usagebased/service/run/preview.go:140 sets the flag from runTotals.Total.IsZero(), and preview deliberately doesn't allocate credits). Net effect: usage-based customers see a preview overage that will usually vanish at collection, flat-fee customers see nothing. Worth a deliberate call on which is right.

9. Unbounded per-hook detailed-line loadflatfee/adapter/detailedline.go:22. FetchDetailedLines now queries RunIDIn(currentRun + all priorRuns), and newStateMachineForStandardLine always requests ExpandDetailedLines (flatfee/service/lineengine.go:866). That state machine is constructed per standard line on every invoice hook, so a charge that accumulated prior runs through prorations pays an ever-growing load — even though only the GetByLineID run is used.


One hardening suggestion: at usagebased/service/linemapper.go:325 the deletion keys off the persisted run.NoFiatTransactionRequired, while the function has just computed fiatOverage.Amount fresh three lines above. Asserting fiatOverage.Amount.IsZero() alongside the flag — and erroring if they disagree — would turn any future drift between the two into a loud failure rather than a silently dropped invoice line.

The empty-invoice leftover is already documented as a known limitation, so not flagging it.

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.

3 participants