Skip to content

Conversation

@turip
Copy link
Member

@turip turip commented Feb 6, 2026

Overview

The major thing this PR does is it replaces the SplitLineGroupHierarchy's way of addressing invoice lines with header from this:

type LineWithInvoiceHeader struct {
	Line    *StandardLine
	Invoice StandardInvoiceBase
}

To this:

type LineWithInvoiceHeader struct {
	Line    GenericInvoiceLine
	Invoice GenericInvoiceReader
}

This has numerous implications:

  • GenericInvoiceLine represents the minimal common set of fields that an invoice line has
  • GenericInvoiceReader represents the minimal common set of invoice header accessors

GenericInvoice* vs InvoiceLine/Invoice

The system uses both union types and interface definitions. The duality is a safety measure:

  • The Generic* interfaces provide read/write access to the underlying lines
  • Each GenericInvoiceLine has an AsInvoiceLine() member, that can convert the line to the union type InvoiceLine, then the union type can be used to get the correct specific type.

This makes sure that we don't have to do type assertions at random places like gathering, ok := (GenericLine)(l).(GatheringLine). Instead this is performed at a single location where we can unit test the conversion itself.

Adapter changes

The adapter of splitlinegroup has been fully refactored to support gathering and standard lines independently.

Notes for reviewer

Summary by CodeRabbit

  • New Features

    • Added "gathering" invoice type and an update operation for gathering invoices.
  • Improvements

    • Unified invoice/line abstractions with new public accessors for consistent handling of standard and gathering lines.
    • Better handling of line hierarchies and subscription-sync flows; auto-cleanup when invoices become empty.
    • Stronger error propagation and safer cloning across invoicing flows.
  • Bug Fixes

    • Fixed validation, type checks and error propagation for invoice/line operations.
  • Tests

    • Added and updated tests covering gathering invoices, line conversions and related flows.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 6, 2026

📝 Walkthrough

Walkthrough

Introduces generic invoice/line abstractions, adds UpdateGatheringInvoice flow with invoicability checks and transactional persistence, renames adapter line-type enums to adapter-specific variants, makes clone/map APIs error-aware, and refactors subscription-sync, adapter, and service layers to handle standard vs gathering invoices and lines.

Changes

