Skip to content

fix(billing): route API invoice delete to line engines#4651

Merged
turip merged 3 commits into
mainfrom
feat/api-invoice-delete-charge-managed-lines
Jul 6, 2026
Merged

fix(billing): route API invoice delete to line engines#4651
turip merged 3 commits into
mainfrom
feat/api-invoice-delete-charge-managed-lines

Conversation

@turip

@turip turip commented Jul 6, 2026

Copy link
Copy Markdown
Member

Summary

  • route API standard-invoice deletion through mutable invoice line API delete hooks before marking the invoice deleted
  • let usage-based charge-managed lines reject unsupported invoice deletion with the existing charge-managed validation issue
  • let flat-fee-only invoice deletion run the flat-fee manual delete cleanup path
  • add billing service and subscription-sync regression coverage for the API-delete behavior

Root Cause

API standard-invoice deletion previously skipped OnMutableInvoiceLinesEditedViaAPI. That meant charge-backed lines were not asked to validate or perform their manual-delete side effects before the invoice entered deletion. Usage-based progressive invoice lines could therefore be deleted at invoice scope without surfacing the charge-managed-line rejection, while flat-fee-only invoice deletion did not exercise the same manual cleanup path as deleting the line through the invoice API.

Validation

  • env GOCACHE=/private/tmp/openmeter-go-build-cache POSTGRES_HOST=127.0.0.1 go test -tags=dynamic ./openmeter/billing/worker/subscriptionsync/service -run 'TestCreditThenInvoiceScenarios/TestDeleteStandardInvoice' -count=1 -v\n- env GOCACHE=/private/tmp/openmeter-go-build-cache POSTGRES_HOST=127.0.0.1 go vet -tags=dynamic ./openmeter/billing/service ./openmeter/billing/worker/subscriptionsync/service\n- env GOCACHE=/private/tmp/openmeter-go-build-cache POSTGRES_HOST=127.0.0.1 go test -tags=dynamic ./openmeter/billing/service -run TestDeleteInvoice -count=1 -v

Summary by CodeRabbit

  • Bug Fixes
    • API-driven invoice deletions now pre-validate which standard lines are eligible, rejecting invoices containing active usage-based (charge-managed) lines with a clear validation error.
    • Deletion dispatch now correctly processes only still-active deleted standard lines and validates line-engine outcomes before completing.
    • Deletion operations now fail early and avoid marking the invoice deleted when charge-managed updates can’t be applied.
  • Tests
    • Added/updated coverage for API delete behaviors across usage-based, flat-fee-only, and mixed/edge invoice scenarios.

@turip turip requested a review from a team as a code owner July 6, 2026 12:38
@turip turip added release-note/bug-fix Release note: Bug Fixes area/billing labels Jul 6, 2026
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

An error occurred during the review process. Please try again later.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/api-invoice-delete-charge-managed-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.

@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR routes API invoice deletion through invoice-line engine behavior. The main changes are:

  • Standard invoice API deletes now dispatch active lines through mutable-line delete hooks.
  • Unsupported usage-based invoice deletes are rejected before marking invoices deleted.
  • Gathering invoice deletes now route through a dedicated service path.
  • Tests cover usage-based rejection, flat-fee cleanup, and gathering invoice deletion.

Confidence Score: 4/5

This is close, but this delete guard should be fixed before merging.

  • The new preflight blocks usage-based charge lines but still lets credit-purchase charge lines through.
  • Mixed flat-fee and credit-purchase invoices can still run flat-fee cleanup before the later charge-managed rejection.
  • The issue is contained to the invoice delete path and has a direct guard fix.

openmeter/billing/httpdriver/invoice.go

Important Files Changed

Filename Overview
openmeter/billing/httpdriver/invoice.go Adds invoice delete preflight and type-based routing, but the charge-managed guard still misses credit-purchase lines.
openmeter/billing/service/invoiceupdate.go Adds standard-line API delete dispatch through line engines before the invoice is marked deleted.
openmeter/billing/service/stdinvoicestate.go Routes API standard invoice deletion through the new line-engine delete dispatch before setting DeletedAt.
openmeter/billing/service/gatheringinvoice.go Adds gathering invoice deletion by marking active gathering lines deleted through UpdateGatheringInvoice.

Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
openmeter/billing/httpdriver/invoice.go:476
**Credit purchases still pass**

This guard blocks usage-based charge lines, but active credit-purchase lines use `LineEngineTypeChargeCreditPurchase` and their mutable-line API hook also rejects with `ErrCannotUpdateChargeManagedLine`. A public API delete for an invoice with flat-fee and credit-purchase lines can pass this guard, then reach the later per-engine delete dispatch. If the flat-fee engine runs first, its manual-delete cleanup can execute before the credit-purchase engine rejects, leaving the invoice undeleted with partial cleanup already applied.

Reviews (3): Last reviewed commit: "fix(billing): support gathering invoice ..." | Re-trigger Greptile

Comment thread openmeter/billing/service/invoiceupdate.go

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

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

