Skip to content

Conversation

@turip
Copy link
Member

@turip turip commented Feb 5, 2026

Overview

Update pricer to be able to report the billable periods without depending on any specific line type.

Fix an issue that progressive billing might need to bill some in arrears UBP lines in case of subscriptions with smaller billing period than the subscription's billing period.

Notes for reviewer

Summary by CodeRabbit

  • Improvements

    • Better period-aware truncation and progressive billing checks to reduce incorrect invoicing and improve invoice accuracy.
    • Refined line-splitting and assembly logic for more reliable invoice line selection.
    • New standardized accessors for line metadata to simplify integrations.
  • Bug Fixes

    • More specific validation errors when no billable lines or non-billable lines are encountered, improving error clarity and handling.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 5, 2026

📝 Walkthrough

Walkthrough

Reworks invoice billability: introduces generic billable-period helpers, changes pricer APIs to return ClosedPeriod, adds StandardLine accessors, centralizes invoicability checks (hasInvoicableLines), and refactors gathering/splitting to operate on *billing.StandardLine and explicit billable periods.

Changes

Cohort / File(s) Summary
Billable-period core & helpers
openmeter/billing/service/lineservice/billableperiod.go
New generic PriceAccessor, ResolveBillablePeriod and GetLinesWithBillablePeriods to compute and filter billable periods per line (handles progressive vs non-progressive billing).
Pricer API & implementations
openmeter/billing/service/lineservice/pricer.go, .../pricemutate.go, .../pricetiered.go, .../priceflat.go, .../pricedynamic.go, .../pricegraduatedtiered.go, .../pricepackage.go, .../priceunit.go
Changed CanBeInvoicedAsOf to accept CanBeInvoicedAsOfInput and return (*timeutil.ClosedPeriod, error); introduced PricerCanBeInvoicedAsOfAccessor; renamed ProgressiveBillingPricer→ProgressiveBillingMeteredPricer; updated pricer implementations and embedded types accordingly.
Lineservice surface & usage-based lines
openmeter/billing/service/lineservice/service.go, .../usagebasedline.go, .../usagebasedlineflat.go
Removed older Line API invoicing methods and legacy LineWithBillablePeriod; moved invoicing logic to new pricer factory/newPricerFor and calculateDetailedLines; trimmed public API surface.
Gathering / invoice pending lines
openmeter/billing/service/gatheringinvoicependinglines.go, openmeter/billing/service/invoice.go
Reworked to use gatheringLineWithBillablePeriod (alias over LineWithBillablePeriod[*billing.StandardLine]); added hasInvoicableLines check; refactored prepare/split flows to use *billing.StandardLine and ClosedPeriod-based checks; replaced several errors with billing.ValidationError variants.
StandardLine accessors
openmeter/billing/stdinvoiceline.go
Added accessor methods on StandardLine: GetFeatureKey, GetPrice, GetServicePeriod, GetSplitLineGroupID, GetInvoiceAt to support new lineservice/pricer APIs.
Utilities & small changes
pkg/timeutil/closedperiod.go, openmeter/billing/worker/subscriptionsync/service/sync_test.go
Added ClosedPeriod.IsEmpty(); tightened tests to assert billing.ValidationError in no-lines cases.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Client as Client
participant InvoiceSvc as InvoiceService
participant LineSvc as LineService (GetLinesWithBillablePeriods)
participant Pricer as Pricer (CanBeInvoicedAsOf)
participant DB as BillingStore

Client->>InvoiceSvc: Request invoice pending lines (currency, asOf)
InvoiceSvc->>LineSvc: GetLinesWithBillablePeriods(AsOf, ProgressiveBilling, FeatureMeters, Lines)
LineSvc->>Pricer: CanBeInvoicedAsOf(CanBeInvoicedAsOfInput)
Pricer-->>LineSvc: *ClosedPeriod / nil / error
LineSvc-->>InvoiceSvc: []LineWithBillablePeriod (filtered)
InvoiceSvc->>DB: create/prepare invoice (use billable periods, split if needed)
DB-->>InvoiceSvc: success / validation error

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

