Skip to content

Conversation

@turip
Copy link
Member

@turip turip commented Feb 11, 2026

Overview

wip: working on self review

Notes for reviewer

Summary by CodeRabbit

  • New Features

    • Added support for Gathering invoice operations alongside existing Standard invoices with type-aware APIs.
    • Introduced generic invoice handling interfaces enabling unified operations across both invoice types.
    • Added new retrieval method for Gathering invoices by ID.
  • Refactor

    • Restructured invoice editing flow to support both Standard and Gathering invoices through a single polymorphic interface.
    • Reorganized invoice line access patterns with generic methods for compatibility across invoice types.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 11, 2026

📝 Walkthrough

Walkthrough

This PR introduces a type-discriminated invoice update system, splitting the generic UpdateInvoice method into UpdateStandardInvoice and UpdateGatheringInvoice flows. It expands invoice interfaces with generic line handling (SetLines, GetGenericLines), adds new adapter methods (GetInvoiceType, GetGatheringInvoiceById), and updates invoice-to-API mapping to dispatch by invoice type. Call sites throughout tests and services are migrated to use the appropriate type-specific APIs.

Changes

Cohort / File(s) Summary
Invoice Type & Service Interface Updates
openmeter/billing/invoice.go, openmeter/billing/service.go
Added UpdateInvoiceInput struct, GetInvoiceTypeAdapterInput alias, generic interface methods (SetLines, GetGenericLines, AsGenericInvoice), and extended ServiceInterface to embed StandardInvoiceService; UpdateInvoice now returns Invoice instead of StandardInvoice.
Standard Invoice Implementation
openmeter/billing/stdinvoice.go, openmeter/billing/stdinvoiceline.go, openmeter/billing/service/stdinvoice.go
Renamed UpdateInvoiceInput to UpdateStandardInvoiceInput; added GetCustomerID, GetGenericLines, SetLines; StandardLine gains SetChildUniqueReferenceID, GetQuantity, GetSubscriptionReference; UpdateStandardInvoice service method validates and delegates via executeTriggerOnInvoice.
Gathering Invoice Implementation
openmeter/billing/gatheringinvoice.go, openmeter/billing/adapter/invoice.go, openmeter/billing/service/gatheringinvoice.go
GatheringInvoice/Line extended with GetGenericLines, SetLines, SetChildUniqueReferenceID, Equal, GetSubscriptionReference, RemoveMetaForCompare; GetInvoiceType adapter method added; GetGatheringInvoiceByIdInput supports type retrieval.
Service Update Flow
openmeter/billing/service/invoice.go
Refactored UpdateInvoice to use transactional type detection and branch to UpdateStandardInvoice or UpdateGatheringInvoice; removed legacy gathering-specific logic.
HTTP API Mapping & Dispatch
openmeter/billing/httpdriver/invoice.go, openmeter/billing/httpdriver/invoice_test.go
Added MapInvoiceToAPI (generic dispatcher), MapStandardInvoiceToAPI for standard invoices; updated UpdateInvoice handler EditFn signature to support both types with type branching; updated call sites (ListInvoices, GetInvoice, ProgressInvoice, SimulateInvoice, UpdateInvoice).
Line Mapping & Merging
openmeter/billing/httpdriver/invoiceline.go
Introduced standardLineFromInvoiceLineReplaceUpdate, gatheringLineFromInvoiceLineReplaceUpdate, mergeGatheringLineFromInvoiceLineReplaceUpdate, mergeGatheringInvoiceLinesFromAPI for type-specific line handling and validation.
Derived Equality & Comparison
openmeter/billing/derived.gen.go, openmeter/billing/invoiceline.go
Refactored internal equality helpers (deriveEqual_, deriveEqual_1/2/3) and their field comparisons; added QuantityAccessor interface; extended GenericInvoiceLine/Reader with SetChildUniqueReferenceID, GetSubscriptionReference, GetSplitLineGroupID.
Adapter & Core Types
openmeter/billing/adapter.go
Added GetInvoiceType method to InvoiceAdapter interface.
Custom Invoicing & Worker Updates
openmeter/app/custominvoicing/httpdriver/custominvoicing.go, openmeter/billing/worker/subscriptionsync/service/invoiceupdate.go
Switched from MapInvoiceToAPI to MapStandardInvoiceToAPI; replaced UpdateInvoice calls with UpdateStandardInvoice.
Integration & Subscription Sync Tests
openmeter/billing/worker/subscriptionsync/service/suitebase_test.go, openmeter/billing/worker/subscriptionsync/service/sync_test.go, openmeter/billing/worker/subscriptionsync/service/syncbillinganchor_test.go
Migrated from StandardInvoice/StandardLine to GatheringInvoice/GatheringLine; updated test helpers (getGatheringLineByChildID, expectLines) to use GenericInvoiceReader with new accessors; refactored populateChildIDsFromParents for generic invoice handling.
Billing Test Suite Updates
test/billing/suite.go, test/billing/invoice_test.go, test/billing/collection_test.go, test/billing/discount_test.go, test/billing/tax_test.go, test/billing/adapter_test.go
Updated test utilities and call sites to use UpdateStandardInvoiceInput, GetInvoiceID, and GetGatheringInvoiceById; refactored DebugDumpInvoice to dispatch by type.
Server & Integration Tests
openmeter/server/server_test.go, test/app/stripe/invoice_test.go
NoopBillingService updated: UpdateInvoice returns Invoice; added UpdateStandardInvoice and GetGatheringInvoiceById methods.
Minor Updates
openmeter/productcatalog/price.go
Price.MarshalJSON receiver changed from pointer to value.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Service
    participant Adapter
    participant DB
    participant TypeSystem

    Client->>Service: UpdateInvoice(ctx, UpdateInvoiceInput)
    Service->>Adapter: GetInvoiceType(invoiceID)
    Adapter->>DB: Query invoice status
    DB-->>Adapter: Return status
    Adapter-->>Service: Return InvoiceType
    
    alt Type == Gathering
        Service->>Service: UpdateGatheringInvoice(ctx, EditFn)
    else Type == Standard
        Service->>Service: UpdateStandardInvoice(ctx, EditFn)
    end
    
    Service->>Service: executeTriggerOnInvoice with edit callback
    Service->>DB: Update invoice with EditFn result
    DB-->>Service: Updated invoice
    Service-->>Client: Return Invoice
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

