Skip to content

Conversation

@turip
Copy link
Member

@turip turip commented Feb 5, 2026

Overview

This patch moves the standard invoice creation to the new gathering invoice API (and fixes an issue with the state machine that prevented line ID allocation in certian cases).

The new deletion function is essential, as this is how we can model the move.

Notes for reviewer

Summary by CodeRabbit

  • New Features

    • Hard-delete for gathering invoice lines with cascading cleanup
    • Retrieve split-line group headers and optional expand of deleted lines
    • Public helpers to inspect, clone, and convert between gathering and standard invoice lines
  • Bug Fixes

    • Preserve/clear invoice periods correctly on soft-delete
    • Exclude deleted lines from listings/annotations unless requested
  • Improvements

    • Namespace-aware feature resolution and ensured DB persistence after state transitions
  • Tests

    • Added positive and negative integration tests for hard-delete and gathering flows

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 5, 2026

📝 Walkthrough

Walkthrough

Adds adapter operations and domain helpers: a transactional hard-delete for gathering invoice lines (with cascading removal of usage-based configs) and a new API to fetch split-line-group headers; plus related gathering-line helpers, namespace-aware feature resolution, and test coverage.

Changes

Cohort / File(s) Summary
Adapter surface
openmeter/billing/adapter.go, openmeter/billing/adapter/invoicelinesplitgroup.go
Added HardDeleteGatheringInvoiceLines and GetSplitLineGroupHeaders adapter methods and a compile-time interface assertion for InvoiceSplitLineGroupAdapter.
Gathering invoice adapters / DB logic
openmeter/billing/adapter/gatheringlines.go, openmeter/billing/adapter/gatheringinvoice.go
Introduced transactional hard-delete flow: validate invoice is Gathering, delete specified lines, cascade-delete usage-based configs, and updated query logic to optionally exclude deleted lines in expansions.
Domain types & helpers
openmeter/billing/gatheringinvoice.go, openmeter/billing/invoicelinesplitgroup.go
New expand flag for deleted lines, many GatheringLine/Lines getters and CloneForCreate, GetReferencedFeatureKeys helpers, and new types/validation for GetSplitLineGroupHeaders input and alias.
Service layer changes
openmeter/billing/service/featuremeter.go, openmeter/billing/service/invoice.go, openmeter/billing/service/quantitysnapshot.go, openmeter/billing/service/gatheringinvoicependinglines.go, openmeter/billing/service/stdinvoicestate.go
Made resolveFeatureMeters namespace-aware (extra namespace param); refactored flows to operate on GatheringInvoice/GatheringLine, added NeedsDBSave flag and requireDBSave hook in state machine, and updated numerous call sites.
Standard invoice helpers
openmeter/billing/stdinvoice.go, openmeter/billing/stdinvoiceline.go
Added GetReferencedFeatureKeys for StandardInvoiceLines/StandardLines and SubscriptionReference.Clone() to support feature-key extraction and cloning.
Business logic & conversions
openmeter/billing/service/gatheringinvoicependinglines.go
Large refactor to use Gathering types across pending-lines, splitting/conversion flows, and conversion helpers between GatheringLine and StandardLine.
Tests
test/billing/adapter_test.go, test/billing/invoice_test.go, openmeter/billing/worker/.../suitebase_test.go, openmeter/billing/worker/.../sync_test.go
Added integration tests for hard-delete (positive and negative cases), updated tests to reference gathering-line IDs, and added debug dumps and minor test adjustments.
Misc small changes
openmeter/billing/worker/subscriptionsync/service/sync.go
Skip deleted lines in hierarchy annotation checks by guarding on per-line DeletedAt.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Client
  participant Adapter as Adapter\n(HardDeleteGatheringInvoiceLines)
  participant DB as Database
  participant UBP as UBPConfigs\n(usage-based configs)

  Client->>Adapter: HardDeleteGatheringInvoiceLines(invoiceID, lineIDs)
  Adapter->>DB: Begin transaction
  DB-->>Adapter: TX started
  Adapter->>DB: Query invoice header by invoiceID
  DB-->>Adapter: Invoice (status)
  Adapter->>DB: Fetch lines by invoiceID & lineIDs (with usage-based relation)
  DB-->>Adapter: Lines + UsageBasedLineIDs
  Adapter->>DB: Delete invoice lines WHERE id IN lineIDs
  DB-->>Adapter: Deleted lines count
  Adapter->>UBP: Delete usage-based configs WHERE id IN UsageBasedLineIDs
  UBP-->>Adapter: Deleted configs count
  Adapter->>DB: Commit transaction
  DB-->>Adapter: TX committed
  Adapter-->>Client: Success / Error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • tothandras
  • gergely-kurucz-konghq
  • chrisgacsal
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'chore: move invoice pending lines to gathering types' clearly and specifically summarizes the main change—migrating the invoice pending lines workflow to use gathering invoice types instead of standard types.