Cohort / File(s) Summary
Adapter mappings
openmeter/billing/adapter/gatheringinvoice.go, openmeter/billing/adapter/gatheringlines.go, openmeter/billing/adapter/invoice.go, openmeter/billing/adapter/stdinvoicelinemapper.go, openmeter/billing/adapter/stdinvoicelines.go
Dropped ctx from some mapper signatures, switched SetType/Type checks to InvoiceLineAdapterType*, added DB-snapshot error handling, and separated mapping paths for standard vs gathering lines.
Split-line mapping
openmeter/billing/adapter/invoicelinesplitgroup.go, openmeter/billing/invoicelinesplitgroup.go
Added multi-path mapping helpers for split-line hierarchies (standard vs gathering), changed expand keys to use GetID(), and eagerly load BillingWorkflowConfig.
Core billing types
openmeter/billing/invoice.go, openmeter/billing/invoiceline.go, openmeter/billing/invoicelinetypes.go
Added GenericInvoice / GenericInvoiceReader and GenericInvoiceLine abstractions, introduced Invoice and InvoiceLine sum-types, and renamed/introduced adapter enum InvoiceLineAdapterType.
Gathering invoice domain
openmeter/billing/gatheringinvoice.go, openmeter/billing/gatheringinvoice_test.go
Extended GatheringInvoice public API (GetID/GetInvoiceID/GetDeletedAt/GetCustomerID/AsInvoice), added line accessors/mutators and generic line wrapper, added UpdateGatheringInvoiceInput and unit test for InvoiceAt accessor.
Standard invoice & lines (error-aware)
openmeter/billing/stdinvoice.go, openmeter/billing/stdinvoiceline.go, openmeter/billing/invoiceline_test.go
Made clone/remove/normalize APIs return errors, added Get* accessors and StandardLines collection, introduced generic wrappers to satisfy GenericInvoiceLine, and surfaced SaveDBSnapshot errors.
Line split group types
openmeter/billing/invoicelinesplitgroup.go
Replaced concrete header pairings with generic LineWithInvoiceHeader/LinesWithInvoiceHeaders, updated SplitLineHierarchy.Clone and LineOrHierarchy to work with GenericInvoiceLine.
Service: gathering invoice flows
openmeter/billing/service.go, openmeter/billing/service/gatheringinvoice.go, openmeter/billing/service/gatheringinvoicependinglines.go
Added Service.UpdateGatheringInvoice and helper checkIfGatheringLinesAreInvoicable, adjusted pending-lines code to use GetInvoiceID and ReplaceByID, and wrapped update flow in transactions.
Service helpers & state machine
openmeter/billing/service/service.go, openmeter/billing/service/stdinvoicestate.go
Added no-value transaction wrapper and made state-machine hooks/error paths clone-and-handle errors for invoice clones passed to hooks.
Lineservice & discounts
openmeter/billing/service/lineservice/discountusage.go
Skip gathering lines when computing usage discounts by ensuring only Standard lines are considered (convert via AsStandardLine).
Subscription-sync worker
openmeter/billing/worker/subscriptionsync/service/sync.go, .../invoiceupdate.go, .../patch.go, .../feehelper.go
Large refactor: explicit gathering invoice update path, many helpers now accept/return GatheringLine or GenericInvoiceLine, moved to accessor-based mutations, and switched period logic to use servicePeriod.To semantics.
Tests, schema & misc.
multiple test files, openmeter/ent/schema/billing.go, openmeter/billing/events.go, openmeter/server/server_test.go
Updated tests to new getters and error-aware clones (lo.Must / explicit checks), adjusted ent enum GoType to adapter enum, added NoopBillingService.UpdateGatheringInvoice, and tightened RemoveCircularReferences error handling.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Caller
    participant Sync as SubscriptionSync
    participant Svc as BillingService
    participant Adapter as BillingAdapter
    participant DB as Database

    Client->>Sync: ApplyPatches(linePatches)
    Sync->>Sync: RouteByInvoiceType(gathering / standard / immutable)
    alt Gathering
        Sync->>Svc: UpdateGatheringInvoice(ctx, input)
        Svc->>Adapter: GetGatheringInvoiceById(ctx, id)
        Adapter->>DB: Query invoice + lines (+workflow config)
        DB-->>Adapter: rows
        Adapter-->>Svc: GatheringInvoice
        Svc->>Svc: EditFn(invoice) → Normalize → Validate/Invoicability
        Svc->>Adapter: UpsertGatheringInvoice(ctx, invoice)
        Adapter->>DB: Persist invoice + lines
        DB-->>Adapter: OK
        Adapter-->>Svc: OK
        Svc-->>Sync: OK
    else Standard
        Sync->>Svc: updateMutableStandardInvoice(ctx, invoice, patches)
        Svc->>Adapter: UpsertStandardInvoice(ctx, invoice)
        Adapter->>DB: Persist
        DB-->>Adapter: OK
        Adapter-->>Svc: OK
        Svc-->>Sync: OK
    end
    Sync-->>Client: Done
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • tothandras
  • chrisgacsal
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.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 clearly captures the main refactoring focus—introducing generic types for split line groups to support both standard and gathering invoices.

✏️ 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/splitlinegroup-gathering-types

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
openmeter/billing/service/gatheringinvoice.go (1)

178-178: Value receiver (s Service) instead of pointer receiver (s *Service).

Every other method on Service in this file uses a pointer receiver (*Service). This one uses a value receiver, which means a copy of the whole Service struct is made on each call. Since Service likely holds references (adapter, calculator, etc.), it'll still work, but it's inconsistent and could be confusing for future readers.