release-note/misc, area/billing

Suggested reviewers

  • tothandras
  • chrisgacsal
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.94% 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 summarizes the main change—adding gathering and standard invoice update functions—which aligns with the significant refactoring work across multiple files introducing type-specific invoice update workflows.

✏️ 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/update-invoice-type-specific

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 marked this pull request as ready for review February 11, 2026 16:45
@turip turip requested a review from a team as a code owner February 11, 2026 16:45
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/httpdriver/invoiceline.go`:
- Around line 790-854: mergeGatheringLineFromInvoiceLineReplaceUpdate currently
assigns existing.Price = lo.FromPtr(rateCardParsed.Price) without handling a nil
rateCardParsed.Price, which will overwrite the value with zero instead of
preserving or rejecting it; update the function
mergeGatheringLineFromInvoiceLineReplaceUpdate to check rateCardParsed.Price for
nil and either preserve existing.Price when nil or return a
billing.ValidationError (consistent with
gatheringLineFromInvoiceLineReplaceUpdate behavior), referencing
rateCardParsed.Price and existing.Price to locate the change.

In `@openmeter/billing/service/gatheringinvoice.go`:
- Around line 209-213: GetGatheringInvoiceById is missing input validation like
other methods; call input.Validate() at the start of
Service.GetGatheringInvoiceById and return an empty billing.GatheringInvoice
plus the error if validation fails, before invoking transaction.Run with
s.adapter and s.adapter.GetGatheringInvoiceById, so the method follows the same
validation pattern as ListGatheringInvoices and UpdateGatheringInvoice.

In `@openmeter/billing/service/invoice.go`:
- Around line 820-899: UpdateInvoice ignores input.IncludeDeletedLines so edit
callbacks can't see deleted lines; pass input.IncludeDeletedLines through into
the type-specific update calls by adding IncludeDeletedLines:
input.IncludeDeletedLines to the UpdateGatheringInvoice call
(UpdateGatheringInvoiceInput) and to the UpdateStandardInvoice call
(UpdateStandardInvoiceInput), and also include input.IncludeDeletedLines when
refetching the invoice (e.g., the s.adapter.GetGatheringInvoiceById /
GetStandardInvoiceById call) if those retrieval inputs support that flag so the
returned invoice matches the requested visibility.
🧹 Nitpick comments (4)
openmeter/billing/httpdriver/invoiceline.go (1)

923-968: Gathering line merge flow looks solid, with one note on duplication.

The logic here parallels mergeStandardInvoiceLinesFromAPI closely, which is fine given the different underlying types. The absence of SnapshotLineQuantity calls is correct for gathering invoices since they don't need quantity snapshots.

One thing: the two merge*InvoiceLinesFromAPI functions share the same pattern (group by ID → iterate → create-or-merge → mark deletions). If this pattern grows more complex over time, consider extracting the shared orchestration via generics. Not urgent though — the current approach is clear and type-safe.

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

890-894: Use invoice.Type directly instead of inferring from status.

The invoice entity already stores a Type field (set during creation, validated as immutable). Inferring the type from invoice.Status == billing.StandardInvoiceStatusGathering duplicates that source of truth. Other parts of the codebase use invoice.Type directly (e.g., line 282 in gatheringinvoice.go and line 645 in adapter/invoice.go), so using it here would be more consistent and robust.

test/billing/invoice_test.go (1)

773-782: Add a test for the generic UpdateInvoice dispatcher

Right now the test exercises UpdateStandardInvoice directly, which is great. But the generic UpdateInvoice method—which dispatches to either UpdateStandardInvoice or UpdateGatheringInvoice based on invoice type—doesn't have direct test coverage. This dispatcher has real logic (type checking, EditFn wrapping), so a focused test hitting the StandardInvoice path through UpdateInvoice would help keep the public API from drifting.

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

505-523: Avoid the extra customer override lookup for standard invoices. UpdateInvoice now always fetches overrides, but the standard mapper doesn’t need them; branching by invoice type keeps the standard path lean and saves a DB roundtrip.

💡 Suggested refactor
-	genericInvoice, err := invoice.AsGenericInvoice()
-	if err != nil {
-		return UpdateInvoiceResponse{}, fmt.Errorf("converting invoice to generic invoice: %w", err)
-	}
-
-	// TODO: For the V3 api let's make sure that we don't return gathering invoice customer data (or even gathering invoices)
-	mergedProfile, err := h.service.GetCustomerOverride(ctx, billing.GetCustomerOverrideInput{
-		Customer: genericInvoice.GetCustomerID(),
-		Expand: billing.CustomerOverrideExpand{
-			Customer: true,
-			Apps:     true,
-		},
-	})
-	if err != nil {
-		return UpdateInvoiceResponse{}, fmt.Errorf("failed to get customer override: %w", err)
-	}
-
-	return MapInvoiceToAPI(invoice, mergedProfile.Customer, mergedProfile.MergedProfile)
+	if invoice.Type() == billing.InvoiceTypeStandard {
+		stdInvoice, err := invoice.AsStandardInvoice()
+		if err != nil {
+			return UpdateInvoiceResponse{}, fmt.Errorf("converting invoice to standard invoice: %w", err)
+		}
+
+		return MapStandardInvoiceToAPI(stdInvoice)
+	}
+
+	genericInvoice, err := invoice.AsGenericInvoice()
+	if err != nil {
+		return UpdateInvoiceResponse{}, fmt.Errorf("converting invoice to generic invoice: %w", err)
+	}
+
+	// TODO: For the V3 api let's make sure that we don't return gathering invoice customer data (or even gathering invoices)
+	mergedProfile, err := h.service.GetCustomerOverride(ctx, billing.GetCustomerOverrideInput{
+		Customer: genericInvoice.GetCustomerID(),
+		Expand: billing.CustomerOverrideExpand{
+			Customer: true,
+			Apps:     true,
+		},
+	})
+	if err != nil {
+		return UpdateInvoiceResponse{}, fmt.Errorf("failed to get customer override: %w", err)
+	}
+
+	return MapInvoiceToAPI(invoice, mergedProfile.Customer, mergedProfile.MergedProfile)

Comment on lines +790 to +854
func mergeGatheringLineFromInvoiceLineReplaceUpdate(existing billing.GatheringLine, line api.InvoiceLineReplaceUpdate) (billing.GatheringLine, error) {
old, err := existing.Clone()
if err != nil {
return billing.GatheringLine{}, fmt.Errorf("cloning existing line: %w", err)
}

rateCardParsed, err := mapAndValidateInvoiceLineRateCardDeprecatedFields(invoiceLineRateCardItems{
RateCard: line.RateCard,
Price: line.Price,
TaxConfig: line.TaxConfig,
FeatureKey: line.FeatureKey,
})
if err != nil {
return billing.GatheringLine{}, billing.ValidationError{
Err: fmt.Errorf("failed to map usage based line: %w", err),
}
}

existing.Metadata = lo.FromPtrOr(line.Metadata, api.Metadata(existing.Metadata))
existing.Name = line.Name
existing.Description = line.Description

existing.ServicePeriod.From = line.Period.From.Truncate(streaming.MinimumWindowSizeDuration)
existing.ServicePeriod.To = line.Period.To.Truncate(streaming.MinimumWindowSizeDuration)
existing.InvoiceAt = line.InvoiceAt.Truncate(streaming.MinimumWindowSizeDuration)

existing.TaxConfig = rateCardParsed.TaxConfig
existing.Price = lo.FromPtr(rateCardParsed.Price)
existing.FeatureKey = rateCardParsed.FeatureKey

// Rate card discounts are not allowed to be updated on a progressively billed line (e.g. if there is
// already a partial invoice created), as we might go short on the discount quantity.
//
// If this is ever requested:
// - we should introduce the concept of a "discount pool" that is shared across invoices and
// - editing the discount edits the pool
// - editing requires that the discount pool's quantity cannot be less than the already used
// quantity.

if existing.SplitLineGroupID != nil && rateCardParsed.Discounts.Usage != nil && existing.RateCardDiscounts.Usage != nil {
if !equal.PtrEqual(rateCardParsed.Discounts.Usage, existing.RateCardDiscounts.Usage) {
return billing.GatheringLine{}, billing.ValidationError{
Err: fmt.Errorf("line[%s]: %w", existing.ID, billing.ErrInvoiceLineProgressiveBillingUsageDiscountUpdateForbidden),
}
}
}

existing.RateCardDiscounts = rateCardParsed.Discounts

if !old.Equal(existing) {
existing.ManagedBy = billing.ManuallyManagedLine
}

// We are not allowing period change for split lines (or their children), as that would mess up the
// calculation logic and/or we would need to update multiple invoices to correct all the references.
//
// Deletion is allowed.
if old.SplitLineGroupID != nil && !old.ServicePeriod.Equal(existing.ServicePeriod) {
return billing.GatheringLine{}, billing.ValidationError{
Err: fmt.Errorf("line[%s]: %w", existing.ID, billing.ErrInvoiceLineNoPeriodChangeForSplitLine),
}
}

return existing, 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

Missing nil check for Price in the gathering line merge path.

In gatheringLineFromInvoiceLineReplaceUpdate (Line 691-695), you guard against a nil price and return a ValidationError. However, in mergeGatheringLineFromInvoiceLineReplaceUpdate, Line 817 does existing.Price = lo.FromPtr(rateCardParsed.Price) without a nil check. If rateCardParsed.Price is nil, this silently sets Price to its zero value rather than preserving the existing price or returning an error.

The standard counterpart at Line 750 (existing.UsageBased.Price = rateCardParsed.Price) gets away with it because Price is a pointer there. Since GatheringLineBase.Price is a value type, the behavior differs.

Proposed fix
 	existing.TaxConfig = rateCardParsed.TaxConfig
-	existing.Price = lo.FromPtr(rateCardParsed.Price)
+	if rateCardParsed.Price != nil {
+		existing.Price = lo.FromPtr(rateCardParsed.Price)
+	}
 	existing.FeatureKey = rateCardParsed.FeatureKey

Or, if a price should always be required on update (consistent with the create path), add a validation error for nil:

+	if rateCardParsed.Price == nil {
+		return billing.GatheringLine{}, billing.ValidationError{
+			Err: fmt.Errorf("price is required for usage based lines"),
+		}
+	}
+
 	existing.TaxConfig = rateCardParsed.TaxConfig
 	existing.Price = lo.FromPtr(rateCardParsed.Price)
📝 Committable suggestion

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

Suggested change
func mergeGatheringLineFromInvoiceLineReplaceUpdate(existing billing.GatheringLine, line api.InvoiceLineReplaceUpdate) (billing.GatheringLine, error) {
old, err := existing.Clone()
if err != nil {
return billing.GatheringLine{}, fmt.Errorf("cloning existing line: %w", err)
}
rateCardParsed, err := mapAndValidateInvoiceLineRateCardDeprecatedFields(invoiceLineRateCardItems{
RateCard: line.RateCard,
Price: line.Price,
TaxConfig: line.TaxConfig,
FeatureKey: line.FeatureKey,
})
if err != nil {
return billing.GatheringLine{}, billing.ValidationError{
Err: fmt.Errorf("failed to map usage based line: %w", err),
}
}
existing.Metadata = lo.FromPtrOr(line.Metadata, api.Metadata(existing.Metadata))
existing.Name = line.Name
existing.Description = line.Description
existing.ServicePeriod.From = line.Period.From.Truncate(streaming.MinimumWindowSizeDuration)
existing.ServicePeriod.To = line.Period.To.Truncate(streaming.MinimumWindowSizeDuration)
existing.InvoiceAt = line.InvoiceAt.Truncate(streaming.MinimumWindowSizeDuration)
existing.TaxConfig = rateCardParsed.TaxConfig
existing.Price = lo.FromPtr(rateCardParsed.Price)
existing.FeatureKey = rateCardParsed.FeatureKey
// Rate card discounts are not allowed to be updated on a progressively billed line (e.g. if there is
// already a partial invoice created), as we might go short on the discount quantity.
//
// If this is ever requested:
// - we should introduce the concept of a "discount pool" that is shared across invoices and
// - editing the discount edits the pool
// - editing requires that the discount pool's quantity cannot be less than the already used
// quantity.
if existing.SplitLineGroupID != nil && rateCardParsed.Discounts.Usage != nil && existing.RateCardDiscounts.Usage != nil {
if !equal.PtrEqual(rateCardParsed.Discounts.Usage, existing.RateCardDiscounts.Usage) {
return billing.GatheringLine{}, billing.ValidationError{
Err: fmt.Errorf("line[%s]: %w", existing.ID, billing.ErrInvoiceLineProgressiveBillingUsageDiscountUpdateForbidden),
}
}
}
existing.RateCardDiscounts = rateCardParsed.Discounts
if !old.Equal(existing) {
existing.ManagedBy = billing.ManuallyManagedLine
}
// We are not allowing period change for split lines (or their children), as that would mess up the
// calculation logic and/or we would need to update multiple invoices to correct all the references.
//
// Deletion is allowed.
if old.SplitLineGroupID != nil && !old.ServicePeriod.Equal(existing.ServicePeriod) {
return billing.GatheringLine{}, billing.ValidationError{
Err: fmt.Errorf("line[%s]: %w", existing.ID, billing.ErrInvoiceLineNoPeriodChangeForSplitLine),
}
}
return existing, nil
}
func mergeGatheringLineFromInvoiceLineReplaceUpdate(existing billing.GatheringLine, line api.InvoiceLineReplaceUpdate) (billing.GatheringLine, error) {
old, err := existing.Clone()
if err != nil {
return billing.GatheringLine{}, fmt.Errorf("cloning existing line: %w", err)
}
rateCardParsed, err := mapAndValidateInvoiceLineRateCardDeprecatedFields(invoiceLineRateCardItems{
RateCard: line.RateCard,
Price: line.Price,
TaxConfig: line.TaxConfig,
FeatureKey: line.FeatureKey,
})
if err != nil {
return billing.GatheringLine{}, billing.ValidationError{
Err: fmt.Errorf("failed to map usage based line: %w", err),
}
}
existing.Metadata = lo.FromPtrOr(line.Metadata, api.Metadata(existing.Metadata))
existing.Name = line.Name
existing.Description = line.Description
existing.ServicePeriod.From = line.Period.From.Truncate(streaming.MinimumWindowSizeDuration)
existing.ServicePeriod.To = line.Period.To.Truncate(streaming.MinimumWindowSizeDuration)
existing.InvoiceAt = line.InvoiceAt.Truncate(streaming.MinimumWindowSizeDuration)
if rateCardParsed.Price == nil {
return billing.GatheringLine{}, billing.ValidationError{
Err: fmt.Errorf("price is required for usage based lines"),
}
}
existing.TaxConfig = rateCardParsed.TaxConfig
existing.Price = lo.FromPtr(rateCardParsed.Price)
existing.FeatureKey = rateCardParsed.FeatureKey
// Rate card discounts are not allowed to be updated on a progressively billed line (e.g. if there is
// already a partial invoice created), as we might go short on the discount quantity.
//
// If this is ever requested:
// - we should introduce the concept of a "discount pool" that is shared across invoices and
// - editing the discount edits the pool
// - editing requires that the discount pool's quantity cannot be less than the already used
// quantity.
if existing.SplitLineGroupID != nil && rateCardParsed.Discounts.Usage != nil && existing.RateCardDiscounts.Usage != nil {
if !equal.PtrEqual(rateCardParsed.Discounts.Usage, existing.RateCardDiscounts.Usage) {
return billing.GatheringLine{}, billing.ValidationError{
Err: fmt.Errorf("line[%s]: %w", existing.ID, billing.ErrInvoiceLineProgressiveBillingUsageDiscountUpdateForbidden),
}
}
}
existing.RateCardDiscounts = rateCardParsed.Discounts
if !old.Equal(existing) {
existing.ManagedBy = billing.ManuallyManagedLine
}
// We are not allowing period change for split lines (or their children), as that would mess up the
// calculation logic and/or we would need to update multiple invoices to correct all the references.
//
// Deletion is allowed.
if old.SplitLineGroupID != nil && !old.ServicePeriod.Equal(existing.ServicePeriod) {
return billing.GatheringLine{}, billing.ValidationError{
Err: fmt.Errorf("line[%s]: %w", existing.ID, billing.ErrInvoiceLineNoPeriodChangeForSplitLine),
}
}
return existing, nil
}
🤖 Prompt for AI Agents
In `@openmeter/billing/httpdriver/invoiceline.go` around lines 790 - 854,
mergeGatheringLineFromInvoiceLineReplaceUpdate currently assigns existing.Price
= lo.FromPtr(rateCardParsed.Price) without handling a nil rateCardParsed.Price,
which will overwrite the value with zero instead of preserving or rejecting it;
update the function mergeGatheringLineFromInvoiceLineReplaceUpdate to check
rateCardParsed.Price for nil and either preserve existing.Price when nil or
return a billing.ValidationError (consistent with
gatheringLineFromInvoiceLineReplaceUpdate behavior), referencing
rateCardParsed.Price and existing.Price to locate the change.

Comment on lines +209 to +213
func (s *Service) GetGatheringInvoiceById(ctx context.Context, input billing.GetGatheringInvoiceByIdInput) (billing.GatheringInvoice, error) {
return transaction.Run(ctx, s.adapter, func(ctx context.Context) (billing.GatheringInvoice, error) {
return s.adapter.GetGatheringInvoiceById(ctx, input)
})
}
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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's see the structure of the gatheringinvoice.go file
cat -n openmeter/billing/service/gatheringinvoice.go | head -100

Repository: openmeterio/openmeter

Length of output: 3993


🏁 Script executed:

# Also check what's around lines 209-213 to see GetGatheringInvoiceById
cat -n openmeter/billing/service/gatheringinvoice.go | sed -n '200,220p'

Repository: openmeterio/openmeter

Length of output: 617


🏁 Script executed:

# Check lines around 20 to see ListGatheringInvoices
cat -n openmeter/billing/service/gatheringinvoice.go | sed -n '15,35p'

Repository: openmeterio/openmeter

Length of output: 1082


🏁 Script executed:

# Check lines around 92 to see UpdateGatheringInvoice
cat -n openmeter/billing/service/gatheringinvoice.go | sed -n '85,105p'

Repository: openmeterio/openmeter

Length of output: 873


🏁 Script executed:

# Search for GetGatheringInvoiceByIdInput definition and its Validate method
rg -n "type GetGatheringInvoiceByIdInput" --type=go

Repository: openmeterio/openmeter

Length of output: 149


🏁 Script executed:

# Search for Validate method on GetGatheringInvoiceByIdInput
rg -n "func.*\*GetGatheringInvoiceByIdInput.*Validate" --type=go

Repository: openmeterio/openmeter

Length of output: 47


🏁 Script executed:

# Also search more broadly for Validate methods on input types
rg -n "\.Validate\(\)" openmeter/billing/service/gatheringinvoice.go --type=go

Repository: openmeterio/openmeter

Length of output: 250


🏁 Script executed:

# Get the definition of GetGatheringInvoiceByIdInput
cat -n openmeter/billing/gatheringinvoice.go | sed -n '820,850p'

Repository: openmeterio/openmeter

Length of output: 988


🏁 Script executed:

# Search for any Validate method on input types in the billing package
rg -n "func.*Validate.*error" openmeter/billing/ --type=go | grep -i "input"

Repository: openmeterio/openmeter

Length of output: 6342


Add input validation to match the pattern used elsewhere in this file.

GetGatheringInvoiceByIdInput has a Validate() method that checks both the Invoice and Expand fields. Since ListGatheringInvoices (line 20) and UpdateGatheringInvoice (line 92) both validate their inputs before proceeding, GetGatheringInvoiceById should do the same for consistency.

func (s *Service) GetGatheringInvoiceById(ctx context.Context, input billing.GetGatheringInvoiceByIdInput) (billing.GatheringInvoice, error) {
	if err := input.Validate(); err != nil {
		return billing.GatheringInvoice{}, err
	}

	return transaction.Run(ctx, s.adapter, func(ctx context.Context) (billing.GatheringInvoice, error) {
		return s.adapter.GetGatheringInvoiceById(ctx, input)
	})
}
🤖 Prompt for AI Agents
In `@openmeter/billing/service/gatheringinvoice.go` around lines 209 - 213,
GetGatheringInvoiceById is missing input validation like other methods; call
input.Validate() at the start of Service.GetGatheringInvoiceById and return an
empty billing.GatheringInvoice plus the error if validation fails, before
invoking transaction.Run with s.adapter and s.adapter.GetGatheringInvoiceById,
so the method follows the same validation pattern as ListGatheringInvoices and
UpdateGatheringInvoice.

Comment on lines +820 to +899
func (s *Service) UpdateInvoice(ctx context.Context, input billing.UpdateInvoiceInput) (billing.Invoice, error) {
if err := input.Validate(); err != nil {
return billing.Invoice{}, billing.ValidationError{
Err: err,
}
}
return transaction.Run(ctx, s.adapter, func(ctx context.Context) (billing.Invoice, error) {
invoiceType, err := s.adapter.GetInvoiceType(ctx, input.Invoice)
if err != nil {
return billing.Invoice{}, fmt.Errorf("getting invoice type: %w", err)
}

if invoiceType == billing.InvoiceTypeGathering {
err := s.UpdateGatheringInvoice(ctx, billing.UpdateGatheringInvoiceInput{
Invoice: input.Invoice,
EditFn: func(invoice *billing.GatheringInvoice) error {
if invoice == nil {
return fmt.Errorf("invoice is nil")
}

editedInvoice, err := input.EditFn(billing.NewInvoice(*invoice))
if err != nil {
return fmt.Errorf("editing invoice: %w", err)
}

editedGatheringInvoice, err := editedInvoice.AsGatheringInvoice()
if err != nil {
return fmt.Errorf("converting invoice to gathering invoice: %w", err)
}

*invoice = editedGatheringInvoice

return nil
},
})
if err != nil {
return billing.Invoice{}, fmt.Errorf("updating gathering invoice: %w", err)
}

gatheringInvoice, err := s.adapter.GetGatheringInvoiceById(ctx, billing.GetGatheringInvoiceByIdInput{
Invoice: input.Invoice,
Expand: billing.GatheringInvoiceExpands{
billing.GatheringInvoiceExpandLines,
billing.GatheringInvoiceExpandAvailableActions,
},
})
if err != nil {
return billing.Invoice{}, fmt.Errorf("fetching gathering invoice: %w", err)
}

return billing.NewInvoice(gatheringInvoice), nil
}

// Standard invoice
standardInvoice, err := s.UpdateStandardInvoice(ctx, billing.UpdateStandardInvoiceInput{
Invoice: input.Invoice,
EditFn: func(invoice *billing.StandardInvoice) error {
if invoice == nil {
return fmt.Errorf("invoice is nil")
}

editedInvoice, err := input.EditFn(billing.NewInvoice(*invoice))
if err != nil {
return fmt.Errorf("editing invoice: %w", err)
}

editedStandardInvoice, err := editedInvoice.AsStandardInvoice()
if err != nil {
return fmt.Errorf("converting invoice to standard invoice: %w", err)
}

*invoice = editedStandardInvoice

return nil
},
})
if err != nil {
return billing.Invoice{}, fmt.Errorf("updating standard invoice: %w", err)
}

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

Propagate IncludeDeletedLines into the type-specific update calls

UpdateInvoiceInput.IncludeDeletedLines is currently ignored, so edit callbacks won’t see deleted lines even when requested. That breaks parity with the type-specific APIs.

🛠️ Proposed fix
-			err := s.UpdateGatheringInvoice(ctx, billing.UpdateGatheringInvoiceInput{
-				Invoice: input.Invoice,
+			err := s.UpdateGatheringInvoice(ctx, billing.UpdateGatheringInvoiceInput{
+				Invoice:             input.Invoice,
+				IncludeDeletedLines: input.IncludeDeletedLines,
 				EditFn: func(invoice *billing.GatheringInvoice) error {
 					if invoice == nil {
 						return fmt.Errorf("invoice is nil")
 					}
@@
-		standardInvoice, err := s.UpdateStandardInvoice(ctx, billing.UpdateStandardInvoiceInput{
-			Invoice: input.Invoice,
+		standardInvoice, err := s.UpdateStandardInvoice(ctx, billing.UpdateStandardInvoiceInput{
+			Invoice:             input.Invoice,
+			IncludeDeletedLines: input.IncludeDeletedLines,
 			EditFn: func(invoice *billing.StandardInvoice) error {
 				if invoice == nil {
 					return fmt.Errorf("invoice is nil")
 				}
📝 Committable suggestion

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

Suggested change
func (s *Service) UpdateInvoice(ctx context.Context, input billing.UpdateInvoiceInput) (billing.Invoice, error) {
if err := input.Validate(); err != nil {
return billing.Invoice{}, billing.ValidationError{
Err: err,
}
}
return transaction.Run(ctx, s.adapter, func(ctx context.Context) (billing.Invoice, error) {
invoiceType, err := s.adapter.GetInvoiceType(ctx, input.Invoice)
if err != nil {
return billing.Invoice{}, fmt.Errorf("getting invoice type: %w", err)
}
if invoiceType == billing.InvoiceTypeGathering {
err := s.UpdateGatheringInvoice(ctx, billing.UpdateGatheringInvoiceInput{
Invoice: input.Invoice,
EditFn: func(invoice *billing.GatheringInvoice) error {
if invoice == nil {
return fmt.Errorf("invoice is nil")
}
editedInvoice, err := input.EditFn(billing.NewInvoice(*invoice))
if err != nil {
return fmt.Errorf("editing invoice: %w", err)
}
editedGatheringInvoice, err := editedInvoice.AsGatheringInvoice()
if err != nil {
return fmt.Errorf("converting invoice to gathering invoice: %w", err)
}
*invoice = editedGatheringInvoice
return nil
},
})
if err != nil {
return billing.Invoice{}, fmt.Errorf("updating gathering invoice: %w", err)
}
gatheringInvoice, err := s.adapter.GetGatheringInvoiceById(ctx, billing.GetGatheringInvoiceByIdInput{
Invoice: input.Invoice,
Expand: billing.GatheringInvoiceExpands{
billing.GatheringInvoiceExpandLines,
billing.GatheringInvoiceExpandAvailableActions,
},
})
if err != nil {
return billing.Invoice{}, fmt.Errorf("fetching gathering invoice: %w", err)
}
return billing.NewInvoice(gatheringInvoice), nil
}
// Standard invoice
standardInvoice, err := s.UpdateStandardInvoice(ctx, billing.UpdateStandardInvoiceInput{
Invoice: input.Invoice,
EditFn: func(invoice *billing.StandardInvoice) error {
if invoice == nil {
return fmt.Errorf("invoice is nil")
}
editedInvoice, err := input.EditFn(billing.NewInvoice(*invoice))
if err != nil {
return fmt.Errorf("editing invoice: %w", err)
}
editedStandardInvoice, err := editedInvoice.AsStandardInvoice()
if err != nil {
return fmt.Errorf("converting invoice to standard invoice: %w", err)
}
*invoice = editedStandardInvoice
return nil
},
})
if err != nil {
return billing.Invoice{}, fmt.Errorf("updating standard invoice: %w", err)
}
func (s *Service) UpdateInvoice(ctx context.Context, input billing.UpdateInvoiceInput) (billing.Invoice, error) {
if err := input.Validate(); err != nil {
return billing.Invoice{}, billing.ValidationError{
Err: err,
}
}
return transaction.Run(ctx, s.adapter, func(ctx context.Context) (billing.Invoice, error) {
invoiceType, err := s.adapter.GetInvoiceType(ctx, input.Invoice)
if err != nil {
return billing.Invoice{}, fmt.Errorf("getting invoice type: %w", err)
}
if invoiceType == billing.InvoiceTypeGathering {
err := s.UpdateGatheringInvoice(ctx, billing.UpdateGatheringInvoiceInput{
Invoice: input.Invoice,
IncludeDeletedLines: input.IncludeDeletedLines,
EditFn: func(invoice *billing.GatheringInvoice) error {
if invoice == nil {
return fmt.Errorf("invoice is nil")
}
editedInvoice, err := input.EditFn(billing.NewInvoice(*invoice))
if err != nil {
return fmt.Errorf("editing invoice: %w", err)
}
editedGatheringInvoice, err := editedInvoice.AsGatheringInvoice()
if err != nil {
return fmt.Errorf("converting invoice to gathering invoice: %w", err)
}
*invoice = editedGatheringInvoice
return nil
},
})
if err != nil {
return billing.Invoice{}, fmt.Errorf("updating gathering invoice: %w", err)
}
gatheringInvoice, err := s.adapter.GetGatheringInvoiceById(ctx, billing.GetGatheringInvoiceByIdInput{
Invoice: input.Invoice,
Expand: billing.GatheringInvoiceExpands{
billing.GatheringInvoiceExpandLines,
billing.GatheringInvoiceExpandAvailableActions,
},
})
if err != nil {
return billing.Invoice{}, fmt.Errorf("fetching gathering invoice: %w", err)
}
return billing.NewInvoice(gatheringInvoice), nil
}
// Standard invoice
standardInvoice, err := s.UpdateStandardInvoice(ctx, billing.UpdateStandardInvoiceInput{
Invoice: input.Invoice,
IncludeDeletedLines: input.IncludeDeletedLines,
EditFn: func(invoice *billing.StandardInvoice) error {
if invoice == nil {
return fmt.Errorf("invoice is nil")
}
editedInvoice, err := input.EditFn(billing.NewInvoice(*invoice))
if err != nil {
return fmt.Errorf("editing invoice: %w", err)
}
editedStandardInvoice, err := editedInvoice.AsStandardInvoice()
if err != nil {
return fmt.Errorf("converting invoice to standard invoice: %w", err)
}
*invoice = editedStandardInvoice
return nil
},
})
if err != nil {
return billing.Invoice{}, fmt.Errorf("updating standard invoice: %w", err)
}
🤖 Prompt for AI Agents
In `@openmeter/billing/service/invoice.go` around lines 820 - 899, UpdateInvoice
ignores input.IncludeDeletedLines so edit callbacks can't see deleted lines;
pass input.IncludeDeletedLines through into the type-specific update calls by
adding IncludeDeletedLines: input.IncludeDeletedLines to the
UpdateGatheringInvoice call (UpdateGatheringInvoiceInput) and to the
UpdateStandardInvoice call (UpdateStandardInvoiceInput), and also include
input.IncludeDeletedLines when refetching the invoice (e.g., the
s.adapter.GetGatheringInvoiceById / GetStandardInvoiceById call) if those
retrieval inputs support that flag so the returned invoice matches the requested
visibility.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant