Skip to content

feat: refactor getInvoiceByID#3863

Merged
turip merged 4 commits into
mainfrom
refactor/get-invoice-by-id
Feb 13, 2026
Merged

feat: refactor getInvoiceByID#3863
turip merged 4 commits into
mainfrom
refactor/get-invoice-by-id

Conversation

@turip
Copy link
Copy Markdown
Member

@turip turip commented Feb 12, 2026

Overview

This patch moves the GetInvoice/ListInvoice types to the new union types. Given standard invoice handling is complex we are just using the Union type based listing to list entities.

Notes for reviewer

Summary by CodeRabbit

  • New Features

    • Split-line hierarchy for invoice lines.
    • More granular invoice expansion options (choose which parts to load).
  • Bug Fixes

    • List/collection views now show NextCollectionAt instead of CollectionAt.
  • Refactor

    • Clearer separation of gathering vs. standard invoice flows for more predictable listing, retrieval and live-data calculation.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 12, 2026

📝 Walkthrough

Walkthrough

Split invoice handling into explicit standard vs gathering flows, introduce typed expand enums and Expand[T] helpers, add split-line hierarchy handling in mappers, and rename/add adapter/service methods (e.g., GetStandardInvoiceById, ListStandardInvoices) with corresponding input/expand type updates and getter renames (InvoiceID → GetInvoiceID).

Changes