kind/refactor

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 16.67% 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 accurately captures the main refactoring goal: cleaning up the pricer API to decouple billable period computation from specific line 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 refactor/pricer

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 changed the title refactor: pricer refactor: pricer billable period cleanup Feb 5, 2026
@turip turip marked this pull request as ready for review February 5, 2026 12:48
@turip turip requested a review from a team as a code owner February 5, 2026 12:48
@turip turip added release-note/misc Miscellaneous changes area/billing labels Feb 5, 2026
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 403-410: The call to gatherInScopeLines is missing the AsOf from
the input, so gatherInScopeLines will receive a zero-time and mis-filter lines;
update the gatherInScopeLines invocation (the gatherInScopeLineInput being
constructed where GatheringInvoicesByCurrency is set for in.Invoice.Currency) to
include AsOf: in.AsOf (matching the hasInvoicableLinesInput validation), so the
struct passed to gatherInScopeLines carries the correct AsOf value.
- Around line 136-140: The check `len(invoicesByCurrency) == 0` inside the loop
is unreachable; instead verify whether the current currency's in-scope lines are
empty. Update the condition inside the loop that uses inScopeLines (from
gatherInScopeLines) to `len(inScopeLines) == 0` (or the equivalent per-currency
slice/map) so you bail with the billing.ValidationError when there are no lines
for that currency; locate the check near the variables invoicesByCurrency and
inScopeLines in the function that gathers invoice pending lines and replace the
invariant global check with the per-currency inScopeLines length check.
🧹 Nitpick comments (4)
openmeter/billing/service/lineservice/usagebasedline.go (1)

78-82: Minor: Consider improving the error message identifier.

For flat price lines without a feature key, line.GetFeatureKey() returns an empty string, which would produce an error like "price is nil for line[]". Consider using a different identifier (like line ID if available through the interface) or handling the empty case.

