Skip to content

Conversation

@turip
Copy link
Member

@turip turip commented Jan 27, 2026

Line service feature + meter dependencies

Removing the meter and feature dependencies, and requiring the caller to gather all the required lines. This allows the service to have zero external dependenies.

Given that most of the times the line service is operating on the whole invoice the external resolving makes invoice operations faster, as we don't need to resolve features meters one-by-one.

Notes for reviewer

Full changeset is available here: #3807

Summary by CodeRabbit

  • New Features

    • Integrated feature-meter resolution across invoice flows so billing now accounts for feature→meter mappings during line processing and calculations.
  • Bug Fixes

    • Improved error signaling for invalid database states during snapshotting (e.g., missing feature or meter), causing affected invoices to move to draft.invalid.
  • Refactor

    • Simplified calculator and calculation APIs to accept explicit dependencies (including feature meters) for more consistent, testable billing computations.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 27, 2026

📝 Walkthrough

Walkthrough

Adds a feature-meter abstraction and resolver, propagates FeatureMeters through invoice/line creation and calculator dependencies, refactors the calculator to accept explicit dependencies, and introduces an ErrSnapshotInvalidDatabaseState error for snapshot-related DB issues.

Changes

Cohort / File(s) Summary
Error & utilities
openmeter/billing/errors.go, openmeter/billing/featuremeter.go
Adds ErrSnapshotInvalidDatabaseState; new FeatureMeter/FeatureMeters types and FeatureMeters.Get(featureKey, dependsOnMeteredQuantity) with validation.
Service-level resolver & tests
openmeter/billing/service/featuremeter.go, openmeter/billing/service/featuremeter_test.go
New Service.resolveFeatureMeters(ctx, lines) that lists features and meters, picks latest-per-key versions, and returns a map of feature→meter; includes tests for getLastFeatures.
Gathering/invoice-pending flow
openmeter/billing/service/gatheringinvoicependinglines.go
Threads FeatureMeters through gathering invoices (new gatheringInvoiceWithFeatureMeters), split/move/create flows, and validation inputs.
Invoice service integration
openmeter/billing/service/invoice.go
Resolve feature meters early; pass FeatureMeters into line service creation, invoicability checks, and calculation calls.
Calculator refactor
openmeter/billing/service/invoicecalc/calculator.go, openmeter/billing/service/invoicecalc/mock.go
Introduces CalculatorDependencies (struct with LineService and FeatureMeters), changes Calculate/live-data signatures to accept deps, simplifies constructor, and updates mocks.
Calculator helpers & details
openmeter/billing/service/invoicecalc/*.go
Adjusts several calc helpers: pass/use deps where needed, removes deps from pure helpers (period, tax snapshot, some realtime helper) and updates calls to FromEntities to include FeatureMeters.
Lineservice changes
openmeter/billing/service/lineservice/service.go, .../linebase.go, .../usagebasedline.go, .../usagebasedlineflat.go
Removes runtime FeatureService/MeterService wiring; adds featureMeters to line base; changes FromEntity(s) to accept FeatureMeters; replaces feature lookups with featureMeters.Get.
Init & standard invoice operations
openmeter/billing/service/service.go, openmeter/billing/service/stdinvoiceline.go, openmeter/billing/service/stdinvoicestate.go
Use new invoice calculator constructor; resolve feature meters before creating line services or snapshotting quantities; propagate meters into calculations and wrap errors with context.
Adapter tweak
openmeter/meter/mockadapter/meter.go
ListMeters returns all items early when Page==0 (non-paginated) and sets TotalCount accordingly.

Sequence Diagram(s)

sequenceDiagram
  participant S as Billing Service
  participant F as Feature Store (features)
  participant M as Meter Store (meters)
  participant LS as LineService
  participant C as Calculator

  S->>F: list features for namespace & keys
  F-->>S: features (multiple versions)
  S->>M: list meters by derived slugs
  M-->>S: meters (multiple versions)
  S->>S: pick latest feature/version per key
  S->>S: pick latest meter/version per slug
  S-->>LS: FromEntities(lines, FeatureMeters)
  LS-->>S: Lines (line services)
  S->>C: Calculate(invoice, CalculatorDependencies{LineService, FeatureMeters})
  C-->>S: Calculated invoice
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • tothandras
  • chrisgacsal
  • GAlexIHU
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.43% 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: use meter service from invoice svc' accurately reflects the core refactoring: moving meter resolution from line service to invoice service for efficiency.

✏️ 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 reafactor/meter-feature-handled-by-invoice-service

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 added release-note/misc Miscellaneous changes area/billing labels Jan 27, 2026
@turip turip marked this pull request as ready for review January 27, 2026 16:27
@turip turip requested a review from a team as a code owner January 27, 2026 16:27
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: 1

Caution

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

⚠️ Outside diff range comments (1)
openmeter/meter/mockadapter/meter.go (1)

42-58: TotalCount should reflect the full filtered set, not just the page.
Right now Line 55–58 returns len(meters) after slicing, which makes TotalCount equal to the page size. That’s inconsistent with the no-pagination path and will break page count UIs.

✅ Suggested fix
 	// In memory pagination: case `#2` if there is pagination settings return the paginated dataset
+	totalCount := len(meters)
 	pageNumberIndex := params.PageNumber - 1
@@
 	return pagination.Result[meter.Meter]{
 		Page:       params.Page,
 		Items:      meters,
-		TotalCount: len(meters),
+		TotalCount: totalCount,
 	}, nil
🤖 Fix all issues with AI agents
In `@openmeter/billing/service/featuremeter.go`:
- Around line 26-35: The mapping over lines can dereference a nil UsageBased
(accessing line.UsageBased.FeatureKey) before the filter runs; update the
generation of featuresToResolve so you guard against nil UsageBased (e.g., in
the lo.Map callback or by using lo.Filter/lo.Map order) — check each line for
line.UsageBased != nil before reading FeatureKey (or use lo.FilterMap/Filter
then Map) and then pass the resulting non-empty keys into lo.Uniq; reference the
variables and functions featuresToResolve, StandardLine, UsageBased, FeatureKey
and the lo.Map/lo.Filter calls to locate where to add the nil check.
🧹 Nitpick comments (6)
openmeter/billing/service/lineservice/usagebasedline.go (1)

105-114: Minor inconsistency in nil-meter handling.

The explicit nil check at lines 110-112 is redundant since l.featureMeters.Get(..., true) already returns ErrSnapshotInvalidDatabaseState when the meter is nil. This defensive check is fine to keep, but note that SnapshotQuantity (line 165) dereferences featureMeter.Meter without this same explicit check.

For consistency, either:

  1. Remove the redundant check here (trusting Get's contract), or
  2. Add the same defensive check in SnapshotQuantity before dereferencing

Current code is safe since Get(..., true) guarantees a non-nil meter on success, but the inconsistency could confuse future readers.

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

13-48: Consider using a fixed timestamp for determinism.

Using time.Now() here makes ordering a bit implicit. A fixed base time keeps expectations explicit and avoids rare clock-jitter flakes.

♻️ Proposed tweak
 func TestGetLastFeatures(t *testing.T) {
+	baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
 	tcs := []struct {
 		name     string
 		features []feature.Feature
 		expected map[string]string
 	}{
 		{
 			name: "single-active",
 			features: []feature.Feature{
 				{ID: "id-active", ArchivedAt: nil, Key: "feature-1-active"},
 			},
 			expected: map[string]string{"feature-1-active": "id-active"},
 		},
 		{
 			name: "single-archived",
 			features: []feature.Feature{
-				{ID: "id-archived", ArchivedAt: lo.ToPtr(time.Now()), Key: "feature-1-archived"},
+				{ID: "id-archived", ArchivedAt: lo.ToPtr(baseTime), Key: "feature-1-archived"},
 			},
 			expected: map[string]string{"feature-1-archived": "id-archived"},
 		},
 		{
 			name: "multi-archived",
 			features: []feature.Feature{
-				{ID: "id-archived", ArchivedAt: lo.ToPtr(time.Now()), Key: "feature-1"},
+				{ID: "id-archived", ArchivedAt: lo.ToPtr(baseTime), Key: "feature-1"},
 				{ID: "id-active", ArchivedAt: nil, Key: "feature-1"},
 			},
 			expected: map[string]string{"feature-1": "id-active"},
 		},
 		{
 			name: "archived-ordering",
 			features: []feature.Feature{
-				{ID: "id-archived-1", ArchivedAt: lo.ToPtr(time.Now()), Key: "feature-1"},
-				{ID: "id-archived-2", ArchivedAt: lo.ToPtr(time.Now().Add(5 * time.Second)), Key: "feature-1"},
+				{ID: "id-archived-1", ArchivedAt: lo.ToPtr(baseTime), Key: "feature-1"},
+				{ID: "id-archived-2", ArchivedAt: lo.ToPtr(baseTime.Add(5 * time.Second)), Key: "feature-1"},
 			},
 			expected: map[string]string{"feature-1": "id-archived-2"},
 		},
 	}
openmeter/billing/service/featuremeter.go (2)

108-120: Minor: Variable naming could be more generic.

The variable featuresByKey on line 109 and the loop variable features on line 114 are named specifically for features, but this is a generic function. Consider renaming to entitiesByKey and entities for better clarity.

♻️ Suggested rename for clarity
 func getLastEntity[T any](entities []T, accessor lastEntityAccessor[T]) map[string]T {
-  featuresByKey := lo.GroupBy(entities, func(entity T) string {
+  entitiesByKey := lo.GroupBy(entities, func(entity T) string {
     return accessor.GetKey(entity)
   })

-  out := make(map[string]T, len(featuresByKey))
-  for key, features := range featuresByKey {
+  out := make(map[string]T, len(entitiesByKey))
+  for key, grouped := range entitiesByKey {
     // Let's try to find an unarchived feature
-    out[key] = latestEntity(features, accessor)
+    out[key] = latestEntity(grouped, accessor)
   }

   return out
 }

85-92: Silent fallback when meter is missing for a feature with MeterSlug.

When a feature has a MeterSlug but the corresponding meter isn't found in the database (line 86-92), the code silently creates a FeatureMeter without the meter. This could mask data integrity issues where expected meters have been deleted.

Consider logging a warning when this happens so issues are visible in monitoring.

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

204-206: Consider using len(in.FeatureMeters) == 0 or keeping nil check consistent.

The validation checks in.FeatureMeters == nil here and in other places, but an empty map would pass this check. If an empty FeatureMeters map is also invalid (no features to resolve), consider checking for both nil and empty. However, if an empty map is valid (invoice has no usage-based lines), then the current check is fine.

Just flagging for awareness - the current approach is probably intentional since invoices without usage-based lines would have empty feature meters.

openmeter/billing/service/invoicecalc/calculator.go (1)

47-70: Consider validating required deps to fail fast.
Now that deps are passed in, a lightweight guard (e.g., non‑nil LineService) before running calculations that rely on it would give callers a clear error instead of a potential nil deref. Maybe add this around Line 63 or Line 67.

@turip turip enabled auto-merge (squash) January 27, 2026 16:42
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: 1

🤖 Fix all issues with AI agents
In `@openmeter/billing/service/featuremeter.go`:
- Around line 15-105: In resolveFeatureMeters: avoid calling
ListFeatures/ListMeters with empty filter slices which causes unfiltered "list
all" queries; check the computed featuresToResolve after building it and if it's
empty return an empty billing.FeatureMeters (no error), and likewise after
computing metersToResolve skip the call to s.meterService.ListMeters and leave
metersByKey empty (or return early if appropriate) so you don't issue an
unfiltered query; update the logic around featuresToResolve, metersToResolve,
ListFeatures and ListMeters to perform these early returns/short-circuits.

Comment on lines +15 to +105
func (s *Service) resolveFeatureMeters(ctx context.Context, lines billing.StandardLines) (billing.FeatureMeters, error) {
namespaces := lo.Uniq(lo.Map(lines, func(line *billing.StandardLine, _ int) string {
return line.Namespace
}))

if len(namespaces) != 1 {
return nil, fmt.Errorf("all lines must be in the same namespace")
}

namespace := namespaces[0]

featuresToResolve := lo.Uniq(
lo.Filter(
lo.Map(lines, func(line *billing.StandardLine, _ int) string {
// Never happens, as StandardLine is always a usage based line, but until we migrate to a new table let's keep it here
if line.UsageBased == nil {
return ""
}

return line.UsageBased.FeatureKey
}),
func(featureKey string, _ int) bool {
return featureKey != ""
},
),
)

// Let's resolve the features
features, err := s.featureService.ListFeatures(ctx, feature.ListFeaturesParams{
IDsOrKeys: featuresToResolve,
Namespace: namespace,
IncludeArchived: true,
})
if err != nil {
return nil, fmt.Errorf("listing features: %w", err)
}

featuresByKey := getLastFeatures(features.Items)

metersToResolve := lo.Uniq(
lo.Filter(
lo.Map(lo.Values(featuresByKey), func(feature feature.Feature, _ int) string {
if feature.MeterSlug == nil {
return ""
}

return *feature.MeterSlug
}),
func(meterSlug string, _ int) bool {
return meterSlug != ""
},
),
)

meters, err := s.meterService.ListMeters(ctx, meter.ListMetersParams{
SlugFilter: lo.ToPtr(metersToResolve),
Namespace: namespace,
IncludeDeleted: true,
})
if err != nil {
return nil, fmt.Errorf("listing meters: %w", err)
}

metersByKey := getLastMeters(meters.Items)

out := make(billing.FeatureMeters, len(featuresByKey))
for featureKey, feature := range featuresByKey {
if feature.MeterSlug == nil {
out[featureKey] = billing.FeatureMeter{
Feature: feature,
}

continue
}

meter, exists := metersByKey[*feature.MeterSlug]
if !exists {
out[featureKey] = billing.FeatureMeter{
Feature: feature,
}

continue
}

out[featureKey] = billing.FeatureMeter{
Feature: feature,
Meter: &meter,
}
}

return out, nil
Copy link
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 feature service ListFeatures implementation
fd -t f -n "feature" --type go | head -20

Repository: openmeterio/openmeter

Length of output: 295


🏁 Script executed:

#!/bin/bash
# Search for ListFeatures and ListMeters implementations
rg -n "func.*ListFeatures\(" --type=go -A 5

Repository: openmeterio/openmeter

Length of output: 4089


🏁 Script executed:

#!/bin/bash
# Search for ListMeters implementation
rg -n "func.*ListMeters\(" --type=go -A 5

Repository: openmeterio/openmeter

Length of output: 6308


🏁 Script executed:

#!/bin/bash
# Read the feature adapter ListFeatures implementation
sed -n '172,230p' openmeter/productcatalog/adapter/feature.go

Repository: openmeterio/openmeter

Length of output: 1797


🏁 Script executed:

#!/bin/bash
# Read the meter adapter ListMeters implementation
sed -n '18,80p' openmeter/meter/adapter/meter.go

Repository: openmeterio/openmeter

Length of output: 1785


🏁 Script executed:

#!/bin/bash
# Find ListFeaturesParams structure definition
rg -n "type ListFeaturesParams struct" --type=go -A 10

Repository: openmeterio/openmeter

Length of output: 2288


🏁 Script executed:

#!/bin/bash
# Find ListMetersParams structure definition
rg -n "type ListMetersParams struct" --type=go -A 10

Repository: openmeterio/openmeter

Length of output: 2418


🏁 Script executed:

#!/bin/bash
# Check how KeyIn behaves with empty slice
rg -n "func.*KeyIn\(" --type=go -A 5 | head -30

Repository: openmeterio/openmeter

Length of output: 2432


🏁 Script executed:

#!/bin/bash
# Also check if there are any tests showing ListMeters behavior with empty SlugFilter
rg -n "ListMeters.*SlugFilter" --type=go -B2 -A8

Repository: openmeterio/openmeter

Length of output: 47


Add early returns to prevent unintended list-all queries when inputs are empty.

When lines is empty or contains items with no UsageBased data, featuresToResolve becomes empty. Calling ListFeatures with an empty IDsOrKeys slice doesn't apply a filter, so it returns all features in the namespace instead of none—a correctness bug in a billing path. Similarly, skipping the meter lookup when there's nothing to resolve avoids unnecessary database hits.

🔧 Suggested fix
 func (s *Service) resolveFeatureMeters(ctx context.Context, lines billing.StandardLines) (billing.FeatureMeters, error) {
+	if len(lines) == 0 {
+		return billing.FeatureMeters{}, nil
+	}
 	namespaces := lo.Uniq(lo.Map(lines, func(line *billing.StandardLine, _ int) string {
 		return line.Namespace
 	}))
@@
 	featuresToResolve := lo.Uniq(
@@
 	)
+	if len(featuresToResolve) == 0 {
+		return billing.FeatureMeters{}, nil
+	}
@@
-	meters, err := s.meterService.ListMeters(ctx, meter.ListMetersParams{
-		SlugFilter:     lo.ToPtr(metersToResolve),
-		Namespace:      namespace,
-		IncludeDeleted: true,
-	})
-	if err != nil {
-		return nil, fmt.Errorf("listing meters: %w", err)
-	}
-
-	metersByKey := getLastMeters(meters.Items)
+	metersByKey := map[string]meter.Meter{}
+	if len(metersToResolve) > 0 {
+		meters, err := s.meterService.ListMeters(ctx, meter.ListMetersParams{
+			SlugFilter:     lo.ToPtr(metersToResolve),
+			Namespace:      namespace,
+			IncludeDeleted: true,
+		})
+		if err != nil {
+			return nil, fmt.Errorf("listing meters: %w", err)
+		}
+		metersByKey = getLastMeters(meters.Items)
+	}
🤖 Prompt for AI Agents
In `@openmeter/billing/service/featuremeter.go` around lines 15 - 105, In
resolveFeatureMeters: avoid calling ListFeatures/ListMeters with empty filter
slices which causes unfiltered "list all" queries; check the computed
featuresToResolve after building it and if it's empty return an empty
billing.FeatureMeters (no error), and likewise after computing metersToResolve
skip the call to s.meterService.ListMeters and leave metersByKey empty (or
return early if appropriate) so you don't issue an unfiltered query; update the
logic around featuresToResolve, metersToResolve, ListFeatures and ListMeters to
perform these early returns/short-circuits.

@turip turip merged commit b8a43ff into main Jan 27, 2026
27 checks passed
@turip turip deleted the reafactor/meter-feature-handled-by-invoice-service branch January 27, 2026 16:47
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