Cohort / File(s) Summary
Core types & expands
openmeter/billing/invoice.go, openmeter/billing/stdinvoice.go, openmeter/billing/gatheringinvoice.go, pkg/expand/expand.go
Replace struct-based expands with enum/string-based Expand[T]; introduce Standard/Gathering specific expand types and constructors; add SetOrUnsetIf on Expand; update List/Get input shapes.
Service surface & flows
openmeter/billing/service.go, openmeter/billing/service/invoice.go, openmeter/billing/service/stdinvoice.go, openmeter/billing/service/gatheringinvoice.go, openmeter/billing/service/*
Introduce GetStandardInvoiceById and ListStandardInvoices; change GetInvoiceById to return union Invoice; branch logic for standard vs gathering; add live-data calculation path converting gathering→standard.
Adapter API & mapping
openmeter/billing/adapter.go, openmeter/billing/adapter/invoice.go, openmeter/billing/adapter/gatheringinvoice.go, openmeter/billing/adapter/*
Add StandardInvoiceAdapter, rename/adjust adapter methods and inputs (GetStandardInvoiceById, UpdateStandardInvoice, ListInvoices → ListInvoicesAdapterInput), propagate ctx through mappers, adjust returned collection types.
Split-line hierarchy & line mapping
openmeter/billing/adapter/invoicelinesplitgroup.go, openmeter/billing/adapter/stdinvoicelinemapper.go, openmeter/billing/adapter/stdinvoicelines.go, openmeter/billing/adapter/gatheringlines.go
Add split-line hierarchy computation and generic setters/interfaces, return typed collections (StandardLines/GatheringLines), and annotate lines with computed hierarchy before returning.
Models & helpers
openmeter/billing/stdinvoiceline.go, openmeter/billing/gatheringinvoice.go, openmeter/billing/stdinvoice.go
Add SplitLineHierarchy fields and setters, AsGeneric/AsStandard/AsGathering helpers, change ExpandedFields types to StandardInvoiceExpands/GatheringInvoiceExpands.
Workers & jobs
openmeter/billing/worker/advance/advance.go, openmeter/billing/worker/collect/collect.go, openmeter/billing/worker/*
Workers switched to ListStandard/ListGathering APIs; input field renames (DraftUntil → DraftUntilLTE, CollectionAt → CollectionAtLTE); use GetInvoiceID() accessor.
HTTP driver, CLI & tests wiring
openmeter/billing/httpdriver/invoice.go, cmd/jobs/billing/collect/collect.go, openmeter/billing/httpdriver/invoice_test.go, openmeter/server/server_test.go, test/...
Map API expands to new enums/Expand[T], replace MapStandardInvoiceToAPI usage with MapInvoiceToAPI(ctx,...), adjust tests/CLI to call GetStandard/GetGathering/ListStandard/ListGathering, and change printed field to NextCollectionAt where applicable.
Integrations & app callers
openmeter/app/custominvoicing/service/sync.go, openmeter/app/stripe/service/billing.go, openmeter/app/*, test/*
Update callers to use GetStandardInvoiceById/ListStandardInvoices and switch invoice ID accessor usage from InvoiceID() → GetInvoiceID().

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client/HTTP
  participant Service as BillingService
  participant Adapter as Adapter/DB

  rect rgba(200,200,255,0.5)
  Client->>Service: GetInvoiceById(GetInvoiceByIdInput)
  Service->>Adapter: GetInvoiceType(invoiceID)
  Adapter-->>Service: InvoiceType (Standard|Gathering)
  alt type == Standard
    Service->>Service: validate expands
    Service->>Adapter: GetStandardInvoiceById(GetStandardInvoiceByIdInput)
    Adapter-->>Service: StandardInvoice
    Service->>Service: resolveWorkflowApps & statusDetails
    Service-->>Client: Standard Invoice (with expands)
  else type == Gathering
    Service->>Adapter: GetGatheringInvoiceById(GetGatheringInvoiceByIdInput)
    Adapter-->>Service: GatheringInvoice
    alt expand includes calculate live-data
      Service->>Service: calculateGatheringInvoiceAsStandardInvoice
      Service-->>Client: Calculated Standard Invoice
    else
      Service-->>Client: Gathering Invoice
    end
  end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested reviewers

  • tothandras
🚥 Pre-merge checks | ✅ 3 | ❌ 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 (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: refactor getInvoiceByID' accurately reflects the main objective of this changeset—refactoring and reorganizing invoice retrieval methods into union types and specialized getters.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/get-invoice-by-id

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 refactor/get-invoice-by-id branch 4 times, most recently from 8896621 to ae687ab Compare February 13, 2026 09:43
@turip turip force-pushed the refactor/get-invoice-by-id branch from ae687ab to 1e98e0d Compare February 13, 2026 10:04
@turip turip marked this pull request as ready for review February 13, 2026 10:04
@turip turip requested a review from a team as a code owner February 13, 2026 10:04
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

Caution

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

⚠️ Outside diff range comments (3)
openmeter/billing/worker/collect/collect.go (1)

165-177: ⚠️ Potential issue | 🟠 Major

Pre-existing data race: err is shared across goroutines.

This isn't introduced by your PR, but since you're touching this file — line 168 uses _, err = ... (assignment, not declaration), so all goroutines in the batch write to the same outer err variable concurrently. This is a race condition.

A quick fix would be to use := for a local err and send it directly to errChan:

🔒 Suggested fix
 			go func() {
 				defer wg.Done()
 
-				_, err = a.CollectCustomerInvoice(ctx, CollectCustomerInvoiceInput{
+				_, err := a.CollectCustomerInvoice(ctx, CollectCustomerInvoiceInput{
 					CustomerID: customerID,
 					AsOf:       time.Now(),
 				})
openmeter/billing/gatheringinvoice.go (1)

585-599: ⚠️ Potential issue | 🟠 Major

Preserve SplitLineHierarchy in GatheringLine.Clone.
Clone currently drops the new SplitLineHierarchy field, so callers using Clone / WithoutDBState / WithNormalizedValues will silently lose it. Please carry it through (or deep‑clone if needed).

🛠️ Suggested patch
 return GatheringLine{
 	GatheringLineBase: base,
 	DBState:           g.DBState,
+	SplitLineHierarchy: g.SplitLineHierarchy,
 }
openmeter/billing/service/invoice.go (1)

188-229: ⚠️ Potential issue | 🟡 Minor

Deleted lines never surface in live-data calc, even when requested.

inScopeLines filters out deleted lines unconditionally, so even if callers request InvoiceExpandDeletedLines, those lines never make it into the response. Consider keeping a full line set for output while still using non-deleted lines for calculation.

💡 Suggested adjustment
-	inScopeLines := lo.FilterMap(invoice.Lines.OrEmpty(), func(line billing.GatheringLine, _ int) (*billing.StandardLine, bool) {
-		return lo.ToPtr(line.AsStandardLine()), line.DeletedAt == nil
-	})
+	allLines := lo.Map(invoice.Lines.OrEmpty(), func(line billing.GatheringLine, _ int) *billing.StandardLine {
+		return lo.ToPtr(line.AsStandardLine())
+	})
+	inScopeLines := lo.Filter(allLines, func(line *billing.StandardLine, _ int) bool {
+		return line.DeletedAt == nil
+	})
@@
-	} else {
-		// Emulate deleted_at filtering
-		out.Lines = billing.NewStandardInvoiceLines(
-			lo.Filter(out.Lines.OrEmpty(), func(line *billing.StandardLine, _ int) bool {
-				if !in.Expand.Has(billing.InvoiceExpandDeletedLines) && line.DeletedAt != nil {
-					return false
-				}
-
-				return true
-			}),
-		)
-	}
+	} else {
+		if in.Expand.Has(billing.InvoiceExpandDeletedLines) {
+			out.Lines = billing.NewStandardInvoiceLines(allLines)
+		} else {
+			out.Lines = billing.NewStandardInvoiceLines(inScopeLines)
+		}
+	}
🤖 Fix all issues with AI agents
In `@openmeter/billing/adapter/invoicelinesplitgroup.go`:
- Line 316: Rename the function setSplitLineHierarchForLines to
setSplitLineHierarchyForLines (adding the missing "y" in "Hierarchy") and update
all call sites to use the new name; specifically change the references in the
invoice.go, gatheringinvoice.go, and the two places in stdinvoicelines.go to
call setSplitLineHierarchyForLines instead of setSplitLineHierarchForLines,
preserving the same signature and generic type parameter so no other logic is
affected.

In `@openmeter/billing/httpdriver/invoice.go`:
- Around line 751-758: mapInvoiceExpandsToEntity currently ignores the
deletedLines expand, causing expand=deletedLines to be dropped; update the
function to also check for api.InvoiceExpandDeletedLines (using slices.Contains)
and include billing.InvoiceExpandDeletedLines in the returned
billing.InvoiceExpands builder (alongside the existing InvoiceExpandLines
mapping) so deletedLines is forwarded for list and get endpoints while
preserving the nil return for empty input.

In `@openmeter/billing/invoice.go`:
- Around line 416-431: The validation branch guarded by OnlyGathering currently
appends errors referring to "standard invoices"; update the error messages in
the block that checks i.OnlyGathering (the appended errors to outErr for
i.Statuses, i.ExtendedStatuses, i.ExternalIDs, and i.DraftUntilLTE) to say
"cannot be set for gathering invoices" (or similar wording referencing gathering
invoices) so the messages correctly reflect the OnlyGathering condition.

In `@openmeter/billing/service/stdinvoice.go`:
- Line 78: The error message string contains a typo: "workload apps" should be
"workflow apps"; update the fmt.Errorf call that returns
billing.StandardInvoice{} (the line using fmt.Errorf("error resolving workload
apps for invoice [%s]: %w", input.Invoice.ID, err)) to use "workflow apps"
instead so the message reads "error resolving workflow apps for invoice [%s]:
%w".

In `@openmeter/billing/stdinvoice.go`:
- Around line 959-989: ListStandardInvoicesInput.Validate is missing pagination
validation; call the embedded Page validation like in ListGatheringInvoicesInput
to prevent invalid/oversized pages reaching DB. Update
ListStandardInvoicesInput.Validate to invoke i.Page.Validate(), and if it
returns an error append it to errs using a prefixed fmt.Errorf("page: %w", err)
before returning errors.Join(errs...), ensuring the pagination guardrail is
applied.

In `@test/subscription/scenario_firstofmonth_test.go`:
- Around line 429-443: The test currently finds a non-gathering invoice but then
calls AsGatheringInvoice() on it; change the predicate in the lo.Find call to
select the gathering invoice (use i.Type() == billing.InvoiceTypeGathering) so
that the returned invoice can be safely converted via AsGatheringInvoice() and
the subsequent assertions on gatheringInvoice.NextCollectionAt and
gatheringInvoice.Lines are valid.
🧹 Nitpick comments (10)
pkg/expand/expand.go (1)

37-43: With() doesn't deduplicate — intentional?

Calling With(value) on a slice that already contains value will produce duplicates. This won't break Has() or Validate(), but it could be surprising for callers who expect set semantics. If that's the intent, a quick comment would help; otherwise, a dedup or guard would make this more robust.

💡 Optional: guard against duplicates
 func (e Expand[T]) With(value T) Expand[T] {
+	if e.Has(value) {
+		return e
+	}
 	out := make(Expand[T], len(e))
 	copy(out, e)
 	out = append(out, value)
 
 	return out
 }
openmeter/billing/invoice.go (1)

329-349: Duplicated time-range validation between ListInvoicesInput and ListInvoicesAdapterInput.

Both Validate() methods repeat the same IssuedAfter/IssuedBefore, CreatedAfter/CreatedBefore, and PeriodStartAfter/PeriodStartBefore checks. Consider extracting a shared helper to keep these in sync. Not urgent, but worth noting for maintainability.

openmeter/billing/validators/customer/customer.go (1)

81-93: Minor: DeletedAt check on gathering invoices may be redundant.

If IncludeDeleted defaults to false in ListInvoicesInput, soft-deleted gathering invoices won't appear in the results, making the gatheringInvoice.DeletedAt != nil check on line 87 a no-op. Not harmful — just FYI in case you want to keep the code lean. If there's a scenario where IncludeDeleted gets flipped to true later, this guard would become useful, so totally fine to keep it as defensive coding.

openmeter/billing/adapter/gatheringinvoice.go (1)

437-439: Pointer-into-slice pattern works here but is worth a small note.

Taking &mappedLines[idx] creates pointers directly into the backing array of mappedLines. This is fine since mappedLines isn't modified afterward, but it's a pattern that can bite if someone later appends to mappedLines (triggering reallocation). A brief comment might save future readers some head-scratching, but no action needed.

openmeter/billing/service/stdinvoice.go (1)

53-69: Optional: the validate → type-check preamble is duplicated across UpdateStandardInvoice and GetStandardInvoiceById.

Both methods share the same validate-input → GetInvoiceType → assert-standard pattern (lines 14–29 and 54–69). If more standard-invoice methods follow the same pattern, consider extracting a small helper like ensureStandardInvoice(ctx, invoiceID) to keep things DRY.

openmeter/billing/worker/subscriptionsync/service/sync.go (1)

154-162: The AsGenericInvoice()GetID() dance is a bit verbose but looks correct.

If billing.Invoice ever gets a convenience GetID() method, this could be simplified. For now, the per-item error handling on the conversion is solid.

openmeter/billing/worker/subscriptionsync/service/invoiceupdate.go (1)

303-305: Prefer GetInvoiceID over deprecated InvoiceID().
Since InvoiceID() is marked deprecated on StandardInvoice, switch to GetInvoiceID here to avoid relying on the legacy accessor.

♻️ Suggested patch
 	invoice, err := u.billingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{
-		Invoice: invoice.InvoiceID(),
+		Invoice: invoice.GetInvoiceID(),
 		Expand:  billing.StandardInvoiceExpandAll,
 	})
test/billing/invoice_test.go (1)

309-314: Add a quick SplitLineHierarchy assertion when using ExpandAll.
GatheringInvoiceExpandAll now includes SplitLineHierarchy; a small assertion here would lock the new expand behavior down.
As per coding guidelines: /*_test.go: Make sure the tests are comprehensive and cover the changes. Keep a strong focus on unit tests and in-code integration tests.

openmeter/billing/adapter/invoice.go (1)

765-777: Possible N+1 when expanding split-line hierarchy.

expandSplitLineHierarchy looks like it could hit the DB per invoice/line. If this is called from list endpoints with ExpandLines, consider batching by invoice IDs (or gating behind a dedicated expand flag) to avoid N+1s.

As per coding guidelines: Performance should be a priority in critical code paths. Anything related to event ingestion, message processing, database operations (regardless of database) should be vetted for potential performance bottlenecks.

openmeter/billing/service/invoice.go (1)

76-95: Consider caching app lookups in ListInvoices.

resolveWorkflowApps pulls three apps per standard invoice; on large lists this can become chatty. A simple memoization per app ID (or batch fetch) would keep this snappy.

As per coding guidelines: Performance should be a priority in critical code paths. Anything related to event ingestion, message processing, database operations (regardless of database) should be vetted for potential performance bottlenecks.

Comment thread openmeter/billing/adapter/invoicelinesplitgroup.go Outdated
Comment thread openmeter/billing/httpdriver/invoice.go
Comment thread openmeter/billing/invoice.go
Comment thread openmeter/billing/service/stdinvoice.go Outdated
Comment thread openmeter/billing/stdinvoice.go
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: 1

🤖 Fix all issues with AI agents
In `@test/subscription/scenario_firstofmonth_test.go`:
- Around line 429-443: The comment above the assertion is stale (mentions
"gathering invoice") while the code now calls invoice.AsStandardInvoice() and
uses standardInvoice; update the comment to accurately describe that we're
asserting properties of the standard invoice (e.g., that
standardInvoice.CollectionAt equals firstOfNextMonth and all
standardInvoice.Lines have InvoiceAt before that collection date) so readers
aren’t confused by the mismatch between the comment and the use of
AsStandardInvoice()/standardInvoice and Lines.Get().

Comment thread test/subscription/scenario_firstofmonth_test.go
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: 2

Caution

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

⚠️ Outside diff range comments (1)
openmeter/billing/adapter/invoice.go (1)

596-615: ⚠️ Potential issue | 🟠 Major

Split-line hierarchy is dropped on the “updatedLines” fast‑path.

When ExpandedFields includes lines, this branch bypasses mapStandardInvoiceFromDB, so SplitLineHierarchy never gets attached. That’s a user‑visible regression for callers relying on the hierarchy data.

✅ Suggested fix: enrich updatedLines before returning
-        updatedInvoice.Lines = updatedLines
+        // Ensure split-line hierarchy is available when lines are expanded
+        hierarchyByLineID, err := tx.expandSplitLineHierarchy(ctx, in.Namespace, updatedLines.AsGenericLines())
+        if err != nil {
+            return in, err
+        }
+        updatedLines, err = withSplitLineHierarchyForLines[*billing.StandardLine](updatedLines, hierarchyByLineID)
+        if err != nil {
+            return in, err
+        }
+        updatedInvoice.Lines = updatedLines
🤖 Fix all issues with AI agents
In `@openmeter/billing/httpdriver/invoice.go`:
- Around line 68-69: The code unconditionally appends
InvoiceExpandCalculateGatheringInvoiceWithLiveData in both ListInvoices and
GetInvoice via mapInvoiceExpandsToEntity(lo.FromPtr(input.Expand)).With(...);
either document that this is intentional (add a short comment above these lines
explaining that live-data recalculation must always be available for gathering
invoices and why) or make it opt-in by only calling
.With(InvoiceExpandCalculateGatheringInvoiceWithLiveData) when the incoming
expand set (from lo.FromPtr(input.Expand) / mapInvoiceExpandsToEntity)
explicitly requests it (e.g., check the parsed expands for that flag before
calling .With). Ensure you update both ListInvoices and GetInvoice and reference
InvoiceExpandCalculateGatheringInvoiceWithLiveData, mapInvoiceExpandsToEntity,
lo.FromPtr(input.Expand), and the .With(...) usage.

In `@openmeter/billing/service/invoice.go`:
- Around line 290-357: Add focused unit tests for Service.GetInvoiceById
covering both InvoiceTypeStandard and InvoiceTypeGathering branches and the
calculate-as-standard path: create tests that (1) assert GetStandardInvoiceById
is called and returned invoice is wrapped when adapter.GetInvoiceType returns
billing.InvoiceTypeStandard and Expand contains
InvoiceExpandLines/InvoiceExpandDeletedLines; (2) assert GetGatheringInvoiceById
is used and returned directly when adapter.GetInvoiceType returns
billing.InvoiceTypeGathering without the calculate flag; (3) assert when Expand
includes InvoiceExpandCalculateGatheringInvoiceWithLiveData the service calls
calculateGatheringInvoiceAsStandardInvoice and returns the calculated standard
invoice (mock calculateGatheringInvoiceAsStandardInvoice to return a valid
billing.StandardInvoice); and (4) assert error handling when
calculateGatheringInvoiceAsStandardInvoice returns nil or an error. Use
mocks/stubs for adapter.GetInvoiceType, GetStandardInvoiceById,
GetGatheringInvoiceById, and calculateGatheringInvoiceAsStandardInvoice and
include cases for InvoiceExpandLines and InvoiceExpandDeletedLines toggled to
verify expand propagation.
🧹 Nitpick comments (6)
pkg/expand/expand.go (1)

18-31: Consider returning early when the expand list is empty.

Minor thought: Validate() iterates even when e is empty. Not a real issue since expansion lists are tiny, but just noting it.

Also worth mentioning: Validate() allows duplicate values in the expand list — it only checks membership against Values(). If duplicates are undesirable, you could validate that too. But this is likely fine for the use case.

openmeter/billing/adapter/gatheringinvoice.go (1)

437-446: The pointer-to-value round-trip works but is a bit noisy.

The double lo.Map (value→ptr, then ptr→value) is needed to satisfy the splitLineSettableLines interface. It's functionally correct, just a tad verbose. If GatheringLine methods were defined on value receivers this wouldn't be needed, but that's a broader design choice — no action needed right now.

openmeter/billing/worker/advance/advance.go (1)

17-17: Field type broadened from billing.InvoiceService to billing.Service.

This is a wider interface than needed — the worker only uses invoice-related methods. If billing.Service includes unrelated capabilities (profiles, overrides, etc.), keeping the dependency narrow via a focused interface would be cleaner. That said, if the old InvoiceService interface was removed as part of this refactor, this is pragmatic.

#!/bin/bash
# Check if billing.InvoiceService still exists or was removed
rg -n "type InvoiceService interface" --type=go

Also applies to: 57-57

openmeter/billing/invoice.go (1)

68-88: Consider adding a comment explaining why InvoiceExpandAll only includes Lines.

Looking at the pattern across the codebase, GatheringInvoiceExpandAll pulls in all values via Values(), while StandardInvoiceExpandAll (like InvoiceExpandAll) includes only Lines and has a comment: // Deleted lines are not expanded by default.

Adding a similar explanatory comment to InvoiceExpandAll would clarify why the other expand types (DeletedLines, CalculateGatheringInvoiceWithLiveData) aren't included, making the "all" naming less confusing. The "ExpandAll" naming convention seems intentionally mean "all default expansions" rather than "all possible ones," so a quick comment would help future readers understand the intent.

openmeter/billing/service/invoice.go (1)

58-95: Potential N+1 app lookups in ListInvoices.

Line 76–92 resolve apps/status per invoice; on large lists this can get expensive. Consider batching app fetches or skipping resolveWorkflowApps when Workflow.Apps is already populated (e.g., for calculated gathering invoices).
As per coding guidelines, “Performance should be a priority in critical code paths. Anything related to … database operations … should be vetted for potential performance bottlenecks.”

openmeter/billing/adapter/invoice.go (1)

765-781: Potential N+1 when expanding split‑line hierarchy.

expandSplitLineHierarchy is called per invoice; on large list queries this may amplify DB work. Consider batching or deferring hierarchy expansion to only the required endpoints.
As per coding guidelines, “Performance should be a priority in critical code paths. Anything related to … database operations … should be vetted for potential performance bottlenecks.”

Comment thread openmeter/billing/httpdriver/invoice.go
Comment thread openmeter/billing/service/invoice.go
@turip turip added release-note/misc Miscellaneous changes area/billing labels Feb 13, 2026
@turip turip enabled auto-merge (squash) February 13, 2026 12:13
@turip turip merged commit f0ea707 into main Feb 13, 2026
23 of 25 checks passed
@turip turip deleted the refactor/get-invoice-by-id branch February 13, 2026 12:18
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: 1

🤖 Fix all issues with AI agents
In `@openmeter/billing/service/invoice.go`:
- Around line 58-96: The converted gathering invoices are immediately replaced
with a StandardInvoice that already has Apps populated (via
createStandardInvoiceHeaderFromGatheringInvoice), but the following Type() ==
InvoiceTypeStandard branch then redundantly calls resolveWorkflowApps and
resolveStatusDetails; to fix this, after setting invoices.Items[i] =
billing.NewInvoice(*calculatedStdInvoice) in the
calculateGatheringInvoiceAsStandardInvoice flow, add a continue to skip the
subsequent StandardInvoice handling (thereby avoiding resolveWorkflowApps and
resolveStatusDetails for invoices converted by
calculateGatheringInvoiceAsStandardInvoice).
🧹 Nitpick comments (4)
openmeter/billing/adapter/invoice.go (2)

69-84: expandInvoiceLineItems accepts StandardInvoiceExpands but is called with constructed values from ListInvoices too.

This helper takes billing.StandardInvoiceExpands (Line 69), but it's also invoked from ListInvoices (Line 236) where you construct a StandardInvoiceExpands from InvoiceExpands. This works, but conceptually the helper is no longer "standard-invoice-specific" — it's a shared utility. If gathering invoices ever need line expansion through a different path, this coupling could get confusing.

Not a blocker at all, just something to keep in mind if the expand types diverge further.


235-239: The expansion translation from InvoiceExpandsStandardInvoiceExpands is a bit dense.

This one-liner does a lot — it constructs a StandardInvoiceExpands, conditionally adds StandardInvoiceExpandLines, and then conditionally adds StandardInvoiceExpandDeletedLines:

query = tx.expandInvoiceLineItems(query, billing.
    StandardInvoiceExpands{billing.StandardInvoiceExpandLines}.
    SetOrUnsetIf(input.Expand.Has(billing.InvoiceExpandDeletedLines), billing.StandardInvoiceExpandDeletedLines))

It works correctly, but the line wrapping makes it a bit hard to parse at a glance. Consider extracting the expand construction into a local variable for readability.

Proposed readability tweak
-		if input.Expand.Has(billing.InvoiceExpandLines) {
-			query = tx.expandInvoiceLineItems(query, billing.
-				StandardInvoiceExpands{billing.StandardInvoiceExpandLines}.
-				SetOrUnsetIf(input.Expand.Has(billing.InvoiceExpandDeletedLines), billing.StandardInvoiceExpandDeletedLines))
-		}
+		if input.Expand.Has(billing.InvoiceExpandLines) {
+			lineExpand := billing.StandardInvoiceExpands{billing.StandardInvoiceExpandLines}.
+				SetOrUnsetIf(input.Expand.Has(billing.InvoiceExpandDeletedLines), billing.StandardInvoiceExpandDeletedLines)
+			query = tx.expandInvoiceLineItems(query, lineExpand)
+		}
openmeter/billing/service/stdinvoice.go (1)

42-76: The "workload" typo from the previous review is fixed — nice!

One observation: GetStandardInvoiceById makes two DB round trips — first GetInvoiceType (Line 49), then adapter.GetStandardInvoiceById (Line 60). The adapter already filters out gathering invoices via StatusNEQ(Gathering), so it would return NotFound for gathering invoices anyway. The separate type check gives a better error message but doubles the DB calls for every standard invoice fetch.

Not necessarily something to change now, but worth keeping in mind if this becomes a hot path.

openmeter/billing/service/invoice.go (1)

384-417: withLockedInvoiceStateMachine does 3 DB round trips before actual work.

  1. GetInvoiceByIdGetInvoiceType (DB hit #1) → actual fetch (DB hit #2)
  2. Inside transaction: GetStandardInvoiceById (DB hit #3)

The first two are just to get the customer ID for the transaction lock scope. This is the existing pattern and seems intentional — just noting it since this is a path used by advance, approve, retry, delete, and update operations. If these become latency-sensitive, a lighter "get customer ID by invoice ID" adapter method could help.

Comment on lines 58 to 96
for i := range invoices.Items {
invoiceID := invoices.Items[i].ID
if invoices.Items[i].Type() == billing.InvoiceTypeGathering && input.Expand.Has(billing.InvoiceExpandCalculateGatheringInvoiceWithLiveData) {
gatheringInvoice, err := invoices.Items[i].AsGatheringInvoice()
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("converting invoice to gathering invoice: %w", err)
}

invoices.Items[i], err = s.resolveWorkflowApps(ctx, invoices.Items[i])
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("error resolving workflow apps [%s]: %w", invoiceID, err)
}
calculatedStdInvoice, err := s.calculateGatheringInvoiceAsStandardInvoice(ctx, recalculateGatheringInvoiceInput{
Invoice: gatheringInvoice,
Expand: input.Expand,
})
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("calculating gathering invoice as standard invoice: %w", err)
}

invoices.Items[i], err = s.resolveStatusDetails(ctx, invoices.Items[i])
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("error resolving status details for invoice [%s]: %w", invoiceID, err)
invoices.Items[i] = billing.NewInvoice(*calculatedStdInvoice)
}

if input.Expand.RecalculateGatheringInvoice {
invoices.Items[i], err = s.recalculateGatheringInvoice(ctx, recalculateGatheringInvoiceInput{
Invoice: invoices.Items[i],
Expand: input.Expand,
})
if invoices.Items[i].Type() == billing.InvoiceTypeStandard {
stdInvoice, err := invoices.Items[i].AsStandardInvoice()
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("converting invoice to standard invoice: %w", err)
}

invoiceID := stdInvoice.ID

stdInvoice, err = s.resolveWorkflowApps(ctx, stdInvoice)
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("error recalculating gathering invoice [%s]: %w", invoiceID, err)
return billing.ListInvoicesResponse{}, fmt.Errorf("error resolving workflow apps [%s]: %w", invoiceID, err)
}

stdInvoice, err = s.resolveStatusDetails(ctx, stdInvoice)
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("error resolving status details for invoice [%s]: %w", invoiceID, err)
}

invoices.Items[i] = billing.NewInvoice(stdInvoice)
}
}
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the implementation of createStandardInvoiceHeaderFromGatheringInvoice
rg -n 'func.*createStandardInvoiceHeaderFromGatheringInvoice' --type=go -A 50

Repository: openmeterio/openmeter

Length of output: 3958


🏁 Script executed:

#!/bin/bash
# Also search for where Type is set in the context of gathering to standard conversion
rg -n 'InvoiceTypeStandard' openmeter/billing/service/invoice.go --type=go -B 2 -A 2

Repository: openmeterio/openmeter

Length of output: 962


🏁 Script executed:

#!/bin/bash
# Check the invoice structure and how Type() is determined
rg -n 'func.*Type\(\)' openmeter/billing/ --type=go

Repository: openmeterio/openmeter

Length of output: 406


🏁 Script executed:

#!/bin/bash
# Check how the Type() method works on Invoice
rg -n 'func (i Invoice) Type()' openmeter/billing/invoice.go -A 10

Repository: openmeterio/openmeter

Length of output: 47


🏁 Script executed:

#!/bin/bash
# Verify resolveWorkflowApps implementation to confirm it unconditionally overwrites Apps
rg -n 'func.*resolveWorkflowApps' openmeter/billing/service/invoice.go -A 30

Repository: openmeterio/openmeter

Length of output: 1357


🏁 Script executed:

#!/bin/bash
# Check calculateGatheringInvoiceAsStandardInvoice to see if it calls resolveWorkflowApps
rg -n 'func.*calculateGatheringInvoiceAsStandardInvoice' openmeter/billing/service/invoice.go -A 50 | head -100

Repository: openmeterio/openmeter

Length of output: 2170


🏁 Script executed:

#!/bin/bash
# Let's also check the Invoice.Type() method to confirm how it determines type
rg -n 'func.*Invoice.*Type\(\)' openmeter/billing/ --type=go -A 5

Repository: openmeterio/openmeter

Length of output: 797


Watch out for redundant app resolution on converted gathering invoices.

When a gathering invoice gets converted to standard (via calculateGatheringInvoiceAsStandardInvoice at line 65), it already has its Apps populated from the customer override—set during createStandardInvoiceHeaderFromGatheringInvoice and tucked into the new StandardInvoice.

But because the converted invoice now has Type() == InvoiceTypeStandard, it triggers the second if block at line 76, which calls resolveWorkflowApps. This makes 3 fresh GetApp calls and overwrites the already-correct Apps with duplicates. It's wasteful, especially since gathering invoices with live data calculation are the default.

The resolveStatusDetails call after is harmless (it short-circuits for StandardInvoiceStatusGathering), but both are unnecessary overhead.

A quick continue after line 73 skips the redundant work:

Fix
 	for i := range invoices.Items {
 		if invoices.Items[i].Type() == billing.InvoiceTypeGathering && input.Expand.Has(billing.InvoiceExpandCalculateGatheringInvoiceWithLiveData) {
 			// ... gathering → standard conversion ...
 			invoices.Items[i] = billing.NewInvoice(*calculatedStdInvoice)
+			continue
 		}
 
 		if invoices.Items[i].Type() == billing.InvoiceTypeStandard {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for i := range invoices.Items {
invoiceID := invoices.Items[i].ID
if invoices.Items[i].Type() == billing.InvoiceTypeGathering && input.Expand.Has(billing.InvoiceExpandCalculateGatheringInvoiceWithLiveData) {
gatheringInvoice, err := invoices.Items[i].AsGatheringInvoice()
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("converting invoice to gathering invoice: %w", err)
}
invoices.Items[i], err = s.resolveWorkflowApps(ctx, invoices.Items[i])
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("error resolving workflow apps [%s]: %w", invoiceID, err)
}
calculatedStdInvoice, err := s.calculateGatheringInvoiceAsStandardInvoice(ctx, recalculateGatheringInvoiceInput{
Invoice: gatheringInvoice,
Expand: input.Expand,
})
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("calculating gathering invoice as standard invoice: %w", err)
}
invoices.Items[i], err = s.resolveStatusDetails(ctx, invoices.Items[i])
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("error resolving status details for invoice [%s]: %w", invoiceID, err)
invoices.Items[i] = billing.NewInvoice(*calculatedStdInvoice)
}
if input.Expand.RecalculateGatheringInvoice {
invoices.Items[i], err = s.recalculateGatheringInvoice(ctx, recalculateGatheringInvoiceInput{
Invoice: invoices.Items[i],
Expand: input.Expand,
})
if invoices.Items[i].Type() == billing.InvoiceTypeStandard {
stdInvoice, err := invoices.Items[i].AsStandardInvoice()
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("converting invoice to standard invoice: %w", err)
}
invoiceID := stdInvoice.ID
stdInvoice, err = s.resolveWorkflowApps(ctx, stdInvoice)
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("error recalculating gathering invoice [%s]: %w", invoiceID, err)
return billing.ListInvoicesResponse{}, fmt.Errorf("error resolving workflow apps [%s]: %w", invoiceID, err)
}
stdInvoice, err = s.resolveStatusDetails(ctx, stdInvoice)
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("error resolving status details for invoice [%s]: %w", invoiceID, err)
}
invoices.Items[i] = billing.NewInvoice(stdInvoice)
}
}
for i := range invoices.Items {
if invoices.Items[i].Type() == billing.InvoiceTypeGathering && input.Expand.Has(billing.InvoiceExpandCalculateGatheringInvoiceWithLiveData) {
gatheringInvoice, err := invoices.Items[i].AsGatheringInvoice()
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("converting invoice to gathering invoice: %w", err)
}
calculatedStdInvoice, err := s.calculateGatheringInvoiceAsStandardInvoice(ctx, recalculateGatheringInvoiceInput{
Invoice: gatheringInvoice,
Expand: input.Expand,
})
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("calculating gathering invoice as standard invoice: %w", err)
}
invoices.Items[i] = billing.NewInvoice(*calculatedStdInvoice)
continue
}
if invoices.Items[i].Type() == billing.InvoiceTypeStandard {
stdInvoice, err := invoices.Items[i].AsStandardInvoice()
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("converting invoice to standard invoice: %w", err)
}
invoiceID := stdInvoice.ID
stdInvoice, err = s.resolveWorkflowApps(ctx, stdInvoice)
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("error resolving workflow apps [%s]: %w", invoiceID, err)
}
stdInvoice, err = s.resolveStatusDetails(ctx, stdInvoice)
if err != nil {
return billing.ListInvoicesResponse{}, fmt.Errorf("error resolving status details for invoice [%s]: %w", invoiceID, err)
}
invoices.Items[i] = billing.NewInvoice(stdInvoice)
}
}
🤖 Prompt for AI Agents
In `@openmeter/billing/service/invoice.go` around lines 58 - 96, The converted
gathering invoices are immediately replaced with a StandardInvoice that already
has Apps populated (via createStandardInvoiceHeaderFromGatheringInvoice), but
the following Type() == InvoiceTypeStandard branch then redundantly calls
resolveWorkflowApps and resolveStatusDetails; to fix this, after setting
invoices.Items[i] = billing.NewInvoice(*calculatedStdInvoice) in the
calculateGatheringInvoiceAsStandardInvoice flow, add a continue to skip the
subsequent StandardInvoice handling (thereby avoiding resolveWorkflowApps and
resolveStatusDetails for invoices converted by
calculateGatheringInvoiceAsStandardInvoice).

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