💡 Possible improvement
 func newPricerFor(line PriceAccessor) (Pricer, error) {
 	price := line.GetPrice()
 	if price == nil {
-		return nil, fmt.Errorf("price is nil for line[%s]", line.GetFeatureKey())
+		featureKey := line.GetFeatureKey()
+		if featureKey == "" {
+			return nil, fmt.Errorf("price is nil for line")
+		}
+		return nil, fmt.Errorf("price is nil for line[%s]", featureKey)
 	}
openmeter/billing/service/lineservice/billableperiod.go (1)

79-121: Minor: Redundant price nil check.

newPricerFor (line 80) already validates that price is not nil and returns an error. The check at lines 85-88 is therefore redundant, though harmless. You could remove it or keep it as defensive coding—up to you.

openmeter/billing/service/lineservice/pricer.go (1)

61-77: Consider adding Validate() calls to CanBeInvoicedAsOf implementations for consistency and defensive programming.

The Validate() method on CanBeInvoicedAsOfInput checks for required fields (Line, FeatureMeters, AsOf), but it's not currently called in any of the pricer implementations. While implementations do perform ad-hoc validation on the fields they depend on, adding a defensive in.Validate() call at the start of each CanBeInvoicedAsOf method would catch programming errors earlier and align with the codebase's broader pattern of validating inputs at entry points.

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

137-139: Minor inconsistency: Pointer vs value for ValidationError.

This line uses &billing.ValidationError{} (pointer) while other occurrences in this file (lines 48, 54, 231, 344) use billing.ValidationError{} (value). Both work correctly as errors, but for consistency, consider using the same style throughout.

Suggested fix for consistency
-				return nil, &billing.ValidationError{
+				return nil, billing.ValidationError{
 					Err: billing.ErrInvoiceCreateNoLines,
 				}

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

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)

652-669: ⚠️ Potential issue | 🟠 Major

Avoid billing a pre‑split line when the truncated period is empty.
When preSplitAtLineEmpty is true, the line is marked DeletedAt but still returned and later queued for billing (because only nil is skipped). That can invoice empty/deleted lines. Consider returning nil for PreSplitAtLine (or filtering deleted lines in prepareLinesToBill).

🛠️ Suggested fix
-	preSplitAtLine := line
+	preSplitAtLine := line
@@
-	if preSplitAtLineEmpty {
-		line.DeletedAt = lo.ToPtr(clock.Now())
-	} else {
+	if preSplitAtLineEmpty {
+		line.DeletedAt = lo.ToPtr(clock.Now())
+		preSplitAtLine = nil
+	} else {
 		if err := preSplitAtLine.Validate(); err != nil {
 			return res, fmt.Errorf("validating pre split line: %w", err)
 		}
 	}
@@
 	return splitGatheringInvoiceLineResult{
 		PreSplitAtLine:   preSplitAtLine,
 		PostSplitAtLine:  postSplitAtLine,
 		GatheringInvoice: gatheringInvoice,
 	}, nil
🤖 Fix all issues with AI agents
In `@openmeter/billing/service/gatheringinvoicependinglines.go`:
- Around line 379-431: hasInvoicableLines always calls gatherInScopeLines with
ProgressiveBilling=false, causing wrong filtering for customers with progressive
billing; add a ProgressiveBilling bool to hasInvoicableLinesInput, propagate it
through hasInvoicableLines into the gatherInScopeLines call (set the
gatherInScopeLineInput.ProgressiveBilling to in.ProgressiveBilling), and update
the caller at invoice.go to pass the resolved ProgressiveBilling value (follow
the InvoicePendingLines pattern for resolving from customerProfile/override).
Ensure hasInvoicableLinesInput.Validate still runs and accepts the new field (no
additional validation needed).

In `@openmeter/billing/service/lineservice/pricer.go`:
- Around line 92-116: In ProgressiveBillingMeteredPricer.CanBeInvoicedAsOf
update the returned ClosedPeriod when progressive billing is enabled so the To
value is capped to the line service period end: compute a cappedTo = min(asOf,
period.To) and return &timeutil.ClosedPeriod{From: period.From, To: cappedTo,}
(preserving existing truncation and the earlier After check); this prevents the
returned period from extending past period.To and avoids downstream
splitAt/out-of-period errors.
🧹 Nitpick comments (2)
openmeter/billing/worker/subscriptionsync/service/sync_test.go (1)

3326-3345: Drop the duplicate s.NoError(err) to reduce noise.
You already assert the same thing a few lines later, so this extra check doesn’t add signal.

🧹 Suggested cleanup
-	s.NoError(err)
-
 	updatedLineFromEditedInvoice := s.getLineByChildID(editedInvoice, childUniqueReferenceID)
 	s.NotNil(updatedLineFromEditedInvoice.DeletedAt)
 	s.Equal(billing.ManuallyManagedLine, updatedLineFromEditedInvoice.ManagedBy)
openmeter/billing/service/lineservice/billableperiod.go (1)

90-109: Skip meter lookup when progressive billing is off.
Right now we always call isDependingOnIncreaseOnlyMeters for non-flat prices, even if ProgressiveBilling is already false. Short‑circuiting avoids extra lookups (and potential errors) on the non‑progressive path.

♻️ Suggested refactor
-	meterTypeAllowsProgressiveBilling := false
-	if price.Type() != productcatalog.FlatPriceType {
-		isDependingOnIncreaseOnlyMeters, err := isDependingOnIncreaseOnlyMeters(CanBeInvoicedAsOfInput{
-			AsOf:               in.AsOf,
-			ProgressiveBilling: in.ProgressiveBilling,
-			Line:               in.Line,
-			FeatureMeters:      in.FeatureMeters,
-		})
-		if err != nil {
-			return nil, err
-		}
-
-		meterTypeAllowsProgressiveBilling = isDependingOnIncreaseOnlyMeters
-	}
+	meterTypeAllowsProgressiveBilling := false
+	if in.ProgressiveBilling && price.Type() != productcatalog.FlatPriceType {
+		isDependingOnIncreaseOnlyMeters, err := isDependingOnIncreaseOnlyMeters(CanBeInvoicedAsOfInput{
+			AsOf:               in.AsOf,
+			ProgressiveBilling: in.ProgressiveBilling,
+			Line:               in.Line,
+			FeatureMeters:      in.FeatureMeters,
+		})
+		if err != nil {
+			return nil, err
+		}
+
+		meterTypeAllowsProgressiveBilling = isDependingOnIncreaseOnlyMeters
+	}

@turip turip merged commit eb5c3be into main Feb 5, 2026
26 checks passed
@turip turip deleted the refactor/pricer branch February 5, 2026 14:50
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