Suggested fix
-func (s Service) checkIfGatheringLinesAreInvoicable(ctx context.Context, invoice billing.GatheringInvoice, progressiveBilling bool, featureMeters billing.FeatureMeters) error {
+func (s *Service) checkIfGatheringLinesAreInvoicable(ctx context.Context, invoice billing.GatheringInvoice, progressiveBilling bool, featureMeters billing.FeatureMeters) error {

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/splitlinegroup-gathering-types branch 4 times, most recently from 05b9cc0 to 8813eec Compare February 8, 2026 16:09
@turip
Copy link
Member Author

turip commented Feb 8, 2026

Still wip, moving to ready for review to see the rabbit results.

@turip turip marked this pull request as ready for review February 8, 2026 16:09
@turip turip requested a review from a team as a code owner February 8, 2026 16:10
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: 6

Caution

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

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

691-718: ⚠️ Potential issue | 🟠 Major

Finalize uses a stale clone; issued/due timestamps won’t reach the app.
FinalizeStandardInvoice still gets the original clone created before IssuedAt/DueAt/external IDs are updated, so downstream apps won’t see the latest state. Re-clone (or update the clone) after the mutations.

💡 Suggested fix
-		results, err := app.FinalizeStandardInvoice(ctx, clonedInvoice)
+		finalizedInvoice, err := m.Invoice.Clone()
+		if err != nil {
+			return nil, err
+		}
+		results, err := app.FinalizeStandardInvoice(ctx, finalizedInvoice)
openmeter/billing/invoicelinesplitgroup.go (1)

250-276: ⚠️ Potential issue | 🟠 Major

SumNetAmount silently discards errors from ForEachChild.

Line 253 assigns the error to _, but the callback on line 260 can return a real error from AsStandardLine(). If that ever fails, you get a silently incorrect sum with no indication anything went wrong.

Consider returning (alpacadecimal.Decimal, error) or at least logging the error.

Proposed fix
-func (h *SplitLineHierarchy) SumNetAmount(in SumNetAmountInput) alpacadecimal.Decimal {
+func (h *SplitLineHierarchy) SumNetAmount(in SumNetAmountInput) (alpacadecimal.Decimal, error) {
 	netAmount := alpacadecimal.Zero
 
-	_ = h.ForEachChild(ForEachChildInput{
+	err := h.ForEachChild(ForEachChildInput{
 		PeriodEndLTE: in.PeriodEndLTE,
 		Callback: func(child LineWithInvoiceHeader) error {
 			if child.Invoice.AsInvoice().Type() != InvoiceTypeStandard {
 				return nil
 			}
 
 			stdLine, err := child.Line.AsInvoiceLine().AsStandardLine()
 			if err != nil {
 				return err
 			}
 
 			netAmount = netAmount.Add(stdLine.Totals.Amount)
 
 			if in.IncludeCharges {
 				netAmount = netAmount.Add(stdLine.Totals.ChargesTotal)
 			}
 
 			return nil
 		},
 	})
+	if err != nil {
+		return alpacadecimal.Zero, fmt.Errorf("iterating children: %w", err)
+	}
 
-	return netAmount
+	return netAmount, nil
 }
🤖 Fix all issues with AI agents
In `@openmeter/billing/adapter/invoicelinesplitgroup.go`:
- Around line 225-234: The code in mapSplitLineHierarchyLinesFromDB reads
dbLine.Edges.BillingInvoice.Status without checking that
dbLine.Edges.BillingInvoice is non-nil; add a nil guard in
mapSplitLineHierarchyLinesFromDB (inside the MapWithErr lambda) to check dbLine
!= nil and dbLine.Edges.BillingInvoice != nil and return a clear error if
missing (e.g., "missing BillingInvoice edge on BillingInvoiceLine"); this makes
mapSplitLineHierarchyLinesFromDB robust even if callers forget to preload the
BillingInvoice edge and points to the failing record for easier debugging.

In `@openmeter/billing/invoice.go`:
- Around line 164-173: Invoice.Validate currently dereferences i.standardInvoice
/ i.gatheringInvoice and can panic if those pointers are nil; update
Invoice.Validate to use the existing nil-safe accessors (AsStandardInvoice and
AsGatheringInvoice) instead of direct field access: call i.AsStandardInvoice()
when i.t == InvoiceTypeStandard and if it returns the standard invoice, run its
Validate(), otherwise return the accessor error; do the same for
InvoiceTypeGathering with i.AsGatheringInvoice(); keep the default case
returning fmt.Errorf("invalid invoice type: %s", i.t).
- Around line 24-26: Billing's InvoiceType values include "credit-note" but the
API expects "credit_note", and simply casting invoice.Type to api.InvoiceType
(in openmeter/billing/httpdriver/invoice.go) preserves the hyphen; add a small
conversion function (e.g., mapBillingInvoiceTypeToAPI or convertInvoiceType)
that switches known billing values (InvoiceTypeStandard, InvoiceTypeCreditNote,
InvoiceTypeGathering) to the API strings (replace hyphen with underscore for
credit note) and use that function when building the response (replace the
direct cast Type: api.InvoiceType(invoice.Type) with Type:
mapBillingInvoiceTypeToAPI(invoice.Type)).

In `@openmeter/billing/invoiceline.go`:
- Around line 175-184: The Validate() method can dereference nil pointers on
i.standardLine or i.gatheringLine; update InvoiceLine.Validate() to guard before
calling Validate(): for InvoiceLineTypeStandard, check i.standardLine != nil (or
use the existing AsStandardLine() guard) and return a clear error if nil,
otherwise call i.standardLine.Validate(); do the same for
InvoiceLineTypeGathering with i.gatheringLine (or AsGatheringLine()); ensure the
default case still returns fmt.Errorf("invalid invoice line type: %s", i.t).
- Around line 214-231: InvoiceLine.AsGenericLine currently returns a value for
standardInvoiceLineGenericWrapper but a pointer for
gatheringInvoiceLineGenericWrapper, causing inconsistency; update the standard
branch in AsGenericLine to return a pointer instead (use
&standardInvoiceLineGenericWrapper{StandardLine: i.standardLine}) so both
branches return the same type (pointer) that implements GenericInvoiceLine;
reference InvoiceLine.AsGenericLine, standardInvoiceLineGenericWrapper,
gatheringInvoiceLineGenericWrapper, and GenericInvoiceLine to locate and make
the change.

In `@openmeter/billing/stdinvoiceline.go`:
- Around line 399-405: The SetPrice method on StandardLine silently no-ops when
UsageBased is nil; change it to either initialize UsageBased before assigning
(e.g., allocate a UsageBased struct and then set i.UsageBased.Price =
price.Clone()) or change the API to return an error so callers are notified
(update GenericInvoiceLine.SetPrice and callers accordingly), and ensure
behavior aligns with GatheringLineBase.SetPrice by documenting or enforcing the
invariant that UsageBased is non-nil before setting the price; reference
SetPrice, StandardLine, UsageBased, productcatalog.Price,
GatheringLineBase.SetPrice, and GenericInvoiceLine.SetPrice when making the
change.
🧹 Nitpick comments (12)
openmeter/billing/adapter/stdinvoicelinemapper.go (1)

114-116: Type constant updated to InvoiceLineAdapterTypeUsageBased — minor note on the return pattern.

The constant rename aligns with the broader refactor. One thing worth noting (pre-existing, not introduced here): line 115 returns (invoiceLine, error) — a partially-constructed object alongside an error. The caller discards it, so it's harmless, but returning nil with the error would be more idiomatic Go.

Optional: return nil instead of partial object on error
 	if dbLine.Type != billing.InvoiceLineAdapterTypeUsageBased {
-		return invoiceLine, fmt.Errorf("only usage based lines can be top level lines [line_id=%s]", dbLine.ID)
+		return nil, fmt.Errorf("only usage based lines can be top level lines [line_id=%s]", dbLine.ID)
 	}
openmeter/billing/gatheringinvoice_test.go (1)

11-32: Solid test for the InvoiceAtAccessor interface!

Covers both direct wrapper access and the interface-asserted path, plus the dual-interface check (GenericInvoiceLineInvoiceAtAccessor). One optional thought: since GatheringLine is stored by value in the wrapper, you could add a quick assertion that the original line.InvoiceAt is not modified after wrapper.SetInvoiceAt(target) — that would document the value-copy semantics and guard against accidental changes to pointer semantics later.

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

35-39: Stale error message after refactor.

Now that we're using GetPrice() on a generic line interface, the message "line misses usage based metadata" doesn't match the actual check (price is nil). Same issue on line 56. A quick update would help future debugging.

✏️ Suggested wording
-		return alpacadecimal.Zero, fmt.Errorf("line misses usage based metadata")
+		return alpacadecimal.Zero, fmt.Errorf("line has no price set")

And similarly for setFlatFeePerUnitAmount:

-		return fmt.Errorf("line misses usage based metadata")
+		return fmt.Errorf("line has no price set")
openmeter/billing/stdinvoice.go (2)

270-283: Looks good! Clean accessor methods for the GenericInvoiceReader interface.

One thing to note: GetInvoiceID() (Line 278) does the same thing as the existing InvoiceID() method on StandardInvoice (Line 322). Might be worth having one delegate to the other to keep things DRY — though since one lives on StandardInvoiceBase and the other on StandardInvoice, the duplication is understandable.


480-491: On error, MapWithErr returns the original receiver c instead of a zero value.

Line 487 returns c, err on failure. This means callers get back the original lines alongside the error. It's a bit inconsistent with the pattern in Clone() (Line 499) and RemoveMetaForCompare() (Line 375), where a zero value is returned on error. If a caller ever accidentally ignores the error, they'd still have the original data — which might be fine, but could also be surprising.

Not a blocker, just something to keep in mind for consistency.

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

840-852: Variable shadowing: dbGatheringLines is reused as a loop variable.

On Line 840, the range variable dbGatheringLines shadows the outer dbGatheringLines declared on Line 831. This compiles fine and works correctly, but it makes the code harder to read. Consider renaming the loop variable to something like invoiceLines or linesInInvoice.

♻️ Suggested rename
-	for invoiceID, dbGatheringLines := range dbGatheringLinesByInvoiceID {
+	for invoiceID, dbLinesForInvoice := range dbGatheringLinesByInvoiceID {
 		schemaLevel, found := invoiceSchemaLevelByID[invoiceID]
 		if !found {
 			return nil, fmt.Errorf("schema level not found for invoice [id=%s]", invoiceID)
 		}
 
-		mappedLines, err := tx.mapGatheringInvoiceLinesFromDB(schemaLevel, dbGatheringLines)
+		mappedLines, err := tx.mapGatheringInvoiceLinesFromDB(schemaLevel, dbLinesForInvoice)
 		if err != nil {
 			return nil, fmt.Errorf("mapping gathering lines: %w", err)
 		}
openmeter/billing/worker/subscriptionsync/service/invoiceupdate.go (1)

193-220: Two-step conversion AsInvoiceLine().AsStandardLine() to get the standard line for mutable updates.

This works but feels a bit roundabout. The GenericInvoiceLine is first converted back to an InvoiceLine (sum type), then unwrapped to a StandardLine. The error handling is good though — if the line isn't actually a standard line, it errors appropriately with a clear message.

openmeter/billing/gatheringinvoice.go (2)

587-635: Several accessors on GatheringLine shadow promoted methods from GatheringLineBase.

GetID(), GetInvoiceAt(), and GetChildUniqueReferenceID() are already available through the embedded GatheringLineBase. The re-declarations are harmless and arguably make the intent clearer, but they do add surface area to maintain. Just a heads-up — totally fine to keep them if you prefer the explicitness.


275-290: WithNormalizedValues could reuse MapWithErr from GatheringInvoiceLines instead of calling slicesx.MapWithErr directly.

Lines 260-273 already define MapWithErr on the type, which handles the IsAbsent() check and wraps the result. This new method repeats that same pattern.

Proposed simplification
 func (l GatheringInvoiceLines) WithNormalizedValues() (GatheringInvoiceLines, error) {
-	if l.IsAbsent() {
-		return l, nil
-	}
-
-	out, err := slicesx.MapWithErr(l.OrEmpty(), func(l GatheringLine) (GatheringLine, error) {
+	return l.MapWithErr(func(l GatheringLine) (GatheringLine, error) {
 		return l.WithNormalizedValues()
 	})
-	if err != nil {
-		return GatheringInvoiceLines{}, err
-	}
-
-	return GatheringInvoiceLines{
-		Option: mo.Some(GatheringLines(out)),
-	}, nil
 }
openmeter/billing/worker/subscriptionsync/service/sync.go (2)

641-712: lineFromSubscritionRateCard cleanly converts to GatheringLine.

The construction of GatheringLine / GatheringLineBase is straightforward and all fields are mapped correctly. The ServicePeriod conversion via ToClosedPeriod() at line 653 properly bridges the two period types.

Tiny pre-existing nit: lineFromSubscritionRateCard has a typo — missing 'p' in "Subscription". If you're touching it anyway, might be worth a rename to lineFromSubscriptionRateCard. No pressure though.


835-848: The InvoiceAtAccessor type assertion + SetInvoiceAt pattern is repeated 3 times.

This same block appears at lines 839–848, 939–945, and 983–988. Extracting a small helper would reduce noise and make the intent clearer.

Possible helper
func setInvoiceAtIfGathering(line billing.GenericInvoiceLine, invoiceAt time.Time, invoiceByID InvoiceByID) error {
	if !invoiceByID.IsGatheringInvoice(line.GetInvoiceID()) {
		return nil
	}

	invoiceAtAccessor, ok := line.(billing.InvoiceAtAccessor)
	if !ok {
		return fmt.Errorf("line is not an invoice at accessor: %T", line)
	}

	invoiceAtAccessor.SetInvoiceAt(invoiceAt)
	return nil
}

Then each call site becomes:

if err := setInvoiceAtIfGathering(targetLine, expectedLine.InvoiceAt, invoiceByID); err != nil {
    return nil, err
}
openmeter/billing/worker/subscriptionsync/service/sync_test.go (1)

3164-3165: Inconsistent error handling for Clone() across test helpers.

Some EditFn callbacks use explicit err + s.NoError(err) (here, lines 3164–3165 and 3245–3246), while others use lo.Must(line.Clone()) (lines 3336, 3449, 3706). Both work fine in tests, but picking one pattern would improve readability.

Minor note: if Clone() fails at line 3164, s.NoError(err) marks the test as failed, but the EditFn still returns nil, so UpdateInvoice proceeds with a potentially nil updatedLine — which could cause a confusing nil-pointer panic downstream. Using lo.Must or returning the error from EditFn would be a bit cleaner:

Option A: return the error
-			updatedLine, err = line.Clone()
-			s.NoError(err)
+			var cloneErr error
+			updatedLine, cloneErr = line.Clone()
+			if cloneErr != nil {
+				return cloneErr
+			}
 			return nil
Option B: use lo.Must consistently
-			updatedLine, err = line.Clone()
-			s.NoError(err)
+			updatedLine = lo.Must(line.Clone())
 			return nil

Also applies to: 3245-3246

@turip turip force-pushed the refactor/splitlinegroup-gathering-types branch from 08921d0 to 65fdeaf Compare February 11, 2026 09:55
@turip turip added release-note/misc Miscellaneous changes area/billing labels Feb 11, 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: 3

🤖 Fix all issues with AI agents
In `@openmeter/billing/adapter/stdinvoicelines.go`:
- Around line 820-852: The filter and grouping closures access
line.Edges.BillingInvoice.Status and .ID without nil checks which can panic if
the edge is nil; update the dbStandardLines and dbGatheringLines filters and the
GroupBy key function to first check that line != nil and line.Edges != nil and
line.Edges.BillingInvoice != nil, and then either skip the entry (return false /
empty key) or return a clear error (e.g., "orphaned invoice line [id=...]")
before calling tx.mapStandardInvoiceLinesFromDB and
tx.mapGatheringInvoiceLinesFromDB; ensure you reference the existing variables
and methods (dbStandardLines, dbGatheringLines, dbGatheringLinesByInvoiceID,
invoiceSchemaLevelByID, mapStandardInvoiceLinesFromDB,
mapGatheringInvoiceLinesFromDB, WithBillingInvoice) when making the changes.

In `@openmeter/billing/invoicelinesplitgroup.go`:
- Around line 198-207: NewLineWithInvoiceHeader currently returns a
value-wrapped standardInvoiceLineGenericWrapper but a pointer-wrapped
gatheringInvoiceLineGenericWrapper, which is inconsistent with AsGenericLine
(which returns pointer wrappers) and NewLineOrHierarchy; change
NewLineWithInvoiceHeader to construct and return pointers for both wrappers (use
&standardInvoiceLineGenericWrapper{...} and
&gatheringInvoiceLineGenericWrapper{...}) and then update the corresponding
Clone methods on standardInvoiceLineGenericWrapper and
gatheringInvoiceLineGenericWrapper so they both return the same pointer/value
style (preferably pointer to match AsGenericLine), and ensure NewLineOrHierarchy
and any callers (e.g., AsGenericLine, Clone) are consistent with this
pointer-based wrapper convention.

In `@openmeter/ent/schema/billing.go`:
- Around line 364-366: The schema's field.Enum("type") using
billing.InvoiceLineAdapterType("") must match the actual stored strings
("flat_fee" and "usage_based") in the DB; currently the codebase/DB used "fee"
vs "flat_fee" mismatch will cause existing rows to break. Update the enum
mapping so billing.InvoiceLineAdapterType uses the exact adapter strings
("flat_fee", "usage_based") (or update the InvoiceLineAdapterType definition to
return those values), and ensure the migration that renames/converts legacy
"fee" rows to "flat_fee" (the migrations
20250605131637_migrate-flat-fees-to-ubp-flat-fees.up.sql and
20250731141420_billing-migrate-flat-fee-lines.up.sql) has run before deploying
this schema change; if not present, add a migration to convert any "fee" or
legacy InvoiceLineType values to "flat_fee" and verify existing records align
with field.Enum("type") and billing.InvoiceLineAdapterType prior to deployment.
🧹 Nitpick comments (10)
openmeter/billing/adapter/stdinvoicelinediff_test.go (1)

154-158: Minor typos in test name and comment (pre-existing).

Line 154: "hieararchy""hierarchy" and Line 158: "tirgger""trigger". These look pre-existing so no pressure, but easy to fix if you're already touching this file.

✏️ Typo fixes
-	t.Run("a line is updated in the existing line hieararchy", func(t *testing.T) {
+	t.Run("a line is updated in the existing line hierarchy", func(t *testing.T) {
 		base := cloneLines(template)
 		snapshotAsDBState(t, base)
 
-		// ID change should tirgger a delete/update
+		// ID change should trigger a delete/update
openmeter/billing/service/service.go (1)

156-162: Looks good — clean convenience wrapper.

The delegation pattern is straightforward. One small note: the pre-existing typo transcation (missing the s → should be transaction) carries over here. Not introduced by this PR, but worth a quick rename if you're touching these signatures anyway.

openmeter/billing/invoice.go (1)

117-132: Unreachable return in NewInvoice — required by the compiler but worth a comment.

Line 131 (return Invoice{}) can never execute given the type constraint [T StandardInvoice | GatheringInvoice], but the compiler needs it. A small // unreachable comment would help future readers understand it's intentional.

openmeter/billing/invoiceline.go (1)

222-239: Both branches now return pointers — previously flagged inconsistency resolved.

However, there's a subtle semantic difference worth being aware of: the standard wrapper holds a *StandardLine (pointer — mutations propagate to original), while the gathering wrapper holds a GatheringLine value copy (mutations are isolated). This seems intentional given how these types are used elsewhere, but it means AsGenericLine() has different mutation semantics depending on the line type.

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

338-359: Immutable path assumes all updated lines are standard lines.

Lines 339-342 convert targetState to a standard line via AsInvoiceLine().AsStandardLine(). If a gathering line somehow ends up in the immutable path, this would error out. This seems correct given that immutable invoices should only be standard invoices, but there's no explicit guard at the top of updateImmutableInvoice asserting this invariant.

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

826-848: InvoiceAtAccessor type assertion pattern for gathering invoices.

Lines 840-845 do a runtime type assertion to check if the cloned line supports SetInvoiceAt. This is a consequence of InvoiceAtAccessor being a separate optional interface. The pattern is repeated in the hierarchy paths too (Lines 940-943, 984-987).

This works, but it's a bit fragile — if a new line type is added that doesn't implement InvoiceAtAccessor, this will error at runtime rather than compile time. Since this is a WIP refactor, this might be fine for now. Just flagging it as something to keep an eye on.


898-956: Cross-type period comparisons: Period.End vs ClosedPeriod.To.

Throughout this method, existingHierarchy.Group.ServicePeriod (type Period with .Start/.End) is compared against expectedLine.ServicePeriod (type ClosedPeriod with .From/.To). For example:

  • Line 905: ...ServicePeriod.End.Equal(expectedLine.ServicePeriod.To)
  • Line 913: ...ServicePeriod.End.Before(expectedLine.ServicePeriod.To)
  • Line 952: ...ServicePeriod.End = expectedLine.ServicePeriod.To

These are semantically correct, but the mixed field naming (.End vs .To) across the two period types adds cognitive overhead. This is an existing design choice in the codebase (not introduced by this PR), so just a note for awareness.

openmeter/billing/stdinvoiceline.go (3)

241-248: Minor: consider a helper for the ClosedPeriod → Period back-conversion.

ToClosedPeriod() handles the forward direction, but the reverse (lines 244-247) is done inline. If this pattern repeats elsewhere, a PeriodFromClosedPeriod constructor could reduce duplication. Totally fine as-is for now though.


805-821: GetReferencedFeatureKeys error could be more informative.

When UsageBased is nil (line 810), the error doesn't indicate which line is the culprit. Adding the index or line ID would help debugging:

Optional improvement
-	for _, line := range c {
+	for idx, line := range c {
 		if line.UsageBased == nil {
-			return nil, fmt.Errorf("usage based line is required")
+			return nil, fmt.Errorf("line[%d]: usage based line is required", idx)
 		}

265-304: TaxConfig and Subscription are shared by pointer, not cloned.

Metadata and Annotations are carefully cloned (lines 276-281), but TaxConfig (line 297) and Subscription (line 300) are pointer fields that get copied by reference. If the resulting GatheringLineBase or the original StandardLine mutates either, the other will see the change.

Consider cloning them for consistency:

Suggested fix
-		TaxConfig:              i.TaxConfig,
-		RateCardDiscounts:      i.RateCardDiscounts,
+		TaxConfig:              lo.Ternary(i.TaxConfig != nil, lo.ToPtr(*i.TaxConfig), nil),
+		RateCardDiscounts:      i.RateCardDiscounts.Clone(),
 		ChildUniqueReferenceID: i.ChildUniqueReferenceID,
-		Subscription:           i.Subscription,
+		Subscription:           lo.Ternary(i.Subscription != nil, i.Subscription.Clone(), nil),

@turip turip enabled auto-merge (squash) February 11, 2026 10:22
@turip turip disabled auto-merge February 11, 2026 10:22
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

🤖 Fix all issues with AI agents
In `@openmeter/billing/invoicelinesplitgroup.go`:
- Around line 389-408: ChildUniqueReferenceID and ServicePeriod assume i.line is
non-nil when i.t == LineOrHierarchyTypeLine and can panic; add the same nil
guard used by AsGenericLine: if i.t == LineOrHierarchyTypeLine then check if
i.line == nil and return nil (for ChildUniqueReferenceID) or return
timeutil.ClosedPeriod{} (for ServicePeriod) before calling
i.line.GetChildUniqueReferenceID() / i.line.GetServicePeriod(); keep the
existing behavior for LineOrHierarchyTypeHierarchy unchanged.
- Around line 250-279: Update the SumNetAmount docstring on SplitLineHierarchy
to state that non-standard (gathering) invoice lines are intentionally filtered
out (see the Invoice.AsInvoice().Type() check against InvoiceTypeStandard in
SumNetAmount and the ForEachChild callback) because gathering lines represent
unfinalized amounts and must not be included in minimum/net amount calculations;
mirror the wording/rationale used in discountusage.go (comment about gathering
lines not holding usage discounts) so future readers understand why the check
exists and that IncludeCharges only applies to standard lines.

In `@openmeter/billing/service/gatheringinvoice.go`:
- Around line 162-163: Update the inline comment that references the old
misspelled function name: change "TranscationForGatheringInvoiceManipulation" to
the corrected "TransactionForGatheringInvoiceManipulation" in
gatheringinvoice.go near the block that auto-deletes invoices with no lines (the
comment above the Auto delete the invoice... line) so the comment matches the
renamed function.
🧹 Nitpick comments (6)
openmeter/billing/service/lock.go (1)

15-17: Tiny nit: the wrapping closure is redundant

Since fn already matches the func(ctx context.Context) error signature, you could pass it directly instead of wrapping it. Totally minor though — feel free to ignore!

✨ Optional simplification
-		err := transactionForInvoiceManipulationNoValue(ctx, s, customerID, func(ctx context.Context) error {
-			return fn(ctx)
-		})
+		err := transactionForInvoiceManipulationNoValue(ctx, s, customerID, fn)
openmeter/billing/adapter/stdinvoicelinediff_test.go (1)

363-367: lo.Must for cloning in tests — totally fine, small thought though.

Using lo.Must means a clone failure panics with a stack trace rather than a clean test failure message. It's perfectly acceptable in test helpers, but if you ever want slightly friendlier output, you could mirror the snapshotAsDBState pattern (accept t *testing.T and use require.NoError). Totally optional — the current approach is concise and clear.

Optional: slightly friendlier test failures
-func cloneLines(lines []*billing.StandardLine) []*billing.StandardLine {
+func cloneLines(t *testing.T, lines []*billing.StandardLine) []*billing.StandardLine {
+	t.Helper()
 	return lo.Map(lines, func(line *billing.StandardLine, _ int) *billing.StandardLine {
-		return lo.Must(line.Clone())
+		cloned, err := line.Clone()
+		require.NoError(t, err)
+		return cloned
 	})
 }

This would require updating all call sites to pass t, similar to what was done for snapshotAsDBState.

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

178-206: Mixed error types in errors.Join — might make unwrapping tricky.

checkIfGatheringLinesAreInvoicable returns a mix of plain fmt.Errorf wrapped errors and billing.ValidationError values via errors.Join. When a caller tries to errors.As or errors.Is against billing.ValidationError, the joined error will match if any contained error is a ValidationError, but it won't distinguish which lines had validation failures vs. which had unexpected errors (like from ResolveBillablePeriod).

If that's intentional and callers just care about "did anything fail," this is fine. But if you ever need to separate validation errors from unexpected errors, you might want to split them or wrap the whole thing in a single ValidationError. Just something to keep in mind.

openmeter/billing/adapter/stdinvoicelines.go (2)

833-865: Gathering lines: iteration order over dbGatheringLinesByInvoiceID map is non-deterministic.

The for invoiceID, dbGatheringLinesForInvoice := range dbGatheringLinesByInvoiceID loop iterates over a map, which means the order of gatheringLines in the output slice is non-deterministic across runs. If downstream consumers depend on stable ordering (e.g., for diffing, snapshotting, or deterministic test assertions), this could cause subtle flakiness.

If ordering doesn't matter here, a brief comment noting that would help future readers. Otherwise, consider sorting the invoice IDs first.


929-941: Output assembly covers all three categories — groups, standard lines, and gathering lines.

Capacity calculation on line 929 includes all three, which is correct. The mapping lambdas are straightforward.

One small nit: standardLines are []*billing.StandardLine (pointer) while gatheringLines are []billing.GatheringLine (value). The NewLineOrHierarchy generic constraint [T *StandardLine | GatheringLine | *SplitLineHierarchy] handles this, but the pointer-vs-value asymmetry between the two line types is a bit of a mental speed bump for readers. Might be worth a brief comment or TODO if unifying this is on the roadmap.

openmeter/billing/invoicelinesplitgroup.go (1)

183-207: The generic constructor NewLineWithInvoiceHeader looks clean.

The type switch on the generic constraint is a nice pattern. Both wrappers now use pointer construction (&standardInvoiceLineGenericWrapper{...} and &gatheringInvoiceLineGenericWrapper{...}) — consistent here. 👍

One thing: lines 204-206 — the default branch returns a zero-value LineWithInvoiceHeader{}. Since the generic constraint is exhaustive (StandardLineWithInvoiceHeader | GatheringLineWithInvoiceHeader), this path is unreachable. It's fine as a safety net, but a panic("unreachable") would make intent clearer and catch future constraint changes.

Suggested tweak
 	case GatheringLineWithInvoiceHeader:
 		return LineWithInvoiceHeader{Line: &gatheringInvoiceLineGenericWrapper{GatheringLine: v.Line}, Invoice: v.Invoice}
 	}
 
-	return LineWithInvoiceHeader{}
+	panic("unreachable: unhandled LineWithInvoiceHeader type")

@turip turip merged commit 479fbda into main Feb 11, 2026
24 of 25 checks passed
@turip turip deleted the refactor/splitlinegroup-gathering-types branch February 11, 2026 13:24
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