✏️ 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 chore/move-invoice-pending-lines-to-gathering-types

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 chore/move-invoice-pending-lines-to-gathering-types branch 2 times, most recently from 0b9f917 to f8cf5ea Compare February 6, 2026 08:08
@turip turip marked this pull request as ready for review February 6, 2026 08:40
@turip turip requested a review from a team as a code owner February 6, 2026 08:40
Copy link
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: 3

Caution

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

⚠️ Outside diff range comments (1)
openmeter/billing/service/gatheringinvoicependinglines.go (1)

854-883: ⚠️ Potential issue | 🟠 Major

Verify detailed line persistence ordering before app upsert.

The code structure looks correct in theory: detailed lines are created during calculateInvoice (triggered at line 889) and persisted at line 902 before advanceUntilStateStable advances to the sync phase. However, the failing test suggests detailed lines don't have IDs when the app's UpsertStandardInvoice runs.

Worth checking:

  1. Does updateInvoice at line 902 actually persist the complete detailed line tree with assigned IDs?
  2. When advanceUntilStateStable eventually reaches syncDraftInvoice, what invoice data does the app receive—is it freshly loaded from the DB or in-memory state that may have lost DetailedLines?
  3. The comment at lines 900–901 promises IDs are available for the app—if that's not holding true, there's a gap in either persistence or state handoff that needs clarifying.

Consider explicitly verifying that updateInvoice persists DetailedLines with IDs before the state machine advances, or add a refresh/reload step to confirm the data is durable before sync.

🤖 Fix all issues with AI agents
In `@openmeter/billing/service/gatheringinvoicependinglines.go`:
- Around line 82-88: The current ListGatheringInvoices call in
gatheringinvoicependinglines.go only expands live lines
(billing.GatheringInvoiceExpandLines) which omits intentionally deleted lines;
update the billing.ListGatheringInvoicesInput passed to
s.adapter.ListGatheringInvoices so the Expand slice includes the deleted-lines
expand flag (e.g., add a GatheringInvoiceExpandDeletedLines or the equivalent
constant provided by the billing package) or conditionally include that expand
when IncludePendingLines is set, ensuring the returned existingGatheringInvoices
contains deleted lines so subsequent association/movement logic in this function
can consider them for audit/tracing.
- Around line 1016-1033: The code assigns line.Metadata directly into the new
billing.StandardLine (convertedLine) causing shared aliasing; change it to use a
cloned copy (like clonedAnnotations) instead of line.Metadata. Locate the
convertedLine construction where StandardLineBase.Metadata is set and replace
Metadata: line.Metadata with a cloned map (e.g., create a new map[string]string
and copy each key/value from line.Metadata, or call an existing clone helper) so
modifications to gathering lines won’t affect the new StandardLine; keep
clonedAnnotations usage as-is.

In `@openmeter/billing/worker/subscriptionsync/service/sync.go`:
- Around line 500-503: The current loop over hierarchy.Lines skips entries with
line.Line.DeletedAt != nil which causes missing annotations on soft-deleted
split lines; change the logic in the check that compares line.Line.Period.End to
servicePeriod.End so it either (a) does not filter out deleted lines when
calling s.lineHasAnnotation(line.Line, annotation), or (b) first tries active
lines and if none match falls back to checking deleted lines as well; update the
code paths referencing hierarchy.Lines, line.Line.Period.End, servicePeriod.End,
line.Line.DeletedAt and s.lineHasAnnotation to ensure deleted lines are
considered for annotation detection.
🧹 Nitpick comments (2)
openmeter/billing/service/gatheringinvoicependinglines.go (1)