908-921: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant validation pass on already-validated grouped input.

GroupByLineEngine() already calls Validate() internally for every per-engine group before returning it (see stdinvoiceedit.go's GroupByLineEngine, which does if err := out[engine].Validate(); err != nil { ... }). Calling groupedInput.Validate() again right after in the loop (Line 919) re-validates the exact same data for no additional benefit — it's just wasted work on every dispatch.

♻️ Proposed fix: drop the redundant re-validation
 	for engineType, groupedInput := range changesByEngine {
 		engine, err := s.lineEngines.Get(engineType)
 		if err != nil {
 			return fmt.Errorf("getting engine %s: %w", engineType, err)
 		}
 
-		if err := groupedInput.Validate(); err != nil {
-			return fmt.Errorf("validating API invoice line delete input for engine %s: %w", engine.GetLineEngineType(), err)
-		}
-
 		engineResult, err := engine.OnMutableInvoiceLinesEditedViaAPI(ctx, groupedInput)
🤖 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/service/invoiceupdate.go` around lines 908 - 921,
`input.GroupByLineEngine()` already validates each grouped payload, so the extra
`groupedInput.Validate()` inside the `invoiceupdate.go` dispatch loop is
redundant. Remove that second validation in the loop over `changesByEngine`, and
keep the existing error handling around `GroupByLineEngine()` and
`s.lineEngines.Get()` so `groupedInput` is used directly after grouping.
openmeter/billing/service/lineengine_test.go (1)

202-241: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding given/when/then intent comment.

Logic looks correct — non-deleted line-1 is the one routed to the API edit hook, deletedBySystemInputs stays empty, matching the production filter in deleteInvoice. Only nit: the sibling test file in this same PR (sync_credittheninvoice_test.go) adds given/when/then intent comments to its new tests, but this one doesn't. Worth a quick line or two for consistency.

As per coding guidelines, "For service and lifecycle subtests, start each non-trivial subtest body with concise intent comments in given/when/then form."

📝 Suggested comment addition
 func TestDeleteInvoiceAPIRequestDispatchesNonDeletedLinesToAPIEditHook(t *testing.T) {
+	// given: an invoice with one active line and one already-deleted line
+	// when: the invoice is deleted via ChangeSourceAPIRequest
+	// then: only the active line is routed to the API edit hook, no system-delete dispatch occurs
 	invoiceEngine := &recordingLineEngine{
🤖 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/service/lineengine_test.go` around lines 202 - 241, Add
concise given/when/then intent comments at the start of this non-trivial test
body for consistency with the service/lifecycle test guidelines. Update
TestDeleteInvoiceAPIRequestDispatchesNonDeletedLinesToAPIEditHook to include a
short setup/behavior/assertion comment sequence near the InvoiceStateMachine and
deleteInvoice call so the test’s purpose is immediately clear.

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.

Nitpick comments:
In `@openmeter/billing/service/invoiceupdate.go`:
- Around line 908-921: `input.GroupByLineEngine()` already validates each
grouped payload, so the extra `groupedInput.Validate()` inside the
`invoiceupdate.go` dispatch loop is redundant. Remove that second validation in
the loop over `changesByEngine`, and keep the existing error handling around
`GroupByLineEngine()` and `s.lineEngines.Get()` so `groupedInput` is used
directly after grouping.

In `@openmeter/billing/service/lineengine_test.go`:
- Around line 202-241: Add concise given/when/then intent comments at the start
of this non-trivial test body for consistency with the service/lifecycle test
guidelines. Update
TestDeleteInvoiceAPIRequestDispatchesNonDeletedLinesToAPIEditHook to include a
short setup/behavior/assertion comment sequence near the InvoiceStateMachine and
deleteInvoice call so the test’s purpose is immediately clear.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 46f0bf0b-d861-45cb-abb1-d0a345f5420b

📥 Commits

Reviewing files that changed from the base of the PR and between ef77b70 and 13514a6.

📒 Files selected for processing (4)
  • openmeter/billing/service/invoiceupdate.go
  • openmeter/billing/service/lineengine_test.go
  • openmeter/billing/service/stdinvoicestate.go
  • openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go

Comment thread openmeter/billing/httpdriver/invoice.go

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

🧹 Nitpick comments (3)
openmeter/billing/httpdriver/invoice_test.go (1)

74-146: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Nice, thorough table-driven coverage of validateAPIGenericInvoiceDeleteSupported! One gap: the wrapper validateAPIInvoiceDeleteSupported (which calls h.service.GetInvoiceById and AsGenericInvoice) isn't directly tested here — only the pure logic function is. A quick test using the handler's mocked service to cover the GetInvoiceById error path would close that gap.

As per path instructions, "Make sure the tests are comprehensive and cover the changes."

🤖 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/httpdriver/invoice_test.go` around lines 74 - 146, Add a
test for validateAPIInvoiceDeleteSupported, not just
validateAPIGenericInvoiceDeleteSupported, so the handler wrapper path is
covered. Use the existing handler and mocked service around
h.service.GetInvoiceById and AsGenericInvoice to verify the GetInvoiceById error
path is exercised and asserted. Keep the current table-driven
validateAPIGenericInvoiceDeleteSupported coverage, but extend test coverage to
the wrapper function so the delete-support checks are fully comprehensive.
openmeter/billing/httpdriver/invoice.go (1)

406-425: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Double full-invoice load on every API delete.

validateAPIInvoiceDeleteSupported fetches the invoice with InvoiceExpandAll (all lines) purely to validate, then h.service.DeleteInvoice (Line 374) runs and will need its own load to actually perform the deletion. That's two full line-expanded fetches per delete request.

Since deletes aren't a hot path this is likely tolerable, but worth confirming there isn't a way to thread the already-loaded invoice/generic-lines into the delete call to avoid the redundant read, especially for invoices with many lines.

As per path instructions, "database operations (regardless of database) should be vetted for potential performance bottlenecks."

🤖 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/httpdriver/invoice.go` around lines 406 - 425, The delete
path in validateAPIInvoiceDeleteSupported and h.service.DeleteInvoice is loading
the full invoice twice with InvoiceExpandAll, so check whether DeleteInvoice can
accept the already-fetched invoice or generic lines from
validateAPIInvoiceDeleteSupported. If possible, thread the loaded
invoice/generic invoice through the delete flow in invoice.go to avoid the
redundant full-line read; otherwise document why the second load is required and
confirm it is acceptable for this path.
openmeter/billing/invoiceline.go (1)

104-105: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Two identical accessors on the same interface — intentional?

GetEngine() and the new GetLineEngineType() both return LineEngineType, and their implementations in StandardLine/GatheringLineBase are byte-for-byte identical (return i.Engine / return g.Engine). Unless GetLineEngineType() is meant to satisfy a separate shared contract (e.g. matching lineengine.Engine.GetLineEngineType() for polymorphic handling alongside the line-engine type), having two same-signature getters returning the same value on one interface is confusing and invites drift if only one gets updated later.

Worth either documenting why both exist, or consolidating call sites onto one name.

As per path instructions, "In general when reviewing the Golang code make readability and maintainability a priority, even potentially suggest restructuring the code to improve them."

🤖 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/invoiceline.go` around lines 104 - 105, The invoicing line
interface currently exposes two identical getters, GetEngine() and
GetLineEngineType(), both returning the same LineEngineType, which is confusing
and risks divergence. In invoiceline.go and the matching
StandardLine/GatheringLineBase implementations, either consolidate call sites
onto a single accessor or add a clear comment explaining why both names must
exist for separate contracts; keep the interface and implementations aligned so
there is only one obvious source of truth for the engine type.
🤖 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.

Nitpick comments:
In `@openmeter/billing/httpdriver/invoice_test.go`:
- Around line 74-146: Add a test for validateAPIInvoiceDeleteSupported, not just
validateAPIGenericInvoiceDeleteSupported, so the handler wrapper path is
covered. Use the existing handler and mocked service around
h.service.GetInvoiceById and AsGenericInvoice to verify the GetInvoiceById error
path is exercised and asserted. Keep the current table-driven
validateAPIGenericInvoiceDeleteSupported coverage, but extend test coverage to
the wrapper function so the delete-support checks are fully comprehensive.

In `@openmeter/billing/httpdriver/invoice.go`:
- Around line 406-425: The delete path in validateAPIInvoiceDeleteSupported and
h.service.DeleteInvoice is loading the full invoice twice with InvoiceExpandAll,
so check whether DeleteInvoice can accept the already-fetched invoice or generic
lines from validateAPIInvoiceDeleteSupported. If possible, thread the loaded
invoice/generic invoice through the delete flow in invoice.go to avoid the
redundant full-line read; otherwise document why the second load is required and
confirm it is acceptable for this path.

In `@openmeter/billing/invoiceline.go`:
- Around line 104-105: The invoicing line interface currently exposes two
identical getters, GetEngine() and GetLineEngineType(), both returning the same
LineEngineType, which is confusing and risks divergence. In invoiceline.go and
the matching StandardLine/GatheringLineBase implementations, either consolidate
call sites onto a single accessor or add a clear comment explaining why both
names must exist for separate contracts; keep the interface and implementations
aligned so there is only one obvious source of truth for the engine type.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b14cb22d-95f8-4e75-ab61-7ed220d86286

📥 Commits

Reviewing files that changed from the base of the PR and between 13514a6 and 46fc258.

📒 Files selected for processing (5)
  • openmeter/billing/gatheringinvoice.go
  • openmeter/billing/httpdriver/invoice.go
  • openmeter/billing/httpdriver/invoice_test.go
  • openmeter/billing/invoiceline.go
  • openmeter/billing/stdinvoiceline.go

Comment thread openmeter/billing/httpdriver/invoice.go
@turip turip merged commit c0c7042 into main Jul 6, 2026
26 checks passed
@turip turip deleted the feat/api-invoice-delete-charge-managed-lines branch July 6, 2026 16:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants