diff --git a/openmeter/billing/README.md b/openmeter/billing/README.md index 2cecb2b448..b9116877eb 100644 --- a/openmeter/billing/README.md +++ b/openmeter/billing/README.md @@ -103,6 +103,11 @@ the portions materialized on individual invoices. Already immutable invoice history is not rewritten to make a later subscription change look as if it had always existed. +Legacy split-line hierarchy reads carry persisted history facts rather than +complete line and invoice aggregates. Deleted children remain available for +reconciliation. Previously billed amounts exclude deleted lines and invoices, +including invoices in terminal deleted status without a deletion timestamp. + Physical subscription attribution is the narrow exception: when a subscription-managed charge is rematerialized without changing its intent, reference repair updates the subscription, phase, and item IDs on every diff --git a/openmeter/billing/adapter/invoicelinesplitgroup.go b/openmeter/billing/adapter/invoicelinesplitgroup.go index 17c2a237d2..a15ae83fab 100644 --- a/openmeter/billing/adapter/invoicelinesplitgroup.go +++ b/openmeter/billing/adapter/invoicelinesplitgroup.go @@ -12,6 +12,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoice" "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoiceline" "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoicesplitlinegroup" + "github.com/openmeterio/openmeter/pkg/convert" "github.com/openmeterio/openmeter/pkg/framework/entutils" "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/slicesx" @@ -23,11 +24,9 @@ var _ billing.InvoiceSplitLineGroupAdapter = (*adapter)(nil) func (a *adapter) splitLineGroupLinesWithInvoice(namespace string) func(*db.BillingInvoiceLineQuery) { return func(q *db.BillingInvoiceLineQuery) { q.Where(billinginvoiceline.Namespace(namespace)) - a.expandLineItems(q, namespace) q.WithBillingInvoice(func(q *db.BillingInvoiceQuery) { - q.Where(billinginvoice.Namespace(namespace)). - WithBillingWorkflowConfig(workflowConfigWithTaxCode(namespace)) - }) // TODO[later]: we can consider loading this in a separate query, might be more efficient + q.Where(billinginvoice.Namespace(namespace)) + }) } } @@ -50,7 +49,7 @@ func (a *adapter) GetSplitLineGroupsForSubscription(ctx context.Context, in bill } groups, err := slicesx.MapWithErr(dbGroups, func(dbGroup *db.BillingInvoiceSplitLineGroup) (billing.SplitLineHierarchy, error) { - return tx.mapSplitLineHierarchyFromDB(ctx, dbGroup) + return tx.mapSplitLineHierarchyFromDB(dbGroup) }) if err != nil { return nil, fmt.Errorf("mapping split line groups: %w", err) @@ -179,7 +178,7 @@ func (a *adapter) GetSplitLineGroup(ctx context.Context, input billing.GetSplitL return billing.SplitLineHierarchy{}, err } - return a.mapSplitLineHierarchyFromDB(ctx, dbSplitLineGroup) + return a.mapSplitLineHierarchyFromDB(dbSplitLineGroup) }) } @@ -236,7 +235,7 @@ func (a *adapter) mapSplitLineGroupFromDB(dbSplitLineGroup *db.BillingInvoiceSpl }, nil } -func (a *adapter) mapSplitLineHierarchyFromDB(ctx context.Context, dbSplitLineGroup *db.BillingInvoiceSplitLineGroup) (billing.SplitLineHierarchy, error) { +func (a *adapter) mapSplitLineHierarchyFromDB(dbSplitLineGroup *db.BillingInvoiceSplitLineGroup) (billing.SplitLineHierarchy, error) { empty := billing.SplitLineHierarchy{} group, err := a.mapSplitLineGroupFromDB(dbSplitLineGroup) @@ -244,7 +243,7 @@ func (a *adapter) mapSplitLineHierarchyFromDB(ctx context.Context, dbSplitLineGr return empty, err } - mappedLines, err := a.mapSplitLineHierarchyLinesFromDB(ctx, dbSplitLineGroup.Edges.BillingInvoiceLines) + mappedLines, err := a.mapSplitLineHierarchyLinesFromDB(dbSplitLineGroup.Edges.BillingInvoiceLines) if err != nil { return empty, err } @@ -255,53 +254,66 @@ func (a *adapter) mapSplitLineHierarchyFromDB(ctx context.Context, dbSplitLineGr }, nil } -func (a *adapter) mapSplitLineHierarchyLinesFromDB(ctx context.Context, dbLines []*db.BillingInvoiceLine) ([]billing.LineWithInvoiceHeader, error) { - return slicesx.MapWithErr(dbLines, func(dbLine *db.BillingInvoiceLine) (billing.LineWithInvoiceHeader, error) { - if dbLine.Edges.BillingInvoice == nil { - return billing.LineWithInvoiceHeader{}, fmt.Errorf("billing invoice must be expanded when mapping split line hierarchy lines [id=%s]", dbLine.ID) +func (a *adapter) mapSplitLineHierarchyLinesFromDB(dbLines []*db.BillingInvoiceLine) ([]billing.SplitLineHierarchyLine, error) { + return slicesx.MapWithErr(dbLines, func(dbLine *db.BillingInvoiceLine) (billing.SplitLineHierarchyLine, error) { + dbInvoice := dbLine.Edges.BillingInvoice + if dbInvoice == nil { + return billing.SplitLineHierarchyLine{}, fmt.Errorf("billing invoice must be expanded when mapping split line hierarchy lines [id=%s]", dbLine.ID) } - switch dbLine.Edges.BillingInvoice.Status { - case billing.StandardInvoiceStatusGathering: - return a.mapSplitLineHierarchyGatheringLineFromDB(ctx, dbLine) - default: - return a.mapSplitLineHierarchyStandardLineFromDB(ctx, dbLine) + invoiceType := billing.InvoiceTypeStandard + if dbInvoice.Status == billing.StandardInvoiceStatusGathering { + invoiceType = billing.InvoiceTypeGathering } - }) -} - -func (a *adapter) mapSplitLineHierarchyStandardLineFromDB(ctx context.Context, dbLine *db.BillingInvoiceLine) (billing.LineWithInvoiceHeader, error) { - line, err := a.mapStandardInvoiceLineWithoutReferences(dbLine) - if err != nil { - return billing.LineWithInvoiceHeader{}, err - } - invoice, err := a.mapStandardInvoiceFromDB(ctx, dbLine.Edges.BillingInvoice, billing.StandardInvoiceExpands{}) - if err != nil { - return billing.LineWithInvoiceHeader{}, err - } - - return billing.NewLineWithInvoiceHeader(billing.StandardLineWithInvoiceHeader{ - Line: line, - Invoice: invoice, - }), nil -} + if dbLine.Type != billing.InvoiceLineAdapterTypeUsageBased { + if invoiceType == billing.InvoiceTypeGathering { + return billing.SplitLineHierarchyLine{}, fmt.Errorf("only usage based lines can be gathering invoice lines [line_id=%s]", dbLine.ID) + } -func (a *adapter) mapSplitLineHierarchyGatheringLineFromDB(ctx context.Context, dbLine *db.BillingInvoiceLine) (billing.LineWithInvoiceHeader, error) { - line, err := a.mapGatheringInvoiceLineFromDB(dbLine) - if err != nil { - return billing.LineWithInvoiceHeader{}, err - } + return billing.SplitLineHierarchyLine{}, fmt.Errorf("only usage based lines can be top level lines [line_id=%s]", dbLine.ID) + } - invoice, err := a.mapGatheringInvoiceFromDB(ctx, dbLine.Edges.BillingInvoice, billing.GatheringInvoiceExpands{}) - if err != nil { - return billing.LineWithInvoiceHeader{}, err - } + var subscription *billing.SubscriptionReference + if dbLine.SubscriptionID != nil && dbLine.SubscriptionPhaseID != nil && dbLine.SubscriptionItemID != nil { + subscription = &billing.SubscriptionReference{ + SubscriptionID: *dbLine.SubscriptionID, + PhaseID: *dbLine.SubscriptionPhaseID, + ItemID: *dbLine.SubscriptionItemID, + BillingPeriod: timeutil.ClosedPeriod{ + From: lo.FromPtr(dbLine.SubscriptionBillingPeriodFrom).UTC(), + To: lo.FromPtr(dbLine.SubscriptionBillingPeriodTo).UTC(), + }, + } + } - return billing.NewLineWithInvoiceHeader(billing.GatheringLineWithInvoiceHeader{ - Line: line, - Invoice: invoice, - }), nil + return billing.SplitLineHierarchyLine{ + LineID: billing.LineID{ + Namespace: dbLine.Namespace, + ID: dbLine.ID, + }, + Invoice: billing.SplitLineHierarchyInvoice{ + InvoiceID: billing.InvoiceID{ + Namespace: dbInvoice.Namespace, + ID: dbInvoice.ID, + }, + Type: invoiceType, + DeletedAt: convert.TimePtrIn(dbInvoice.DeletedAt, time.UTC), + Status: dbInvoice.Status, + }, + DeletedAt: convert.TimePtrIn(dbLine.DeletedAt, time.UTC), + ServicePeriod: timeutil.ClosedPeriod{ + From: dbLine.PeriodStart.UTC(), + To: dbLine.PeriodEnd.UTC(), + }, + ManagedBy: dbLine.ManagedBy, + Annotations: dbLine.Annotations, + Subscription: subscription, + InvoiceAt: dbLine.InvoiceAt.UTC(), + Amount: dbLine.Amount, + ChargesTotal: dbLine.ChargesTotal, + }, nil + }) } type lineIdToSplitLineHierarchy map[string]*billing.SplitLineHierarchy @@ -332,7 +344,7 @@ func (a *adapter) expandSplitLineHierarchy(ctx context.Context, namespace string hierarchyByLineID := map[string]*billing.SplitLineHierarchy{} for _, splitLineGroup := range splitLineGroups { for _, line := range splitLineGroup.Lines { - hierarchyByLineID[line.Line.GetID()] = &splitLineGroup + hierarchyByLineID[line.ID] = &splitLineGroup } } @@ -376,7 +388,7 @@ func (a *adapter) fetchAllSplitLineGroups(ctx context.Context, namespace string, } return slicesx.MapWithErr(dbSplitLineGroups, func(dbSplitLineGroup *db.BillingInvoiceSplitLineGroup) (billing.SplitLineHierarchy, error) { - return a.mapSplitLineHierarchyFromDB(ctx, dbSplitLineGroup) + return a.mapSplitLineHierarchyFromDB(dbSplitLineGroup) }) } diff --git a/openmeter/billing/invoicelinesplitgroup.go b/openmeter/billing/invoicelinesplitgroup.go index 0a708a51d3..73369fc6f1 100644 --- a/openmeter/billing/invoicelinesplitgroup.go +++ b/openmeter/billing/invoicelinesplitgroup.go @@ -46,6 +46,11 @@ func (i SplitLineGroupMutableFields) ValidateForPrice(price *productcatalog.Pric func (i SplitLineGroupMutableFields) Clone() SplitLineGroupMutableFields { clone := i + clone.Metadata = i.Metadata.Clone() + if i.Description != nil { + clone.Description = lo.ToPtr(*i.Description) + } + clone.RatecardDiscounts = i.RatecardDiscounts.Clone() return clone @@ -159,16 +164,29 @@ func (i SplitLineGroup) ToUpdate() SplitLineGroupUpdate { } func (i SplitLineGroup) Clone() SplitLineGroup { - return SplitLineGroup{ - ManagedModel: i.ManagedModel, - NamespacedID: i.NamespacedID, - SplitLineGroupMutableFields: i.SplitLineGroupMutableFields.Clone(), - Price: i.Price, - FeatureKey: i.FeatureKey, - Subscription: i.Subscription, - Currency: i.Currency, - UniqueReferenceID: i.UniqueReferenceID, + clone := i + clone.SplitLineGroupMutableFields = i.SplitLineGroupMutableFields.Clone() + if i.DeletedAt != nil { + clone.DeletedAt = lo.ToPtr(*i.DeletedAt) + } + + if i.Price != nil { + clone.Price = i.Price.Clone() + } + + if i.FeatureKey != nil { + clone.FeatureKey = lo.ToPtr(*i.FeatureKey) + } + + if i.Subscription != nil { + clone.Subscription = i.Subscription.Clone() + } + + if i.UniqueReferenceID != nil { + clone.UniqueReferenceID = lo.ToPtr(*i.UniqueReferenceID) } + + return clone } type GatheringLineWithInvoiceHeader struct { @@ -322,23 +340,64 @@ func (i LinesWithInvoiceHeaders) Lines() []GenericInvoiceLine { }) } +// SplitLineHierarchyInvoice carries the identity and deletion facts needed to +// interpret a child's billing history without loading its invoice aggregate. +type SplitLineHierarchyInvoice struct { + InvoiceID `json:",inline"` + Type InvoiceType `json:"type"` + DeletedAt *time.Time `json:"deletedAt,omitempty"` + Status StandardInvoiceStatus `json:"status"` +} + +// SplitLineHierarchyLine is the persisted history used by legacy rating and +// subscription reconciliation. Updates describe intent; the invoice updater +// loads the canonical line before applying them. +type SplitLineHierarchyLine struct { + LineID `json:",inline"` + Invoice SplitLineHierarchyInvoice `json:"invoice"` + DeletedAt *time.Time `json:"deletedAt,omitempty"` + ServicePeriod timeutil.ClosedPeriod `json:"period"` + ManagedBy InvoiceLineManagedBy `json:"managedBy"` + Annotations models.Annotations `json:"annotations,omitempty"` + Subscription *SubscriptionReference `json:"subscription,omitempty"` + InvoiceAt time.Time `json:"invoiceAt"` + Amount alpacadecimal.Decimal `json:"amount"` + ChargesTotal alpacadecimal.Decimal `json:"chargesTotal"` +} + +func (l SplitLineHierarchyLine) Clone() (SplitLineHierarchyLine, error) { + clone := l + annotations, err := l.Annotations.Clone() + if err != nil { + return SplitLineHierarchyLine{}, fmt.Errorf("cloning annotations: %w", err) + } + + clone.Annotations = annotations + if l.Subscription != nil { + clone.Subscription = l.Subscription.Clone() + } + + if l.DeletedAt != nil { + clone.DeletedAt = lo.ToPtr(*l.DeletedAt) + } + + if l.Invoice.DeletedAt != nil { + clone.Invoice.DeletedAt = lo.ToPtr(*l.Invoice.DeletedAt) + } + + return clone, nil +} + type SplitLineHierarchy struct { Group SplitLineGroup - Lines LinesWithInvoiceHeaders + // Preserve database traversal order, including historical deleted children. + Lines []SplitLineHierarchyLine } func (h *SplitLineHierarchy) Clone() (SplitLineHierarchy, error) { - lines, err := slicesx.MapWithErr(h.Lines, func(line LineWithInvoiceHeader) (LineWithInvoiceHeader, error) { - clonedLine, err := line.Line.Clone() - if err != nil { - return LineWithInvoiceHeader{}, err - } - - // TODO: We might want to clone the invoice too, but that data is mostly read-only, so it's fine for now. - return LineWithInvoiceHeader{Line: clonedLine, Invoice: line.Invoice}, nil - }) + lines, err := slicesx.MapWithErr(h.Lines, SplitLineHierarchyLine.Clone) if err != nil { - return SplitLineHierarchy{}, err + return SplitLineHierarchy{}, fmt.Errorf("cloning hierarchy lines: %w", err) } return SplitLineHierarchy{ @@ -352,29 +411,23 @@ type SumNetAmountInput struct { IncludeCharges bool } -// SumNetAmount returns the sum of the net amount (pre-tax) of the progressive billed line and its children -// containing the values for all lines whose period's end is <= in.UpTo and are not deleted or not part of -// an invoice that has been deleted. -// As gathering lines do not represent any kind of actual charge, they are not included in the sum. +// SumNetAmount sums prior standard-line amounts through PeriodEndLTE, excluding +// deleted lines and invoices. A zero cutoff includes all periods. Gathering +// children do not represent billed amounts and never contribute to the sum. func (h *SplitLineHierarchy) SumNetAmount(in SumNetAmountInput) (alpacadecimal.Decimal, error) { netAmount := alpacadecimal.Zero err := h.ForEachChild(ForEachChildInput{ PeriodEndLTE: in.PeriodEndLTE, - Callback: func(child LineWithInvoiceHeader) error { - if child.Invoice.AsInvoice().Type() != InvoiceTypeStandard { + Callback: func(child SplitLineHierarchyLine) error { + if child.Invoice.Type != InvoiceTypeStandard { return nil } - stdLine, err := child.Line.AsInvoiceLine().AsStandardLine() - if err != nil { - return err - } - - netAmount = netAmount.Add(stdLine.Totals.Amount) + netAmount = netAmount.Add(child.Amount) if in.IncludeCharges { - netAmount = netAmount.Add(stdLine.Totals.ChargesTotal) + netAmount = netAmount.Add(child.ChargesTotal) } return nil @@ -389,38 +442,28 @@ func (h *SplitLineHierarchy) SumNetAmount(in SumNetAmountInput) (alpacadecimal.D type ForEachChildInput struct { PeriodEndLTE time.Time - Callback func(child LineWithInvoiceHeader) error + Callback func(child SplitLineHierarchyLine) error } func (h *SplitLineHierarchy) ForEachChild(in ForEachChildInput) error { for _, child := range h.Lines { - line := child.Line - // The line is not in scope - if !in.PeriodEndLTE.IsZero() && line.GetServicePeriod().To.After(in.PeriodEndLTE) { + if !in.PeriodEndLTE.IsZero() && child.ServicePeriod.To.After(in.PeriodEndLTE) { continue } // The line is deleted - if line.GetDeletedAt() != nil { + if child.DeletedAt != nil { continue } // The invoice is deleted - if child.Invoice.GetDeletedAt() != nil { + if child.Invoice.DeletedAt != nil { continue } - invoice := child.Invoice.AsInvoice() - if invoice.Type() == InvoiceTypeStandard { - stdInvoice, err := invoice.AsStandardInvoice() - if err != nil { - return err - } - - if stdInvoice.Status == StandardInvoiceStatusDeleted { - continue - } + if child.Invoice.Type == InvoiceTypeStandard && child.Invoice.Status == StandardInvoiceStatusDeleted { + continue } if err := in.Callback(child); err != nil { diff --git a/openmeter/billing/invoicelinesplitgroup_test.go b/openmeter/billing/invoicelinesplitgroup_test.go index d73691c636..b9e425ce26 100644 --- a/openmeter/billing/invoicelinesplitgroup_test.go +++ b/openmeter/billing/invoicelinesplitgroup_test.go @@ -3,7 +3,6 @@ package billing import ( "encoding/json" "testing" - "time" "github.com/alpacahq/alpacadecimal" "github.com/stretchr/testify/require" @@ -12,78 +11,52 @@ import ( "github.com/openmeterio/openmeter/pkg/models" ) -func TestSplitLineGroupMarshaling(t *testing.T) { - now := time.Now().In(time.UTC) - - testGroup := SplitLineHierarchy{ - Group: SplitLineGroup{ - ManagedModel: models.ManagedModel{ - CreatedAt: now, - UpdatedAt: now, - }, - NamespacedID: models.NamespacedID{ - Namespace: "test", - ID: "123", - }, - }, - Lines: LinesWithInvoiceHeaders{ - { - Line: &standardInvoiceLineGenericWrapper{StandardLine: &StandardLine{ - StandardLineBase: StandardLineBase{ - ManagedResource: models.ManagedResource{ - ID: "123", - }, - }, - UsageBased: &UsageBasedLine{ - Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ - Amount: alpacadecimal.NewFromFloat(100), - }), - }, - }}, - Invoice: &StandardInvoice{ - StandardInvoiceBase: StandardInvoiceBase{ - ID: "123", - Number: "invoice-123", +func TestLineWithInvoiceHeadersMarshaling(t *testing.T) { + lines := LinesWithInvoiceHeaders{ + { + Line: &standardInvoiceLineGenericWrapper{StandardLine: &StandardLine{ + StandardLineBase: StandardLineBase{ + ManagedResource: models.ManagedResource{ + ID: "123", }, }, + UsageBased: &UsageBasedLine{ + Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ + Amount: alpacadecimal.NewFromFloat(100), + }), + }, + }}, + Invoice: &StandardInvoice{ + StandardInvoiceBase: StandardInvoiceBase{ + ID: "123", + Number: "invoice-123", + }, }, - { - Line: &gatheringInvoiceLineGenericWrapper{GatheringLine: GatheringLine{ - GatheringLineBase: GatheringLineBase{ - ManagedResource: models.ManagedResource{ - ID: "123", - }, - Price: *productcatalog.NewPriceFrom(productcatalog.UnitPrice{ - Amount: alpacadecimal.NewFromFloat(100), - }), + }, + { + Line: &gatheringInvoiceLineGenericWrapper{GatheringLine: GatheringLine{ + GatheringLineBase: GatheringLineBase{ + ManagedResource: models.ManagedResource{ + ID: "123", }, - }}, - Invoice: &GatheringInvoice{ - GatheringInvoiceBase: GatheringInvoiceBase{ - ManagedResource: models.ManagedResource{ - ID: "123", - }, - Number: "invoice-123", + Price: *productcatalog.NewPriceFrom(productcatalog.UnitPrice{ + Amount: alpacadecimal.NewFromFloat(100), + }), + }, + }}, + Invoice: &GatheringInvoice{ + GatheringInvoiceBase: GatheringInvoiceBase{ + ManagedResource: models.ManagedResource{ + ID: "123", }, + Number: "invoice-123", }, }, }, } - - jsonBytes, err := json.Marshal(testGroup) - if err != nil { - t.Fatalf("failed to marshal test group: %v", err) - } - - t.Logf("test group: %s", string(jsonBytes)) - - var unmarshalled SplitLineHierarchy - err = json.Unmarshal(jsonBytes, &unmarshalled) - if err != nil { - t.Fatalf("failed to unmarshal test group: %v", err) - } - - t.Logf("unmarshalled test group: %+v", unmarshalled) - - require.Equal(t, testGroup, unmarshalled) + jsonBytes, err := json.Marshal(lines) + require.NoError(t, err) + var unmarshalled LinesWithInvoiceHeaders + require.NoError(t, json.Unmarshal(jsonBytes, &unmarshalled)) + require.Equal(t, lines, unmarshalled) } diff --git a/openmeter/billing/lineengine/rating_test.go b/openmeter/billing/lineengine/rating_test.go index 8f0ec3186b..5d8e971fa1 100644 --- a/openmeter/billing/lineengine/rating_test.go +++ b/openmeter/billing/lineengine/rating_test.go @@ -383,29 +383,60 @@ func TestRateStandardLinesProgressiveCommitments(t *testing.T) { }, }, } - for _, childState := range []string{"live", "deleted line", "deleted invoice", "terminal deleted invoice", "later period"} { + for _, childCase := range []struct { + name string + setup func(*billing.SplitLineHierarchyLine) + }{ + { + name: "live", + setup: func(*billing.SplitLineHierarchyLine) {}, + }, + { + name: "deleted line", + setup: func(child *billing.SplitLineHierarchyLine) { + child.DeletedAt = lo.ToPtr(periodBoundary) + }, + }, + { + name: "deleted invoice", + setup: func(child *billing.SplitLineHierarchyLine) { + child.Invoice.DeletedAt = lo.ToPtr(periodBoundary) + }, + }, + { + name: "terminal deleted invoice", + setup: func(child *billing.SplitLineHierarchyLine) { + child.Invoice.Status = billing.StandardInvoiceStatusDeleted + }, + }, + { + name: "later period", + setup: func(child *billing.SplitLineHierarchyLine) { + child.ServicePeriod.To = fullPeriod.To + }, + }, + } { priorLine := standardLineForLineEngineOverrideTest(t, timeutil.ClosedPeriod{ From: fullPeriod.From, To: periodBoundary, }) - priorLine.ID = childState + priorLine.ID = childCase.name priorLine.Totals.Amount = alpacadecimal.NewFromInt(40) priorInvoice := quantitySnapshotTestInvoice() - switch childState { - case "deleted line": - priorLine.DeletedAt = lo.ToPtr(periodBoundary) - case "deleted invoice": - priorInvoice.DeletedAt = lo.ToPtr(periodBoundary) - case "terminal deleted invoice": - priorInvoice.Status = billing.StandardInvoiceStatusDeleted - case "later period": - priorLine.Period.To = fullPeriod.To + child := billing.SplitLineHierarchyLine{ + LineID: priorLine.GetLineID(), + ServicePeriod: priorLine.Period, + DeletedAt: priorLine.DeletedAt, + Amount: priorLine.Totals.Amount, + Invoice: billing.SplitLineHierarchyInvoice{ + InvoiceID: billing.InvoiceID{Namespace: priorInvoice.Namespace, ID: priorInvoice.ID}, + Type: billing.InvoiceTypeStandard, + Status: priorInvoice.Status, + DeletedAt: priorInvoice.DeletedAt, + }, } - - line.SplitLineHierarchy.Lines = append(line.SplitLineHierarchy.Lines, billing.NewLineWithInvoiceHeader(billing.StandardLineWithInvoiceHeader{ - Line: priorLine, - Invoice: priorInvoice, - })) + childCase.setup(&child) + line.SplitLineHierarchy.Lines = append(line.SplitLineHierarchy.Lines, child) } before, err := json.Marshal(line) diff --git a/openmeter/billing/service/invoicelinesplitgroup.go b/openmeter/billing/service/invoicelinesplitgroup.go index 465481492a..003b853560 100644 --- a/openmeter/billing/service/invoicelinesplitgroup.go +++ b/openmeter/billing/service/invoicelinesplitgroup.go @@ -40,7 +40,7 @@ func (s *Service) DeleteSplitLineGroup(ctx context.Context, input billing.Delete // Let's validate that all of it's children are also deleted for _, childLine := range splitLineGroup.Lines { - if childLine.Line.GetDeletedAt() == nil { + if childLine.DeletedAt == nil { return billing.ValidationError{ Err: fmt.Errorf("child lines must be deleted, to delete split line group %s", input.ID), } diff --git a/openmeter/billing/splitlinehierarchy_test.go b/openmeter/billing/splitlinehierarchy_test.go new file mode 100644 index 0000000000..77b38a37bb --- /dev/null +++ b/openmeter/billing/splitlinehierarchy_test.go @@ -0,0 +1,87 @@ +package billing + +import ( + "encoding/json" + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/pkg/models" +) + +func TestSplitLineHierarchyPreviouslyBilledAmountExcludesGatheringLines(t *testing.T) { + // Given an issued line and a gathering line that both have stored amounts. + hierarchy := SplitLineHierarchy{ + Lines: []SplitLineHierarchyLine{ + { + Invoice: SplitLineHierarchyInvoice{ + Type: InvoiceTypeStandard, + Status: StandardInvoiceStatusPaid, + }, + Amount: alpacadecimal.NewFromInt(40), + }, + { + Invoice: SplitLineHierarchyInvoice{ + Type: InvoiceTypeGathering, + Status: StandardInvoiceStatusGathering, + }, + Amount: alpacadecimal.NewFromInt(60), + }, + }, + } + + // When previously billed amounts are summed, only the issued line contributes. + amount, err := hierarchy.SumNetAmount(SumNetAmountInput{}) + require.NoError(t, err) + require.Equal(t, float64(40), amount.InexactFloat64()) +} + +func TestSplitLineHierarchyCloneDoesNotShareMutableFields(t *testing.T) { + // Given a hierarchy with subscriptions, deletion timestamps, and nested annotations. + now := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + source := SplitLineHierarchy{ + Group: SplitLineGroup{ + NamespacedID: models.NamespacedID{Namespace: "ns", ID: "group"}, + SplitLineGroupMutableFields: SplitLineGroupMutableFields{ + Description: lo.ToPtr("group description"), + Metadata: models.Metadata{"key": "value"}, + }, + Subscription: &SubscriptionReference{ItemID: "group item"}, + }, + Lines: []SplitLineHierarchyLine{ + { + LineID: LineID{Namespace: "ns", ID: "line"}, + Invoice: SplitLineHierarchyInvoice{ + InvoiceID: InvoiceID{Namespace: "ns", ID: "invoice"}, + Type: InvoiceTypeStandard, + Status: StandardInvoiceStatusDeleted, + DeletedAt: &now, + }, + DeletedAt: &now, + Subscription: &SubscriptionReference{ItemID: "child item"}, + Annotations: models.Annotations{"nested": map[string]interface{}{"value": "original"}}, + }, + }, + } + before, err := json.Marshal(source) + require.NoError(t, err) + + // When subscriptions, timestamps, metadata, and annotations on the clone are changed. + clone, err := source.Clone() + require.NoError(t, err) + clone.Group.Subscription.ItemID = "changed" + clone.Group.Metadata["key"] = "changed" + *clone.Group.Description = "changed" + clone.Lines[0].Subscription.ItemID = "changed" + *clone.Lines[0].DeletedAt = now.Add(time.Hour) + *clone.Lines[0].Invoice.DeletedAt = now.Add(2 * time.Hour) + clone.Lines[0].Annotations["nested"].(map[string]interface{})["value"] = "changed" + + // Then the original hierarchy remains unchanged. + after, err := json.Marshal(source) + require.NoError(t, err) + require.JSONEq(t, string(before), string(after)) +} diff --git a/openmeter/billing/worker/subscriptionsync/service/persistedstate/hierarchy_test.go b/openmeter/billing/worker/subscriptionsync/service/persistedstate/hierarchy_test.go new file mode 100644 index 0000000000..d89553ec92 --- /dev/null +++ b/openmeter/billing/worker/subscriptionsync/service/persistedstate/hierarchy_test.go @@ -0,0 +1,99 @@ +package persistedstate + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func TestNormalizePersistedHierarchyPeriodsAndInvoiceAt(t *testing.T) { + // Given stored service periods and InvoiceAt values with fractional seconds. + start := time.Date(2026, 10, 1, 0, 0, 0, 123456789, time.UTC) + end := start.AddDate(0, 1, 0) + period := timeutil.ClosedPeriod{From: start, To: end} + hierarchy := billing.SplitLineHierarchy{ + Group: billing.SplitLineGroup{ + SplitLineGroupMutableFields: billing.SplitLineGroupMutableFields{ServicePeriod: period}, + }, + Lines: []billing.SplitLineHierarchyLine{ + { + LineID: billing.LineID{Namespace: "ns", ID: "line"}, + Invoice: billing.SplitLineHierarchyInvoice{Type: billing.InvoiceTypeGathering}, + ServicePeriod: period, + InvoiceAt: end.Add(time.Second), + }, + { + LineID: billing.LineID{Namespace: "ns", ID: "standard"}, + Invoice: billing.SplitLineHierarchyInvoice{Type: billing.InvoiceTypeStandard}, + ServicePeriod: period, + InvoiceAt: end.Add(time.Second), + }, + }, + } + + // When the stored timestamps are normalized for subscription sync. + normalized, err := normalizePersistedSplitLineHierarchy(hierarchy) + require.NoError(t, err) + + // Then service periods and gathering InvoiceAt lose fractional seconds; standard InvoiceAt is preserved. + expectedPeriod := timeutil.ClosedPeriod{ + From: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), + } + require.Equal(t, expectedPeriod, normalized.Group.ServicePeriod) + require.Equal(t, expectedPeriod, normalized.Lines[0].ServicePeriod) + require.Equal(t, time.Date(2026, 11, 1, 0, 0, 1, 0, time.UTC), normalized.Lines[0].InvoiceAt) + require.Equal(t, expectedPeriod, normalized.Lines[1].ServicePeriod) + require.Equal(t, end.Add(time.Second), normalized.Lines[1].InvoiceAt) + require.Equal(t, period, hierarchy.Group.ServicePeriod) + for _, child := range hierarchy.Lines { + require.Equal(t, period, child.ServicePeriod) + require.Equal(t, end.Add(time.Second), child.InvoiceAt) + } +} + +func TestHierarchyOwnershipUsesFirstLiveChildAtGroupEnd(t *testing.T) { + // Given three children ending on November 1: a deleted line, a subscription-managed line, and a manual line. + end := time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC) + hierarchy := &billing.SplitLineHierarchy{ + Group: billing.SplitLineGroup{ + SplitLineGroupMutableFields: billing.SplitLineGroupMutableFields{ServicePeriod: timeutil.ClosedPeriod{To: end}}, + }, + Lines: []billing.SplitLineHierarchyLine{ + {LineID: billing.LineID{ID: "deleted"}, ServicePeriod: timeutil.ClosedPeriod{To: end}, DeletedAt: &end}, + { + LineID: billing.LineID{ID: "first"}, + Invoice: billing.SplitLineHierarchyInvoice{Type: billing.InvoiceTypeStandard, Status: billing.StandardInvoiceStatusDeleted}, + ServicePeriod: timeutil.ClosedPeriod{To: end}, + ManagedBy: billing.SubscriptionManagedLine, + Annotations: models.Annotations{billing.AnnotationSubscriptionSyncIgnore: true}, + }, + { + LineID: billing.LineID{ID: "second"}, + ServicePeriod: timeutil.ClosedPeriod{To: end}, + ManagedBy: billing.ManuallyManagedLine, + }, + }, + } + item, err := newPersistedSplitLineHierarchy(hierarchy) + require.NoError(t, err) + + // When sync reads ownership and annotations, it uses the first undeleted line, even on a deleted invoice. + require.Equal(t, "first", item.getLastLineForAnnotations().ID) + require.True(t, item.IsSubscriptionManaged()) + require.True(t, item.HasLastLineAnnotation(billing.AnnotationSubscriptionSyncIgnore)) + + // Then putting the manual line first changes the group's ownership and ignore flag. + hierarchy.Lines[1], hierarchy.Lines[2] = hierarchy.Lines[2], hierarchy.Lines[1] + require.Equal(t, "second", item.getLastLineForAnnotations().ID) + require.False(t, item.IsSubscriptionManaged()) + require.False(t, item.HasLastLineAnnotation(billing.AnnotationSubscriptionSyncIgnore)) + hierarchy.Lines = nil + require.False(t, item.IsSubscriptionManaged()) + require.False(t, item.HasLastLineAnnotation(billing.AnnotationSubscriptionSyncIgnore)) +} diff --git a/openmeter/billing/worker/subscriptionsync/service/persistedstate/item.go b/openmeter/billing/worker/subscriptionsync/service/persistedstate/item.go index 4a6341822f..4357a3ffb8 100644 --- a/openmeter/billing/worker/subscriptionsync/service/persistedstate/item.go +++ b/openmeter/billing/worker/subscriptionsync/service/persistedstate/item.go @@ -168,7 +168,7 @@ func (i persistedSplitLineHierarchy) IsSubscriptionManaged() bool { return false } - return child.GetManagedBy() == billing.SubscriptionManagedLine + return child.ManagedBy == billing.SubscriptionManagedLine } func (i persistedSplitLineHierarchy) HasLastLineAnnotation(annotation string) bool { @@ -177,14 +177,15 @@ func (i persistedSplitLineHierarchy) HasLastLineAnnotation(annotation string) bo return false } - return child.GetAnnotations().GetBool(annotation) + return child.Annotations.GetBool(annotation) } -func (i persistedSplitLineHierarchy) getLastLineForAnnotations() billing.GenericInvoiceLine { +func (i persistedSplitLineHierarchy) getLastLineForAnnotations() *billing.SplitLineHierarchyLine { servicePeriod := i.hierarchy.Group.ServicePeriod - for _, child := range i.hierarchy.Lines { - if child.Line.GetServicePeriod().To.Equal(servicePeriod.To) && child.Line.GetDeletedAt() == nil { - return child.Line + for childIdx := range i.hierarchy.Lines { + child := &i.hierarchy.Lines[childIdx] + if child.ServicePeriod.To.Equal(servicePeriod.To) && child.DeletedAt == nil { + return child } } diff --git a/openmeter/billing/worker/subscriptionsync/service/persistedstate/loader.go b/openmeter/billing/worker/subscriptionsync/service/persistedstate/loader.go index 7649ebca9c..8ec4f021bf 100644 --- a/openmeter/billing/worker/subscriptionsync/service/persistedstate/loader.go +++ b/openmeter/billing/worker/subscriptionsync/service/persistedstate/loader.go @@ -241,7 +241,7 @@ func (l Loader) loadInvoicesForSubscriptionItems(ctx context.Context, subs subsc } for _, child := range hierarchy.Lines { - invoiceIDs[child.Invoice.GetID()] = struct{}{} + invoiceIDs[child.Invoice.ID] = struct{}{} } default: return Invoices{}, fmt.Errorf("unsupported persisted invoice item type: %s", item.Type()) @@ -314,8 +314,6 @@ func normalizePersistedLine(line billing.GenericInvoiceLine) (billing.GenericInv invoiceAtAccessor.SetInvoiceAt(invoiceAtAccessor.GetInvoiceAt().Truncate(streaming.MinimumWindowSizeDuration)) } - normalizeSubscriptionReference(cloned.GetSubscriptionReference()) - return cloned, nil } @@ -328,30 +326,12 @@ func normalizePersistedSplitLineHierarchy(hierarchy billing.SplitLineHierarchy) cloned.Group.ServicePeriod = cloned.Group.ServicePeriod.Truncate(streaming.MinimumWindowSizeDuration) for i := range cloned.Lines { - cloned.Lines[i].Line.UpdateServicePeriod(func(period *timeutil.ClosedPeriod) { - *period = period.Truncate(streaming.MinimumWindowSizeDuration) - }) - - if invoiceAtAccessor, ok := cloned.Lines[i].Line.(billing.InvoiceAtAccessor); ok { - invoiceAtAccessor.SetInvoiceAt(invoiceAtAccessor.GetInvoiceAt().Truncate(streaming.MinimumWindowSizeDuration)) + child := &cloned.Lines[i] + child.ServicePeriod = child.ServicePeriod.Truncate(streaming.MinimumWindowSizeDuration) + if child.Invoice.Type == billing.InvoiceTypeGathering { + child.InvoiceAt = child.InvoiceAt.Truncate(streaming.MinimumWindowSizeDuration) } - - normalizeSubscriptionReference(cloned.Lines[i].Line.GetSubscriptionReference()) } return &cloned, nil } - -func normalizeSubscriptionReference(ref *billing.SubscriptionReference) { - if ref == nil { - return - } - - // Historical billing rows can carry sub-second subscription billing periods even - // though subscription sync and meter queries operate on MinimumWindowSizeDuration - // precision. Normalize the persisted subscription reference on read so legacy - // timestamp precision does not leak into reconciliation decisions. - // TODO: Add a migration to normalize existing billing timestamps to the precision - // supported by meter queries. - ref.BillingPeriod = ref.BillingPeriod.Truncate(streaming.MinimumWindowSizeDuration) -} diff --git a/openmeter/billing/worker/subscriptionsync/service/reconciler/patchhelpers.go b/openmeter/billing/worker/subscriptionsync/service/reconciler/patchhelpers.go index 72a93062db..da74f6dd54 100644 --- a/openmeter/billing/worker/subscriptionsync/service/reconciler/patchhelpers.go +++ b/openmeter/billing/worker/subscriptionsync/service/reconciler/patchhelpers.go @@ -35,7 +35,7 @@ func shouldSkipHierarchyPatch(existingHierarchy *billing.SplitLineHierarchy, exp } for _, line := range existingHierarchy.Lines { - if line.Line.GetAnnotations().GetBool(billing.AnnotationSubscriptionSyncIgnore) { + if line.Annotations.GetBool(billing.AnnotationSubscriptionSyncIgnore) { return true } } diff --git a/openmeter/billing/worker/subscriptionsync/service/reconciler/patchinvoicelinehierarchy.go b/openmeter/billing/worker/subscriptionsync/service/reconciler/patchinvoicelinehierarchy.go index 093383c9ad..076b5294a4 100644 --- a/openmeter/billing/worker/subscriptionsync/service/reconciler/patchinvoicelinehierarchy.go +++ b/openmeter/billing/worker/subscriptionsync/service/reconciler/patchinvoicelinehierarchy.go @@ -44,7 +44,7 @@ func (c *lineHierarchyPatchCollection) AddDelete(uniqueID string, existing persi patches := make([]invoiceupdater.Patch, 0, 1+len(group.Lines)) for _, line := range group.Lines { - if line.Line.GetAnnotations().GetBool(billing.AnnotationSubscriptionSyncIgnore) { + if line.Annotations.GetBool(billing.AnnotationSubscriptionSyncIgnore) { return nil } } @@ -54,11 +54,11 @@ func (c *lineHierarchyPatchCollection) AddDelete(uniqueID string, existing persi } for _, line := range group.Lines { - if line.Line.GetDeletedAt() != nil { + if line.DeletedAt != nil { continue } - patches = append(patches, invoiceupdater.NewDeleteLinePatch(line.Line.GetLineID(), line.Invoice.GetID())) + patches = append(patches, invoiceupdater.NewDeleteLinePatch(line.LineID, line.Invoice.ID)) } if len(patches) == 0 { @@ -90,34 +90,34 @@ func (c *lineHierarchyPatchCollection) AddShrink(uniqueID string, existing persi patches := make([]invoiceupdater.Patch, 0, len(existingHierarchy.Lines)+1) for _, child := range existingHierarchy.Lines { - if child.Line.GetServicePeriod().To.Before(expectedLine.ServicePeriod.To) { + if child.ServicePeriod.To.Before(expectedLine.ServicePeriod.To) { continue } - if child.Line.GetServicePeriod().From.After(expectedLine.ServicePeriod.To) { - patches = append(patches, invoiceupdater.NewDeleteLinePatch(child.Line.GetLineID(), child.Line.GetInvoiceID())) + if child.ServicePeriod.From.After(expectedLine.ServicePeriod.To) { + patches = append(patches, invoiceupdater.NewDeleteLinePatch(child.LineID, child.Invoice.ID)) continue } - if !child.Line.GetServicePeriod().To.Equal(expectedLine.ServicePeriod.To) { - updatedPeriod := child.Line.GetServicePeriod() + if !child.ServicePeriod.To.Equal(expectedLine.ServicePeriod.To) { + updatedPeriod := child.ServicePeriod updatedPeriod.To = expectedLine.ServicePeriod.To updateInput := invoiceupdater.NewUpdateLinePatchInput{ - Line: child.Line.GetLineID(), - InvoiceID: child.Line.GetInvoiceID(), + Line: child.LineID, + InvoiceID: child.Invoice.ID, ServicePeriod: mo.Some(updatedPeriod), } - if child.Invoice.AsInvoice().Type() == billing.InvoiceTypeGathering { + if child.Invoice.Type == billing.InvoiceTypeGathering { updateInput.InvoiceAt = mo.Some(expectedLine.InvoiceAt) } - if child.Line.GetManagedBy() == billing.SubscriptionManagedLine && child.Line.GetDeletedAt() != nil { + if child.ManagedBy == billing.SubscriptionManagedLine && child.DeletedAt != nil { updateInput.DeletedAt = mo.Some[*time.Time](nil) } if updatedPeriod.Truncate(streaming.MinimumWindowSizeDuration).IsEmpty() { - patches = append(patches, invoiceupdater.NewDeleteLinePatch(child.Line.GetLineID(), child.Line.GetInvoiceID())) + patches = append(patches, invoiceupdater.NewDeleteLinePatch(child.LineID, child.Invoice.ID)) continue } @@ -164,24 +164,24 @@ func (c *lineHierarchyPatchCollection) AddExtend(existing persistedstate.Item, t if len(existingHierarchy.Lines) > 0 { lines := slices.Clone(existingHierarchy.Lines) - slices.SortFunc(lines, func(i, j billing.LineWithInvoiceHeader) int { - return timeutil.Compare(i.Line.GetServicePeriod().To, j.Line.GetServicePeriod().To) + slices.SortFunc(lines, func(i, j billing.SplitLineHierarchyLine) int { + return timeutil.Compare(i.ServicePeriod.To, j.ServicePeriod.To) }) lastChild := lines[len(lines)-1] - updatedPeriod := lastChild.Line.GetServicePeriod() + updatedPeriod := lastChild.ServicePeriod updatedPeriod.To = expectedLine.ServicePeriod.To updateInput := invoiceupdater.NewUpdateLinePatchInput{ - Line: lastChild.Line.GetLineID(), - InvoiceID: lastChild.Line.GetInvoiceID(), + Line: lastChild.LineID, + InvoiceID: lastChild.Invoice.ID, ServicePeriod: mo.Some(updatedPeriod), } - if lastChild.Line.GetManagedBy() == billing.SubscriptionManagedLine && lastChild.Line.GetDeletedAt() != nil { + if lastChild.ManagedBy == billing.SubscriptionManagedLine && lastChild.DeletedAt != nil { updateInput.DeletedAt = mo.Some[*time.Time](nil) } - if lastChild.Invoice.AsInvoice().Type() == billing.InvoiceTypeGathering { + if lastChild.Invoice.Type == billing.InvoiceTypeGathering { updateInput.InvoiceAt = mo.Some(expectedLine.InvoiceAt) } diff --git a/openmeter/billing/worker/subscriptionsync/service/reconciler/patchinvoicelinehierarchy_test.go b/openmeter/billing/worker/subscriptionsync/service/reconciler/patchinvoicelinehierarchy_test.go new file mode 100644 index 0000000000..2257d88b1f --- /dev/null +++ b/openmeter/billing/worker/subscriptionsync/service/reconciler/patchinvoicelinehierarchy_test.go @@ -0,0 +1,193 @@ +package reconciler + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/service/persistedstate" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type hierarchyPatchTestItem struct { + persistedstate.Item + hierarchy *billing.SplitLineHierarchy +} + +func (i hierarchyPatchTestItem) GetSplitLineHierarchy() *billing.SplitLineHierarchy { + return i.hierarchy +} + +func newHierarchyPatchTestHistory() *billing.SplitLineHierarchy { + from := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + boundary := from.AddDate(0, 0, 14) + to := from.AddDate(0, 1, 0) + + return &billing.SplitLineHierarchy{ + Group: billing.SplitLineGroup{ + NamespacedID: models.NamespacedID{Namespace: "namespace", ID: "group"}, + SplitLineGroupMutableFields: billing.SplitLineGroupMutableFields{ + Name: "group", + ServicePeriod: timeutil.ClosedPeriod{From: from, To: to}, + }, + }, + Lines: []billing.SplitLineHierarchyLine{ + { + LineID: billing.LineID{Namespace: "namespace", ID: "issued"}, + Invoice: billing.SplitLineHierarchyInvoice{ + InvoiceID: billing.InvoiceID{Namespace: "namespace", ID: "issued-invoice"}, + Type: billing.InvoiceTypeStandard, + Status: billing.StandardInvoiceStatusPaid, + }, + ManagedBy: billing.SubscriptionManagedLine, + ServicePeriod: timeutil.ClosedPeriod{From: from, To: boundary}, + }, + { + LineID: billing.LineID{Namespace: "namespace", ID: "gathering"}, + Invoice: billing.SplitLineHierarchyInvoice{ + InvoiceID: billing.InvoiceID{Namespace: "namespace", ID: "gathering-invoice"}, + Type: billing.InvoiceTypeGathering, + Status: billing.StandardInvoiceStatusGathering, + }, + ManagedBy: billing.SubscriptionManagedLine, + ServicePeriod: timeutil.ClosedPeriod{From: boundary, To: to}, + InvoiceAt: to, + }, + }, + } +} + +func TestHierarchyShrinkRestoresSubscriptionDeletedChild(t *testing.T) { + // Given an issued October 1-15 line and a deleted, subscription-managed gathering line through November 1. + hierarchy := newHierarchyPatchTestHistory() + deletedAt := hierarchy.Group.ServicePeriod.From + hierarchy.Lines[1].DeletedAt = &deletedAt + before, err := json.Marshal(hierarchy) + require.NoError(t, err) + target := newChargePatchTestTarget(t, productcatalog.CreditThenInvoiceSettlementMode, newChargePatchTestUsageRateCard()) + target.ServicePeriod = hierarchy.Group.ServicePeriod + target.ServicePeriod.To = time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + target.FullServicePeriod = target.ServicePeriod + target.BillingPeriod = target.ServicePeriod + + // When cancellation moves the service-period end to October 20. + collection := newLineHierarchyPatchCollection(0) + require.NoError(t, collection.AddShrink(target.UniqueID, hierarchyPatchTestItem{hierarchy: hierarchy}, target)) + require.Len(t, collection.Patches(), 1) + patches, err := collection.Patches()[0].GetInvoicePatches() + require.NoError(t, err) + + // Then the patch shortens and restores the gathering line, leaving the issued line alone. + require.Len(t, patches, 2) + update, err := patches[0].AsUpdateLinePatch() + require.NoError(t, err) + require.Equal(t, hierarchy.Lines[1].LineID, update.Line) + require.Equal(t, hierarchy.Lines[1].Invoice.ID, update.InvoiceID) + require.Equal(t, timeutil.ClosedPeriod{From: hierarchy.Lines[1].ServicePeriod.From, To: target.ServicePeriod.To}, update.ServicePeriod.MustGet()) + require.Equal(t, target.ServicePeriod.To, update.InvoiceAt.MustGet()) + require.True(t, update.FlatFeePerUnitAmount.IsAbsent()) + require.True(t, update.DeletedAt.IsPresent()) + require.Nil(t, update.DeletedAt.MustGet()) + groupUpdate, err := patches[1].AsUpdateSplitLineGroupPatch() + require.NoError(t, err) + require.Equal(t, target.ServicePeriod, groupUpdate.TargetState.ServicePeriod) + after, err := json.Marshal(hierarchy) + require.NoError(t, err) + require.JSONEq(t, string(before), string(after)) +} + +func TestHierarchyExtensionSelectsLastChildWhenPeriodsEndTogether(t *testing.T) { + for _, tc := range []struct { + name string + setup func(*billing.SplitLineHierarchy) + expectedLineID string + restoresLine bool + }{ + { + name: "deleted child last", + setup: func(*billing.SplitLineHierarchy) {}, + expectedLineID: "historical", + restoresLine: true, + }, + { + name: "live child last", + setup: func(hierarchy *billing.SplitLineHierarchy) { + hierarchy.Lines[1], hierarchy.Lines[2] = hierarchy.Lines[2], hierarchy.Lines[1] + }, + expectedLineID: "gathering", + }, + } { + t.Run(tc.name, func(t *testing.T) { + // Given live and deleted gathering lines that both end on November 1. + hierarchy := newHierarchyPatchTestHistory() + historical, err := hierarchy.Lines[1].Clone() + require.NoError(t, err) + historical.ID = "historical" + historical.DeletedAt = &hierarchy.Group.ServicePeriod.From + hierarchy.Lines = append(hierarchy.Lines, historical) + tc.setup(hierarchy) + + target := newChargePatchTestTarget(t, productcatalog.CreditThenInvoiceSettlementMode, newChargePatchTestUsageRateCard()) + target.ServicePeriod = hierarchy.Group.ServicePeriod + target.ServicePeriod.To = target.ServicePeriod.To.AddDate(0, 0, 10) + target.FullServicePeriod = target.ServicePeriod + target.BillingPeriod = target.ServicePeriod + before, err := json.Marshal(hierarchy) + require.NoError(t, err) + + // When service continues through November 11. + collection := newLineHierarchyPatchCollection(0) + require.NoError(t, collection.AddExtend(hierarchyPatchTestItem{hierarchy: hierarchy}, target)) + patches, err := collection.Patches()[0].GetInvoicePatches() + require.NoError(t, err) + + // Then the last line in stored order is extended and restored if it was deleted. + require.Len(t, patches, 2) + update, err := patches[0].AsUpdateLinePatch() + require.NoError(t, err) + require.Equal(t, billing.LineID{Namespace: "namespace", ID: tc.expectedLineID}, update.Line) + require.Equal(t, target.ServicePeriod.To, update.ServicePeriod.MustGet().To) + require.Equal(t, target.ServicePeriod.To, update.InvoiceAt.MustGet()) + require.Equal(t, tc.restoresLine, update.DeletedAt.IsPresent()) + if update.DeletedAt.IsPresent() { + require.Nil(t, update.DeletedAt.MustGet()) + } + + after, err := json.Marshal(hierarchy) + require.NoError(t, err) + require.JSONEq(t, string(before), string(after)) + }) + } +} + +func TestHierarchyIgnoreOnDeletedChildSuppressesPatches(t *testing.T) { + // Given a deleted gathering line marked to be ignored by subscription sync. + hierarchy := newHierarchyPatchTestHistory() + historical, err := hierarchy.Lines[1].Clone() + require.NoError(t, err) + historical.ID = "historical" + historical.DeletedAt = &hierarchy.Group.ServicePeriod.From + historical.Annotations = models.Annotations{billing.AnnotationSubscriptionSyncIgnore: true} + hierarchy.Lines = append(hierarchy.Lines, historical) + item := hierarchyPatchTestItem{hierarchy: hierarchy} + + // When sync attempts to delete, shorten, or extend the group, the annotation blocks all three operations. + ignored := newLineHierarchyPatchCollection(0) + require.NoError(t, ignored.AddDelete("unique", item)) + target := newChargePatchTestTarget(t, productcatalog.CreditThenInvoiceSettlementMode, newChargePatchTestUsageRateCard()) + target.ServicePeriod = hierarchy.Group.ServicePeriod + target.ServicePeriod.To = time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC) + target.FullServicePeriod = target.ServicePeriod + target.BillingPeriod = target.ServicePeriod + require.NoError(t, ignored.AddShrink("unique", item, target)) + target.ServicePeriod.To = time.Date(2026, 11, 11, 0, 0, 0, 0, time.UTC) + target.FullServicePeriod = target.ServicePeriod + target.BillingPeriod = target.ServicePeriod + require.NoError(t, ignored.AddExtend(item, target)) + require.True(t, ignored.IsEmpty()) +} diff --git a/test/billing/discount_test.go b/test/billing/discount_test.go index 0668e4ee7e..7885b52517 100644 --- a/test/billing/discount_test.go +++ b/test/billing/discount_test.go @@ -152,10 +152,20 @@ func (s *DiscountsTestSuite) TestCorrelationIDHandling() { // Split lines have the same correlation ID for the discount s.Len(invoiceLine.SplitLineHierarchy.Lines, 2) - genericLinesWithInvoices := invoiceLine.SplitLineHierarchy.Lines - - for _, genericLine := range genericLinesWithInvoices { - s.Equal(discountCorrelationID, genericLine.Line.GetRateCardDiscounts().Percentage.CorrelationID) + for _, child := range invoiceLine.SplitLineHierarchy.Lines { + childInvoice, err := s.BillingService.GetInvoiceById(ctx, billing.GetInvoiceByIdInput{ + Invoice: child.Invoice.InvoiceID, + Expand: billing.InvoiceExpandAll, + }) + s.Require().NoError(err) + genericInvoice, err := childInvoice.AsGenericInvoice() + s.Require().NoError(err) + genericLine := genericInvoice.GetGenericLines().MustGet() + matchedLine, found := lo.Find(genericLine, func(line billing.GenericInvoiceLine) bool { + return line.GetID() == child.ID + }) + s.Require().True(found) + s.Equal(discountCorrelationID, matchedLine.GetRateCardDiscounts().Percentage.CorrelationID) } // The split line group has the same correlation ID for the discount diff --git a/test/billing/invoice_test.go b/test/billing/invoice_test.go index 4b7de7eb1e..763a111c34 100644 --- a/test/billing/invoice_test.go +++ b/test/billing/invoice_test.go @@ -2262,11 +2262,11 @@ func (s *InvoicingTestSuite) TestUBPProgressiveInvoicing() { require.Equal(s.T(), flatPerUnit.UsageBased.Quantity.InexactFloat64(), float64(10), "flat per unit should have 10 units") require.Len(s.T(), tieredGraduatedHierarchy.Lines, 2, "there should be to child lines [id=%s]", tieredGraduatedHierarchy.Group.ID) - require.True(s.T(), tieredGraduatedHierarchy.Lines[0].Line.GetServicePeriod().Equal(timeutil.ClosedPeriod{ + require.True(s.T(), tieredGraduatedHierarchy.Lines[0].ServicePeriod.Equal(timeutil.ClosedPeriod{ From: truncatedPeriodStart, To: truncatedPeriodStart.Add(time.Hour), }), "first child period should be truncated") - require.True(s.T(), tieredGraduatedHierarchy.Lines[1].Line.GetServicePeriod().Equal(timeutil.ClosedPeriod{ + require.True(s.T(), tieredGraduatedHierarchy.Lines[1].ServicePeriod.Equal(timeutil.ClosedPeriod{ From: truncatedPeriodStart.Add(time.Hour), To: truncatedPeriodEnd, }), "second child period should be until the end of parent's period") @@ -2550,15 +2550,15 @@ func (s *InvoicingTestSuite) TestUBPProgressiveInvoicing() { require.True(s.T(), tieredGraduatedHierarchy.Group.ServicePeriod.Equal(lines.tieredGraduated.ServicePeriod)) require.Len(s.T(), tieredGraduatedHierarchy.Lines, 3, "there should be to child lines [id=%s]", tieredGraduatedHierarchy.Group.ID) - require.True(s.T(), tieredGraduatedHierarchy.Lines[0].Line.GetServicePeriod().Equal(timeutil.ClosedPeriod{ + require.True(s.T(), tieredGraduatedHierarchy.Lines[0].ServicePeriod.Equal(timeutil.ClosedPeriod{ From: truncatedPeriodStart, To: truncatedPeriodStart.Add(time.Hour), }), "first child period should be truncated") - require.True(s.T(), tieredGraduatedHierarchy.Lines[1].Line.GetServicePeriod().Equal(timeutil.ClosedPeriod{ + require.True(s.T(), tieredGraduatedHierarchy.Lines[1].ServicePeriod.Equal(timeutil.ClosedPeriod{ From: truncatedPeriodStart.Add(time.Hour), To: truncatedPeriodStart.Add(2 * time.Hour), }), "second child period should be between the first and the third child's period") - require.True(s.T(), tieredGraduatedHierarchy.Lines[2].Line.GetServicePeriod().Equal(timeutil.ClosedPeriod{ + require.True(s.T(), tieredGraduatedHierarchy.Lines[2].ServicePeriod.Equal(timeutil.ClosedPeriod{ From: truncatedPeriodStart.Add(2 * time.Hour), To: truncatedPeriodEnd, }), "third child period should be until the end of parent's period") @@ -2757,15 +2757,15 @@ func (s *InvoicingTestSuite) TestUBPProgressiveInvoicing() { require.True(s.T(), tieredGraduatedHierarchy.Group.ServicePeriod.Equal(lines.tieredGraduated.ServicePeriod)) require.Len(s.T(), tieredGraduatedHierarchy.Lines, 3, "there should be to child lines [id=%s]", tieredGraduatedHierarchy.Group.ID) - require.True(s.T(), tieredGraduatedHierarchy.Lines[0].Line.GetServicePeriod().Equal(timeutil.ClosedPeriod{ + require.True(s.T(), tieredGraduatedHierarchy.Lines[0].ServicePeriod.Equal(timeutil.ClosedPeriod{ From: truncatedPeriodStart, To: truncatedPeriodStart.Add(time.Hour), }), "first child period should be truncated") - require.True(s.T(), tieredGraduatedHierarchy.Lines[1].Line.GetServicePeriod().Equal(timeutil.ClosedPeriod{ + require.True(s.T(), tieredGraduatedHierarchy.Lines[1].ServicePeriod.Equal(timeutil.ClosedPeriod{ From: truncatedPeriodStart.Add(time.Hour), To: truncatedPeriodStart.Add(2 * time.Hour), }), "second child period should be between the first and the third child's period") - require.True(s.T(), tieredGraduatedHierarchy.Lines[2].Line.GetServicePeriod().Equal(timeutil.ClosedPeriod{ + require.True(s.T(), tieredGraduatedHierarchy.Lines[2].ServicePeriod.Equal(timeutil.ClosedPeriod{ From: truncatedPeriodStart.Add(2 * time.Hour), To: truncatedPeriodEnd, }), "third child period should be until the end of parent's period") @@ -3540,7 +3540,7 @@ func (s *InvoicingTestSuite) lineInSameSplitLineGroup(lines []*billing.StandardL } for _, child := range line.SplitLineHierarchy.Lines { - if child.Line.GetID() == shiblingLineID { + if child.ID == shiblingLineID { return line } } @@ -3567,11 +3567,11 @@ func (s *InvoicingTestSuite) lineByID(lines []*billing.StandardLine, id string) func (s *InvoicingTestSuite) sortedSplitLineGroupChildren(line *billing.StandardLine) { s.NotNil(line.SplitLineHierarchy) - slices.SortFunc(line.SplitLineHierarchy.Lines, func(a, b billing.LineWithInvoiceHeader) int { + slices.SortFunc(line.SplitLineHierarchy.Lines, func(a, b billing.SplitLineHierarchyLine) int { switch { - case a.Line.GetServicePeriod().From.Equal(b.Line.GetServicePeriod().From): + case a.ServicePeriod.From.Equal(b.ServicePeriod.From): return 0 - case a.Line.GetServicePeriod().From.Before(b.Line.GetServicePeriod().From): + case a.ServicePeriod.From.Before(b.ServicePeriod.From): return -1 default: return 1