1053-1068: Use a set for line-ID filtering to avoid O(n²) scans.
This is in a DB-heavy path and can be simplified + sped up with a map.

♻️ Suggested refactor
 lineIDsToRemove := lo.Map(linesToRemove, func(l billing.GatheringLine, _ int) string { return l.ID })
+lineIDSet := make(map[string]struct{}, len(lineIDsToRemove))
+for _, id := range lineIDsToRemove {
+	lineIDSet[id] = struct{}{}
+}

 nrLinesRemoved := 0
 invoiceLinesWithRemovedLines := lo.Filter(invoice.Lines.OrEmpty(), func(l billing.GatheringLine, _ int) bool {
-	if slices.Contains(lineIDsToRemove, l.ID) {
+	if _, ok := lineIDSet[l.ID]; ok {
 		nrLinesRemoved++
 		return false
 	}
As per coding guidelines, performance should be a priority in critical code paths, including database operations.
openmeter/billing/service/featuremeter.go (1)

33-33: Minor: lo.Uniq may be redundant if GetReferencedFeatureKeys already deduplicates.

Based on the AI summary, StandardLines.GetReferencedFeatureKeys() collects unique feature keys. If that's the case, this lo.Uniq is a no-op. That said, it's cheap and acts as a safety net if future implementations of linesFeatureGetter don't deduplicate, so totally fine to keep.

Copy link
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

🤖 Fix all issues with AI agents
In `@openmeter/billing/service/gatheringinvoicependinglines.go`:
- Around line 581-586: The warning message in the block checking
splitLine.PreSplitAtLine.DeletedAt != nil is misleading; update the
s.logger.WarnContext call in the same block (the one using "pre split line is
nil, we are not creating empty lines") to accurately state that the pre-split
line was deleted/soft-deleted (e.g., "pre-split line is soft-deleted, skipping
creation of empty lines") while keeping the existing context keys ("line",
line.Line.ID, "period_start", line.Line.ServicePeriod.From, "period_end",
line.Line.ServicePeriod.To).

In `@openmeter/billing/service/stdinvoicestate.go`:
- Around line 455-462: After a successful DB save via m.Service.updateInvoice
(the block guarding m.NeedsDBSave), reset the flag so subsequent loop iterations
don't perform redundant writes: set m.NeedsDBSave = false immediately after
assigning m.Invoice = updatedInvoice (and only on no-error path); ensure you
update the same receiver/struct field (m.NeedsDBSave) so future transitions that
do not call requireDBSave don't trigger an unnecessary update.
🧹 Nitpick comments (5)
openmeter/billing/service/stdinvoicestate.go (1)

850-866: allOf runs requireDBSave even when a prior action (e.g., calculateInvoice) fails — worth a quick note.

This is currently safe because FireAndActivate propagates the error and the loop in AdvanceUntilStateStable exits before reaching the DB save block. But it's a subtle invariant: NeedsDBSave can be true even when the invoice is in an error state. If you reset the flag after save (per my comment above), this becomes a non-issue since the flag is ephemeral per iteration anyway.

Just flagging for awareness — no action needed unless you want to make allOf short-circuit on error.

openmeter/billing/service/gatheringinvoicependinglines.go (2)

858-894: Multiple fetches and cross-references after conversion — worth a brief sanity check.

The flow here is:

  1. Convert gathering lines → standard lines (in-memory)
  2. Hard-delete gathering lines from DB
  3. Update + re-fetch the standard invoice (to get split-line hierarchies expanded)
  4. Re-resolve the new lines from the persisted invoice by matching IDs

Step 4 (lines 887-894) does an O(n×m) search with slices.Contains inside lo.Filter. For typical billing workloads this is fine, but if line counts grow significantly, consider using a set for newLineIDs.


441-515: Conversion function validates correctly, but consider using StandardLine.ToGatheringLineBase() instead.

The function does require UsageBased to be non-nil (lines 447-448), but in practice, every StandardLine created in the codebase sets this field — even flat-fee lines are created with UsageBased containing a FlatPrice. The risk is theoretical rather than practical.

Worth noting: this validation logic is already implemented in StandardLine.ToGatheringLineBase() on line 191-198 of stdinvoiceline.go. The current code duplicates that logic. Since the function is marked as temporary (TODO on line 442), it might make sense to call the existing method instead of reimplementing the conversion.

test/billing/invoice_test.go (2)

2083-2085: Consider using require.NotEmpty instead of s.Fail for early termination.

s.Fail(...) records the failure but continues execution, which means the callback will keep running and might produce confusing follow-on errors or even panic on the empty ID. require.NotEmpty(s.T(), detailedLine.ID) would stop the test immediately. Same applies to the discount ID check on lines 2090-2092.

That said, this is inside a callback returning (*result, error), so you could also return nil, fmt.Errorf(...) to propagate cleanly. Either way works — just flagging the subtle behavior difference.

Suggested alternative
-					if detailedLine.ID == "" {
-						s.Fail("detailed line id is empty")
-					}
+					require.NotEmpty(s.T(), detailedLine.ID, "detailed line id should not be empty")

Also applies to: 2090-2092


2139-2148: Same s.Fail vs require.NotEmpty consideration as above.

These guards in the "create end of period invoice" test callback have the same behavior: s.Fail marks failure without stopping execution. Worth aligning with the approach used elsewhere in the test suite for consistency.

@turip turip added release-note/misc Miscellaneous changes area/billing labels Feb 6, 2026
@turip turip force-pushed the chore/move-invoice-pending-lines-to-gathering-types branch from a5686b5 to 67fd5f5 Compare February 6, 2026 12:20
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@openmeter/billing/adapter/gatheringlines.go`:
- Around line 47-49: The error message in the gathering invoice validation uses
invoiceHeader.Currency which isn't selected and will be zero-valued; update the
fmt.Errorf call in the block that checks invoiceHeader.Status against
billing.StandardInvoiceStatusGathering to reference the actual validated field
(invoiceHeader.Status) instead of invoiceHeader.Currency (keep invoiceID.ID and
invoiceID.Namespace as-is), so the error accurately reflects the validated
status.
- Around line 62-64: The code maps existingLines to usageBasedLineConfigIDs by
accessing line.Edges.UsageBasedLine.ID which can panic if UsageBasedLine is nil;
update the mapping in the function that builds usageBasedLineConfigIDs to either
skip lines with nil line.Edges.UsageBasedLine (filtering them out) or detect any
nil and return a descriptive error, e.g., iterate existingLines, check if line
== nil or line.Edges == nil or line.Edges.UsageBasedLine == nil before reading
.ID, and handle accordingly; reference the variables/structs
usageBasedLineConfigIDs, existingLines, db.BillingInvoiceLine and
Edges.UsageBasedLine when applying the fix.

In `@openmeter/billing/service/gatheringinvoicependinglines.go`:
- Around line 873-877: Fix the typo in the comment above the call to
s.resolveSplitLineGroupHeadersForLines: change "exapanded" to "expanded" in the
comment that references split line group headers and snapshotting (the comment
immediately preceding the call to resolveSplitLineGroupHeadersForLines with
convertResults.LinesAssociated).
- Around line 1109-1151: Add an early-return to avoid an unnecessary DB call
when no split group IDs exist: inside resolveSplitLineGroupHeadersForLines,
after computing splitLineGroupIDs (the lo.Uniq(...) result), check if
len(splitLineGroupIDs) == 0 and return nil immediately so you skip calling
s.adapter.GetSplitLineGroupHeaders; keep all existing error handling and
subsequent mapping logic unchanged.

In `@test/billing/adapter_test.go`:
- Line 633: Subtest title strings contain a double space; locate the s.Run calls
that use the title "Given a customer and  billing profile exists" (the subtest
registration using s.Run) and remove the extra space so it reads "Given a
customer and billing profile exists", and apply the same correction to the other
s.Run occurrence with the same typo elsewhere in this test file.
🧹 Nitpick comments (7)
openmeter/billing/adapter/gatheringinvoice.go (1)

224-231: Consistent deleted-line filtering — nice.

The same filtering pattern appears in both ListGatheringInvoices (line 226) and GetGatheringInvoiceById (line 351). It's concise enough to keep inline, but if you ever add a third call site, consider extracting a small helper like applyDeletedLineFilter(q, expand) to keep things DRY.

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

1064-1079: O(n*m) lookup in removeLinesFromGatheringInvoice — fine for small sets, heads up for scale.

slices.Contains(lineIDsToRemove, l.ID) inside lo.Filter is a linear scan per line. If line counts stay small (typical for a single invoice), this is totally fine. If you ever expect larger sets, a map[string]struct{} would be the easy fix.

Optional: use a set for O(1) lookups
-	lineIDsToRemove := lo.Map(linesToRemove, func(l billing.GatheringLine, _ int) string { return l.ID })
+	lineIDsToRemoveSet := lo.SliceToMap(linesToRemove, func(l billing.GatheringLine) (string, struct{}) { return l.ID, struct{}{} })

 	nrLinesRemoved := 0
 	invoiceLinesWithoutRemovedLines := lo.Filter(invoice.Lines.OrEmpty(), func(l billing.GatheringLine, _ int) bool {
-		if slices.Contains(lineIDsToRemove, l.ID) {
+		if _, ok := lineIDsToRemoveSet[l.ID]; ok {
 			nrLinesRemoved++
 			return false
 		}
 		return true
 	})

-	if nrLinesRemoved != len(lineIDsToRemove) {
-		return fmt.Errorf("lines to remove[%d] must contain the same number of lines as line IDs to remove[%d]", nrLinesRemoved, len(lineIDsToRemove))
+	if nrLinesRemoved != len(lineIDsToRemoveSet) {
+		return fmt.Errorf("lines to remove[%d] must contain the same number of lines as line IDs to remove[%d]", nrLinesRemoved, len(lineIDsToRemoveSet))
 	}
openmeter/billing/invoicelinesplitgroup.go (1)

348-362: Consider validating SplitLineGroupIDs is non-empty.

The Validate() method checks Namespace but not SplitLineGroupIDs. Calling the adapter with an empty slice would produce a valid but pointless DB query. If callers are expected to guard this themselves, that's fine — just noting the asymmetry.

Optional: add a check
 func (i GetSplitLineGroupHeadersInput) Validate() error {
 	var errs []error
 
 	if i.Namespace == "" {
 		errs = append(errs, errors.New("namespace is required"))
 	}
+
+	if len(i.SplitLineGroupIDs) == 0 {
+		errs = append(errs, errors.New("split line group IDs are required"))
+	}
 
 	return errors.Join(errs...)
 }
openmeter/billing/service/stdinvoicestate.go (1)

168-170: Wrapping single functions in allOf is unnecessary but harmless.

allOf(out.syncDraftInvoice) and allOf(out.finalizeInvoice) don't gain anything from the allOf wrapper since there's only one function. If the intent is to make it easy to add more hooks later (like requireDBSave), that's fair — just a minor style nit.

Also applies to: 229-231

test/billing/adapter_test.go (1)

743-861: Negative test cases look solid.

Testing both the "wrong invoice type" and "line doesn't belong to invoice" rejection paths is great. One small suggestion: asserting on specific error content (e.g., checking if the error message contains a known substring) would make these tests more robust against false positives where any error satisfies s.Error(err).

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

75-78: Count mismatch check is good — but consider deduplicating lineIDs input.

If the caller passes duplicate line IDs, the DB delete would only remove unique rows, causing the count check to fail with a confusing error. A lo.Uniq(lineIDs) at the top would make this more robust.

Proposed fix
 	if len(lineIDs) == 0 {
 		return nil
 	}
+	lineIDs = lo.Uniq(lineIDs)
openmeter/billing/gatheringinvoice.go (1)

436-438: GetPrice() returns a pointer from a value receiver — intentional?

Since GatheringLineBase is a value receiver, &i.Price points to the copy's field. The caller gets a valid pointer, but it's disconnected from the original struct. If the intent is to avoid copying Price, consider using a pointer receiver. If it's just for interface compliance, this is fine as-is.

@turip turip merged commit a02569f into main Feb 6, 2026
26 checks passed
@turip turip deleted the chore/move-invoice-pending-lines-to-gathering-types branch February 6, 2026 13:01
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