diff --git a/app/common/billing.go b/app/common/billing.go index 27d457a38f..9de1c300f1 100644 --- a/app/common/billing.go +++ b/app/common/billing.go @@ -16,6 +16,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased" chargesworkeradvance "github.com/openmeterio/openmeter/openmeter/billing/charges/worker/advance" + billinglineengine "github.com/openmeterio/openmeter/openmeter/billing/lineengine" "github.com/openmeterio/openmeter/openmeter/billing/rating" billingratingservice "github.com/openmeterio/openmeter/openmeter/billing/rating/service" billingsequence "github.com/openmeterio/openmeter/openmeter/billing/sequence" @@ -48,9 +49,10 @@ import ( // BillingRegistry bundles the billing and charges services. External callers that need // billing or charges should depend on BillingRegistry rather than individual services. type BillingRegistry struct { - Billing billing.Service - Sequence billingsequence.Service - Charges *ChargesRegistry + Billing billing.Service + LegacyBillingLineEngine *billinglineengine.Engine + Sequence billingsequence.Service + Charges *ChargesRegistry } func (r BillingRegistry) ChargesServiceOrNil() charges.Service { @@ -109,6 +111,22 @@ func NewBillingSequenceService( }) } +func NewLegacyBillingLineEngine( + billingAdapter billing.Adapter, + billingRatingService rating.Service, + featureConnector feature.FeatureConnector, + streamingConnector streaming.Connector, + billingConfig config.BillingConfiguration, +) (*billinglineengine.Engine, error) { + return billinglineengine.New(billinglineengine.Config{ + SplitLineGroupAdapter: billingAdapter, + RatingService: billingRatingService, + FeatureService: featureConnector, + StreamingConnector: streamingConnector, + MaxParallelQuantitySnapshots: billingConfig.MaxParallelQuantitySnapshots, + }) +} + // newBillingService creates the billing service and registers validators/hooks. // Downstream consumers should use BillingRegistry. func newBillingService( @@ -116,11 +134,11 @@ func newBillingService( appService app.Service, billingAdapter billing.Adapter, billingRatingService rating.Service, + legacyBillingLineEngine *billinglineengine.Engine, sequenceService billingsequence.Service, customerService customer.Service, featureConnector feature.FeatureConnector, meterService meter.Service, - streamingConnector streaming.Connector, eventPublisher eventbus.Publisher, billingConfig config.BillingConfiguration, subscriptionServices SubscriptionServiceWithWorkflow, @@ -130,20 +148,19 @@ func newBillingService( taxCodeService taxcode.Service, ) (billing.Service, error) { service, err := billingservice.New(billingservice.Config{ - Adapter: billingAdapter, - SequenceService: sequenceService, - RatingService: billingRatingService, - AppService: appService, - CustomerService: customerService, - TaxCodeService: taxCodeService, - FeatureService: featureConnector, - Logger: logger, - MeterService: meterService, - StreamingConnector: streamingConnector, - Publisher: eventPublisher, - AdvancementStrategy: billingConfig.AdvancementStrategy, - FSNamespaceLockdown: fsConfig.NamespaceLockdown, - MaxParallelQuantitySnapshots: billingConfig.MaxParallelQuantitySnapshots, + Adapter: billingAdapter, + SequenceService: sequenceService, + RatingService: billingRatingService, + LegacyBillingLineEngine: legacyBillingLineEngine, + AppService: appService, + CustomerService: customerService, + TaxCodeService: taxCodeService, + FeatureService: featureConnector, + Logger: logger, + MeterService: meterService, + Publisher: eventPublisher, + AdvancementStrategy: billingConfig.AdvancementStrategy, + FSNamespaceLockdown: fsConfig.NamespaceLockdown, }) if err != nil { return nil, err @@ -184,16 +201,27 @@ func NewBillingRegistry( return BillingRegistry{}, err } + legacyBillingLineEngine, err := NewLegacyBillingLineEngine( + billingAdapter, + billingRatingService, + featureConnector, + streamingConnector, + billingConfig, + ) + if err != nil { + return BillingRegistry{}, err + } + billingService, err := newBillingService( logger, appService, billingAdapter, billingRatingService, + legacyBillingLineEngine, sequenceService, customerService, featureConnector, meterService, - streamingConnector, eventPublisher, billingConfig, subscriptionServices, @@ -233,9 +261,10 @@ func NewBillingRegistry( } billingRegistry := BillingRegistry{ - Billing: billingService, - Sequence: sequenceService, - Charges: chargesRegistry, + Billing: billingService, + LegacyBillingLineEngine: legacyBillingLineEngine, + Sequence: sequenceService, + Charges: chargesRegistry, } // Hook registration @@ -330,6 +359,7 @@ func NewBillingSubscriptionSyncService(logger *slog.Logger, subsServices Subscri return subscriptionsyncservice.New(subscriptionsyncservice.Config{ SubscriptionService: subsServices.Service, BillingService: billingRegistry.Billing, + LegacyBillingLineEngine: billingRegistry.LegacyBillingLineEngine, ChargesService: billingRegistry.ChargesServiceOrNil(), SubscriptionSyncAdapter: subscriptionSyncAdapter, FeatureFlags: subscriptionsyncservice.FeatureFlags{ diff --git a/openmeter/billing/adapter.go b/openmeter/billing/adapter.go index 6b3c63a456..fb5d9f0723 100644 --- a/openmeter/billing/adapter.go +++ b/openmeter/billing/adapter.go @@ -13,7 +13,6 @@ type Adapter interface { ProfileAdapter CustomerOverrideAdapter InvoiceLineAdapter - InvoiceSplitLineGroupAdapter InvoiceAdapter GatheringInvoiceAdapter StandardInvoiceAdapter @@ -59,7 +58,6 @@ type CustomerSynchronizationAdapter interface { type InvoiceLineAdapter interface { UpsertInvoiceLines(ctx context.Context, input UpsertInvoiceLinesAdapterInput) ([]*StandardLine, error) ListInvoiceLines(ctx context.Context, input ListInvoiceLinesAdapterInput) ([]*StandardLine, error) - GetLinesForSubscription(ctx context.Context, input GetLinesForSubscriptionInput) ([]LineOrHierarchy, error) } type InvoiceAdapter interface { @@ -74,6 +72,7 @@ type InvoiceAdapter interface { } type StandardInvoiceAdapter interface { + GetStandardLinesForSubscription(ctx context.Context, input GetLinesForSubscriptionInput) (StandardLines, error) GetStandardInvoiceById(ctx context.Context, input GetStandardInvoiceByIdInput) (StandardInvoice, error) UpdateStandardInvoice(ctx context.Context, input UpdateStandardInvoiceAdapterInput) (StandardInvoice, error) ListStandardInvoicesPendingAdvancement(ctx context.Context, input ListStandardInvoicesPendingAdvancementInput) ([]StandardInvoice, error) @@ -81,6 +80,7 @@ type StandardInvoiceAdapter interface { } type GatheringInvoiceAdapter interface { + GetGatheringLinesForSubscription(ctx context.Context, input GetLinesForSubscriptionInput) (GatheringLines, error) CreateGatheringInvoice(ctx context.Context, input CreateGatheringInvoiceAdapterInput) (GatheringInvoice, error) UpdateGatheringInvoice(ctx context.Context, input UpdateGatheringInvoiceAdapterInput) error DeleteGatheringInvoice(ctx context.Context, input DeleteGatheringInvoiceAdapterInput) error @@ -90,14 +90,6 @@ type GatheringInvoiceAdapter interface { HardDeleteGatheringInvoiceLines(ctx context.Context, invoiceID InvoiceID, lineIDs []string) error } -type InvoiceSplitLineGroupAdapter interface { - CreateSplitLineGroup(ctx context.Context, input CreateSplitLineGroupAdapterInput) (SplitLineGroup, error) - UpdateSplitLineGroup(ctx context.Context, input UpdateSplitLineGroupInput) (SplitLineGroup, error) - DeleteSplitLineGroup(ctx context.Context, input DeleteSplitLineGroupInput) error - GetSplitLineGroup(ctx context.Context, input GetSplitLineGroupInput) (SplitLineHierarchy, error) - GetSplitLineGroupHeaders(ctx context.Context, input GetSplitLineGroupHeadersInput) (SplitLineGroupHeaders, error) -} - type InvoiceAppAdapter interface { UpdateInvoiceFields(ctx context.Context, input UpdateInvoiceFieldsInput) error } diff --git a/openmeter/billing/adapter/gatheringlines.go b/openmeter/billing/adapter/gatheringlines.go index f7bb4a6f55..737fa0d4fc 100644 --- a/openmeter/billing/adapter/gatheringlines.go +++ b/openmeter/billing/adapter/gatheringlines.go @@ -2,6 +2,7 @@ package billingadapter import ( "context" + "errors" "fmt" "time" @@ -11,6 +12,7 @@ import ( "github.com/samber/lo" "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/openmeter/ent/db" "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoice" "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoiceline" @@ -25,6 +27,86 @@ import ( "github.com/openmeterio/openmeter/pkg/timeutil" ) +func (a *adapter) GetGatheringLinesForSubscription(ctx context.Context, in billing.GetLinesForSubscriptionInput) (billing.GatheringLines, error) { + if err := in.Validate(); err != nil { + return nil, billing.ValidationError{ + Err: err, + } + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.GatheringLines, error) { + query := tx.db.BillingInvoiceLine.Query(). + Where(billinginvoiceline.Namespace(in.Namespace)). + Where(billinginvoiceline.SubscriptionID(in.SubscriptionID)). + Where(billinginvoiceline.ParentLineIDIsNil()). // Split-line children are loaded through their hierarchy instead of as independent subscription items. + Where(billinginvoiceline.HasBillingInvoiceWith( + billinginvoice.StatusEQ(billing.StandardInvoiceStatusGathering), + )). + Where( + billinginvoiceline.Or( + billinginvoiceline.DeletedAtIsNil(), + billinginvoiceline.And( + billinginvoiceline.DeletedAtNotNil(), + billinginvoiceline.ManagedByEQ(billing.ManuallyManagedLine), + ), + ), + ). + WithBillingInvoice() + + if !in.IncludeChargeManaged { + query = query.Where(billinginvoiceline.ChargeIDIsNil()) + } + + query = tx.expandLineItems(query) + + dbLines, err := query.All(ctx) + if err != nil { + return nil, fmt.Errorf("fetching gathering lines: %w", err) + } + + if err := errors.Join( + lo.Map(dbLines, func(line *db.BillingInvoiceLine, _ int) error { + if line.Edges.BillingInvoice == nil { + return fmt.Errorf("billing invoice not found for line [id=%s]", line.ID) + } + + return nil + })..., + ); err != nil { + return nil, err + } + + invoiceSchemaLevelByID, err := tx.getSchemaLevelPerInvoice(ctx, customer.CustomerID{ + Namespace: in.Namespace, + ID: in.CustomerID, + }) + if err != nil { + return nil, fmt.Errorf("getting schema level per invoice: %w", err) + } + + dbLinesByInvoiceID := lo.GroupBy(dbLines, func(line *db.BillingInvoiceLine) string { + return line.Edges.BillingInvoice.ID + }) + + gatheringLines := make(billing.GatheringLines, 0, len(dbLines)) + for invoiceID, dbLinesForInvoice := range dbLinesByInvoiceID { + schemaLevel, found := invoiceSchemaLevelByID[invoiceID] + if !found { + return nil, fmt.Errorf("schema level not found for invoice [id=%s]", invoiceID) + } + + mappedLines, err := tx.mapGatheringInvoiceLinesFromDB(schemaLevel, dbLinesForInvoice) + if err != nil { + return nil, fmt.Errorf("mapping gathering lines: %w", err) + } + + gatheringLines = append(gatheringLines, mappedLines...) + } + + return gatheringLines, nil + }) +} + func (a *adapter) HardDeleteGatheringInvoiceLines(ctx context.Context, invoiceID billing.InvoiceID, lineIDs []string) error { if err := invoiceID.Validate(); err != nil { return fmt.Errorf("validating invoice ID: %w", err) diff --git a/openmeter/billing/adapter/stdinvoicelines.go b/openmeter/billing/adapter/stdinvoicelines.go index f75072dfc1..a1b2f6fb56 100644 --- a/openmeter/billing/adapter/stdinvoicelines.go +++ b/openmeter/billing/adapter/stdinvoicelines.go @@ -21,7 +21,6 @@ import ( "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoiceline" "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoicelinediscount" "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoicelineusagediscount" - "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoicesplitlinegroup" "github.com/openmeterio/openmeter/openmeter/ent/db/billinginvoiceusagebasedlineconfig" "github.com/openmeterio/openmeter/openmeter/ent/db/billingstandardinvoicedetailedline" "github.com/openmeterio/openmeter/openmeter/ent/db/billingstandardinvoicedetailedlineamountdiscount" @@ -776,18 +775,21 @@ func (a *adapter) refetchInvoiceLines(ctx context.Context, in refetchInvoiceLine return lines, nil } -func (a *adapter) GetLinesForSubscription(ctx context.Context, in billing.GetLinesForSubscriptionInput) ([]billing.LineOrHierarchy, error) { +func (a *adapter) GetStandardLinesForSubscription(ctx context.Context, in billing.GetLinesForSubscriptionInput) (billing.StandardLines, error) { if err := in.Validate(); err != nil { return nil, billing.ValidationError{ Err: err, } } - return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]billing.LineOrHierarchy, error) { + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (billing.StandardLines, error) { query := tx.db.BillingInvoiceLine.Query(). Where(billinginvoiceline.Namespace(in.Namespace)). Where(billinginvoiceline.SubscriptionID(in.SubscriptionID)). - Where(billinginvoiceline.ParentLineIDIsNil()). // This one is required so that we are not fetching split line's children directly, the mapper will handle that + Where(billinginvoiceline.ParentLineIDIsNil()). // Split-line children are loaded through their hierarchy instead of as independent subscription items. + Where(billinginvoiceline.HasBillingInvoiceWith( + billinginvoice.StatusNEQ(billing.StandardInvoiceStatusGathering), + )). Where( billinginvoiceline.Or( billinginvoiceline.DeletedAtIsNil(), @@ -831,118 +833,11 @@ func (a *adapter) GetLinesForSubscription(ctx context.Context, in billing.GetLin return nil, fmt.Errorf("getting schema level per invoice: %w", err) } - // map standard lines - dbStandardLines := lo.Filter(dbLines, func(line *db.BillingInvoiceLine, _ int) bool { - return line.Edges.BillingInvoice.Status != billing.StandardInvoiceStatusGathering - }) - - standardLines, err := tx.mapStandardInvoiceLinesFromDB(invoiceSchemaLevelByID, dbStandardLines) + standardLines, err := tx.mapStandardInvoiceLinesFromDB(invoiceSchemaLevelByID, dbLines) if err != nil { return nil, fmt.Errorf("mapping standard lines: %w", err) } - // map gathering lines - dbGatheringLines := lo.Filter(dbLines, func(line *db.BillingInvoiceLine, _ int) bool { - return line.Edges.BillingInvoice.Status == billing.StandardInvoiceStatusGathering - }) - - dbGatheringLinesByInvoiceID := lo.GroupBy(dbGatheringLines, func(line *db.BillingInvoiceLine) string { - return line.Edges.BillingInvoice.ID - }) - - gatheringLines := make([]billing.GatheringLine, 0, len(dbGatheringLines)) - for invoiceID, dbGatheringLinesForInvoice := 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, dbGatheringLinesForInvoice) - if err != nil { - return nil, fmt.Errorf("mapping gathering lines: %w", err) - } - - gatheringLines = append(gatheringLines, mappedLines...) - } - - dbGroups, err := tx.db.BillingInvoiceSplitLineGroup.Query(). - Where(billinginvoicesplitlinegroup.Namespace(in.Namespace)). - Where(billinginvoicesplitlinegroup.SubscriptionID(in.SubscriptionID)). - WithBillingInvoiceLines(func(q *db.BillingInvoiceLineQuery) { - tx.expandLineItems(q) - q.WithBillingInvoice(func(q *db.BillingInvoiceQuery) { - q.WithBillingWorkflowConfig(workflowConfigWithTaxCode) - }) - }). - Where(billinginvoicesplitlinegroup.DeletedAtIsNil()). - All(ctx) - if err != nil { - return nil, fmt.Errorf("fetching split line groups: %w", err) - } - - groups, err := slicesx.MapWithErr(dbGroups, func(dbGroup *db.BillingInvoiceSplitLineGroup) (billing.SplitLineHierarchy, error) { - group, err := tx.mapSplitLineGroupFromDB(dbGroup) - if err != nil { - return billing.SplitLineHierarchy{}, err - } - - lines, err := tx.mapSplitLineHierarchyLinesFromDB(ctx, dbGroup.Edges.BillingInvoiceLines) - if err != nil { - return billing.SplitLineHierarchy{}, err - } - - return billing.SplitLineHierarchy{ - Group: group, - Lines: lines, - }, nil - }) - if err != nil { - return nil, fmt.Errorf("mapping groups: %w", err) - } - - // Sanity check: let's make sure that there are no items with overlapping childUniqueReferenceID - groupUniqueReferenceIDs := lo.Map( - lo.Filter( - groups, - func(group billing.SplitLineHierarchy, _ int) bool { - return group.Group.UniqueReferenceID != nil - }, - ), - func(group billing.SplitLineHierarchy, _ int) string { - return lo.FromPtr(group.Group.UniqueReferenceID) - }, - ) - - lineChildUniqueReferenceIDs := lo.Union( - lo.FilterMap(standardLines, func(line *billing.StandardLine, _ int) (string, bool) { - return lo.FromPtr(line.ChildUniqueReferenceID), line.ChildUniqueReferenceID != nil - }), - lo.FilterMap(gatheringLines, func(line billing.GatheringLine, _ int) (string, bool) { - return lo.FromPtr(line.ChildUniqueReferenceID), line.ChildUniqueReferenceID != nil - }), - ) - - overlappingChildUniqueReferenceIDs := lo.Intersect(groupUniqueReferenceIDs, lineChildUniqueReferenceIDs) - - if len(overlappingChildUniqueReferenceIDs) > 0 { - return nil, fmt.Errorf("overlapping childUniqueReferenceID: %v", overlappingChildUniqueReferenceIDs) - } - - // Let's map to the union type - out := make([]billing.LineOrHierarchy, 0, len(groups)+len(standardLines)+len(gatheringLines)) - - out = append(out, lo.Map(groups, func(h billing.SplitLineHierarchy, _ int) billing.LineOrHierarchy { - return billing.NewLineOrHierarchy(&h) - })...) - - out = append(out, lo.Map(standardLines, func(line *billing.StandardLine, _ int) billing.LineOrHierarchy { - return billing.NewLineOrHierarchy(line) - })...) - - out = append(out, lo.Map(gatheringLines, func(line billing.GatheringLine, _ int) billing.LineOrHierarchy { - return billing.NewLineOrHierarchy(line) - })...) - - return out, nil + return standardLines, nil }) } diff --git a/openmeter/billing/charges/flatfee/service/realizations/credittheninvoice.go b/openmeter/billing/charges/flatfee/service/realizations/credittheninvoice.go index 0c98f6069a..299fec454c 100644 --- a/openmeter/billing/charges/flatfee/service/realizations/credittheninvoice.go +++ b/openmeter/billing/charges/flatfee/service/realizations/credittheninvoice.go @@ -355,7 +355,6 @@ func rateFlatFeeLine(line billing.StandardLine, ratingService billingrating.Serv // split-line metadata must not make the flat pricer skip an otherwise // billable in-advance or in-arrears charge run. ratingLine.SplitLineGroupID = nil - ratingLine.SplitLineHierarchy = nil generatedDetailedLines, err := ratingService.GenerateDetailedLines(ratingLine, billingrating.WithCreditsMutatorDisabled()) if err != nil { diff --git a/openmeter/billing/charges/usagebased/rating.go b/openmeter/billing/charges/usagebased/rating.go index 9b50cbd61a..69dcaa757f 100644 --- a/openmeter/billing/charges/usagebased/rating.go +++ b/openmeter/billing/charges/usagebased/rating.go @@ -66,19 +66,6 @@ func (r RateableIntent) GetStandardLineDiscounts() billing.StandardLineDiscounts return billing.StandardLineDiscounts{} } -func (r RateableIntent) IsProgressivelyBilled() bool { - // A charge is never progressively billed - return false -} - -func (r RateableIntent) GetProgressivelyBilledServicePeriod() (timeutil.ClosedPeriod, error) { - return r.ServicePeriod, nil -} - -func (r RateableIntent) GetPreviouslyBilledAmount() (alpacadecimal.Decimal, error) { - return alpacadecimal.Zero, nil -} - func (r RateableIntent) GetCreditsApplied() billing.CreditsApplied { return r.CreditsApplied } diff --git a/openmeter/billing/charges/usagebased/service/rating/delta/engine_test.go b/openmeter/billing/charges/usagebased/service/rating/delta/engine_test.go index c66fe47125..50331c4393 100644 --- a/openmeter/billing/charges/usagebased/service/rating/delta/engine_test.go +++ b/openmeter/billing/charges/usagebased/service/rating/delta/engine_test.go @@ -1,6 +1,7 @@ package delta import ( + "fmt" "strconv" "testing" "time" @@ -341,7 +342,11 @@ func (s *stubRatingService) ResolveBillablePeriod(in billingrating.ResolveBillab return nil, nil } +func (s *stubRatingService) GenerateProgressiveBilledDetailedLines(in billingrating.ProgressiveBilledLineAccessor, opts ...billingrating.GenerateDetailedLinesOption) (billingrating.GenerateDetailedLinesResult, error) { + return billingrating.GenerateDetailedLinesResult{}, fmt.Errorf("charges rating must always use the non-progressive billed rating engine") +} + func (s *stubRatingService) GenerateDetailedLines(in billingrating.StandardLineAccessor, opts ...billingrating.GenerateDetailedLinesOption) (billingrating.GenerateDetailedLinesResult, error) { s.lastOpts = billingrating.NewGenerateDetailedLinesOptions(opts...) - return s.result, nil + return billingrating.GenerateDetailedLinesResult{}, nil } diff --git a/openmeter/billing/gatheringinvoice.go b/openmeter/billing/gatheringinvoice.go index 33acdb00da..d0fc88247d 100644 --- a/openmeter/billing/gatheringinvoice.go +++ b/openmeter/billing/gatheringinvoice.go @@ -727,8 +727,7 @@ func (i gatheringInvoiceLineGenericWrapper) AsGenericInvoiceLine() GenericInvoic type GatheringLine struct { GatheringLineBase `json:",inline"` - DBState *GatheringLine `json:"-"` - SplitLineHierarchy *SplitLineHierarchy `json:"splitLineHierarchy,omitempty"` + DBState *GatheringLine `json:"-"` } func (g GatheringLine) Clone() (GatheringLine, error) { @@ -805,10 +804,6 @@ func (g GatheringLine) RemoveMetaForCompare() (GatheringLine, error) { return g.WithoutDBState() } -func (g *GatheringLine) SetSplitLineHierarchy(hierarchy *SplitLineHierarchy) { - g.SplitLineHierarchy = hierarchy -} - func (g GatheringLine) AsNewStandardLine(invoiceID string) (*StandardLine, error) { if invoiceID == "" { return nil, errors.New("invoice ID is required") @@ -829,16 +824,6 @@ func (g GatheringLine) AsNewStandardLine(invoiceID string) (*StandardLine, error subscription = g.Subscription.Clone() } - var splitLineHierarchy *SplitLineHierarchy - if g.SplitLineHierarchy != nil { - clonedSHierarchy, err := g.SplitLineHierarchy.Clone() - if err != nil { - return nil, fmt.Errorf("cloning split line hierarchy: %w", err) - } - - splitLineHierarchy = lo.ToPtr(clonedSHierarchy) - } - // Carry the gathering line's unit_config snapshot onto the standard line so the legacy // line-engine path's rating (StandardLine.GetUnitConfig) converts from raw metered units. // Deep-cloned so the standard line owns its own config, matching the charges path. @@ -873,8 +858,6 @@ func (g GatheringLine) AsNewStandardLine(invoiceID string) (*StandardLine, error UnitConfig: unitConfig, }, - SplitLineHierarchy: splitLineHierarchy, - DBState: nil, // We don't want to reuse the state from the gathering line (so let's make it explicit) } @@ -1096,3 +1079,22 @@ type PrepareBillableLinesInput = InvoicePendingLinesInput type PrepareBillableLinesResult struct { LinesByCurrency map[currencyx.Code]GatheringLines } + +type GatheringLineWithInvoiceHeader struct { + Line GatheringLine + Invoice GatheringInvoice +} + +func (l GatheringLineWithInvoiceHeader) Validate() error { + if err := l.Line.Validate(); err != nil { + return fmt.Errorf("line: %w", err) + } + + // This transport only carries the parent invoice entity as a header object, + // so we validate invoice identity here instead of requiring a fully fetched invoice aggregate. + if l.Invoice.ID == "" { + return fmt.Errorf("invoice id is required") + } + + return nil +} diff --git a/openmeter/billing/invoicelinesplitgroup.go b/openmeter/billing/invoicelinesplitgroup.go deleted file mode 100644 index 028eb452f0..0000000000 --- a/openmeter/billing/invoicelinesplitgroup.go +++ /dev/null @@ -1,535 +0,0 @@ -package billing - -import ( - "encoding/json" - "errors" - "fmt" - "time" - - "github.com/alpacahq/alpacadecimal" - "github.com/samber/lo" - - "github.com/openmeterio/openmeter/openmeter/productcatalog" - "github.com/openmeterio/openmeter/pkg/currencyx" - "github.com/openmeterio/openmeter/pkg/models" - "github.com/openmeterio/openmeter/pkg/slicesx" - timeutil "github.com/openmeterio/openmeter/pkg/timeutil" -) - -type SplitLineGroupMutableFields struct { - Name string `json:"name"` - Description *string `json:"description,omitempty"` - Metadata models.Metadata `json:"metadata,omitempty"` - - ServicePeriod timeutil.ClosedPeriod `json:"period"` - - RatecardDiscounts Discounts `json:"ratecardDiscounts"` -} - -func (i SplitLineGroupMutableFields) ValidateForPrice(price *productcatalog.Price) error { - var errs []error - - if i.Name == "" { - errs = append(errs, errors.New("name is required")) - } - - if err := i.ServicePeriod.Validate(); err != nil { - errs = append(errs, err) - } - - if err := i.RatecardDiscounts.ValidateForPrice(price); err != nil { - errs = append(errs, err) - } - - return errors.Join(errs...) -} - -func (i SplitLineGroupMutableFields) Clone() SplitLineGroupMutableFields { - clone := i - clone.RatecardDiscounts = i.RatecardDiscounts.Clone() - - return clone -} - -type SplitLineGroupCreate struct { - Namespace string `json:"namespace"` - - SplitLineGroupMutableFields `json:",inline"` - - Price *productcatalog.Price `json:"price"` - FeatureKey *string `json:"featureKey,omitempty"` - Subscription *SubscriptionReference `json:"subscription,omitempty"` - Currency currencyx.Code `json:"currency"` - UniqueReferenceID *string `json:"childUniqueReferenceId,omitempty"` -} - -func (i SplitLineGroupCreate) Validate() error { - var errs []error - - if i.Namespace == "" { - errs = append(errs, errors.New("namespace is required")) - } - - if err := i.SplitLineGroupMutableFields.ValidateForPrice(i.Price); err != nil { - errs = append(errs, err) - } - - if i.Price == nil { - errs = append(errs, errors.New("price is required")) - } else { - if err := i.Price.Validate(); err != nil { - errs = append(errs, err) - } - } - - if i.Subscription != nil { - if err := i.Subscription.Validate(); err != nil { - errs = append(errs, err) - } - } - - if i.Currency == "" { - errs = append(errs, errors.New("currency is required")) - } - - if i.UniqueReferenceID != nil && *i.UniqueReferenceID == "" { - errs = append(errs, errors.New("unique reference id is required")) - } - - return errors.Join(errs...) -} - -type SplitLineGroupUpdate struct { - models.NamespacedID `json:",inline"` - - SplitLineGroupMutableFields `json:",inline"` -} - -func (i SplitLineGroupUpdate) ValidateWithPrice(price *productcatalog.Price) error { - var errs []error - - if err := i.SplitLineGroupMutableFields.ValidateForPrice(price); err != nil { - errs = append(errs, err) - } - - if err := i.NamespacedID.Validate(); err != nil { - errs = append(errs, err) - } - - return errors.Join(errs...) -} - -type SplitLineGroup struct { - models.ManagedModel `json:",inline"` - models.NamespacedID `json:",inline"` - SplitLineGroupMutableFields `json:",inline"` - - Price *productcatalog.Price `json:"price"` - FeatureKey *string `json:"featureKey,omitempty"` - Subscription *SubscriptionReference `json:"subscription,omitempty"` - Currency currencyx.Code `json:"currency"` - UniqueReferenceID *string `json:"childUniqueReferenceId,omitempty"` -} - -func (i SplitLineGroup) Validate() error { - var errs []error - - if err := i.SplitLineGroupMutableFields.ValidateForPrice(i.Price); err != nil { - errs = append(errs, err) - } - - if i.Price != nil { - if err := i.Price.Validate(); err != nil { - errs = append(errs, err) - } - } - - if i.Currency == "" { - errs = append(errs, errors.New("currency is required")) - } - - return errors.Join(errs...) -} - -func (i SplitLineGroup) ToUpdate() SplitLineGroupUpdate { - return SplitLineGroupUpdate{ - NamespacedID: i.NamespacedID, - SplitLineGroupMutableFields: i.SplitLineGroupMutableFields.Clone(), - } -} - -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, - } -} - -type GatheringLineWithInvoiceHeader struct { - Line GatheringLine - Invoice GatheringInvoice -} - -func (l GatheringLineWithInvoiceHeader) Validate() error { - if err := l.Line.Validate(); err != nil { - return fmt.Errorf("line: %w", err) - } - - // This transport only carries the parent invoice entity as a header object, - // so we validate invoice identity here instead of requiring a fully fetched invoice aggregate. - if l.Invoice.ID == "" { - return fmt.Errorf("invoice id is required") - } - - return nil -} - -type StandardLineWithInvoiceHeader struct { - Line *StandardLine - Invoice StandardInvoice -} - -func (l StandardLineWithInvoiceHeader) Validate() error { - if l.Line == nil { - return fmt.Errorf("line is required") - } - - if err := l.Line.Validate(); err != nil { - return fmt.Errorf("line: %w", err) - } - - // This transport only carries the parent invoice entity as a header object, - // so we validate invoice identity here instead of requiring a fully fetched invoice aggregate. - if l.Invoice.ID == "" { - return fmt.Errorf("invoice id is required") - } - - return nil -} - -type LineWithInvoiceHeader struct { - Line GenericInvoiceLine - Invoice GenericInvoiceReader -} - -type lineWithInvoiceHeaderSerde[IT StandardInvoice | GatheringInvoice, LT StandardLine | GatheringLine] struct { - Type InvoiceType `json:"type"` - - Line LT `json:"line"` - Invoice IT `json:"invoice"` -} - -func (l *LineWithInvoiceHeader) UnmarshalJSON(data []byte) error { - var genericSerde struct { - Type InvoiceType `json:"type"` - } - - if err := json.Unmarshal(data, &genericSerde); err != nil { - return err - } - - switch genericSerde.Type { - case InvoiceTypeStandard: - unmarshalled := lineWithInvoiceHeaderSerde[StandardInvoice, StandardLine]{} - if err := json.Unmarshal(data, &unmarshalled); err != nil { - return err - } - - l.Line = &standardInvoiceLineGenericWrapper{StandardLine: &unmarshalled.Line} - l.Invoice = &unmarshalled.Invoice - - return nil - case InvoiceTypeGathering: - unmarshalled := lineWithInvoiceHeaderSerde[GatheringInvoice, GatheringLine]{} - if err := json.Unmarshal(data, &unmarshalled); err != nil { - return err - } - - l.Line = &gatheringInvoiceLineGenericWrapper{GatheringLine: unmarshalled.Line} - l.Invoice = &unmarshalled.Invoice - - return nil - default: - return fmt.Errorf("unknown invoice type: %s", genericSerde.Type) - } -} - -func (l LineWithInvoiceHeader) MarshalJSON() ([]byte, error) { - invoice := l.Invoice.AsInvoice() - - invoiceType := invoice.Type() - - switch invoiceType { - case InvoiceTypeStandard: - stdInvoice, err := invoice.AsStandardInvoice() - if err != nil { - return nil, err - } - - stdLine, err := l.Line.AsInvoiceLine().AsStandardLine() - if err != nil { - return nil, err - } - - return json.Marshal(lineWithInvoiceHeaderSerde[StandardInvoice, StandardLine]{ - Type: InvoiceTypeStandard, - Line: stdLine, - Invoice: stdInvoice, - }) - case InvoiceTypeGathering: - gatheringInvoice, err := invoice.AsGatheringInvoice() - if err != nil { - return nil, err - } - - gatheringLine, err := l.Line.AsInvoiceLine().AsGatheringLine() - if err != nil { - return nil, err - } - - return json.Marshal(lineWithInvoiceHeaderSerde[GatheringInvoice, GatheringLine]{ - Type: InvoiceTypeGathering, - Line: gatheringLine, - Invoice: gatheringInvoice, - }) - } - - return nil, fmt.Errorf("unknown invoice type: %s", invoiceType) -} - -func NewLineWithInvoiceHeader[T StandardLineWithInvoiceHeader | GatheringLineWithInvoiceHeader](line T) LineWithInvoiceHeader { - switch v := any(line).(type) { - case StandardLineWithInvoiceHeader: - return LineWithInvoiceHeader{Line: &standardInvoiceLineGenericWrapper{StandardLine: v.Line}, Invoice: v.Invoice} - case GatheringLineWithInvoiceHeader: - return LineWithInvoiceHeader{Line: &gatheringInvoiceLineGenericWrapper{GatheringLine: v.Line}, Invoice: v.Invoice} - } - - return LineWithInvoiceHeader{} -} - -type LinesWithInvoiceHeaders []LineWithInvoiceHeader - -func (i LinesWithInvoiceHeaders) Lines() []GenericInvoiceLine { - return lo.Map(i, func(line LineWithInvoiceHeader, _ int) GenericInvoiceLine { - return line.Line - }) -} - -type SplitLineHierarchy struct { - Group SplitLineGroup - Lines LinesWithInvoiceHeaders -} - -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 - }) - if err != nil { - return SplitLineHierarchy{}, err - } - - return SplitLineHierarchy{ - Group: h.Group.Clone(), - Lines: lines, - }, nil -} - -type SumNetAmountInput struct { - PeriodEndLTE time.Time - 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. -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 { - 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, err - } - - return netAmount, nil -} - -type ForEachChildInput struct { - PeriodEndLTE time.Time - Callback func(child LineWithInvoiceHeader) 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) { - continue - } - - // The line is deleted - if line.GetDeletedAt() != nil { - continue - } - - // The invoice is deleted - if child.Invoice.GetDeletedAt() != 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 err := in.Callback(child); err != nil { - return err - } - } - - return nil -} - -// Adapter -type ( - CreateSplitLineGroupAdapterInput = SplitLineGroupCreate - UpdateSplitLineGroupInput = SplitLineGroupUpdate - DeleteSplitLineGroupInput = models.NamespacedID - GetSplitLineGroupInput = models.NamespacedID -) - -// LineOrHierarchy is a wrapper around a line or a split line hierarchy - -type LineOrHierarchyType string - -const ( - LineOrHierarchyTypeLine LineOrHierarchyType = "line" - LineOrHierarchyTypeHierarchy LineOrHierarchyType = "hierarchy" -) - -type LineOrHierarchy struct { - t LineOrHierarchyType - line GenericInvoiceLine - splitLineHierarchy *SplitLineHierarchy -} - -func NewLineOrHierarchy[T *StandardLine | GatheringLine | *SplitLineHierarchy](line T) LineOrHierarchy { - switch v := any(line).(type) { - case *StandardLine: - return LineOrHierarchy{t: LineOrHierarchyTypeLine, line: standardInvoiceLineGenericWrapper{StandardLine: v}} - case GatheringLine: - return LineOrHierarchy{t: LineOrHierarchyTypeLine, line: &gatheringInvoiceLineGenericWrapper{GatheringLine: v}} - case *SplitLineHierarchy: - return LineOrHierarchy{t: LineOrHierarchyTypeHierarchy, splitLineHierarchy: v} - } - - return LineOrHierarchy{} -} - -func (i LineOrHierarchy) Type() LineOrHierarchyType { - return i.t -} - -func (i LineOrHierarchy) AsGenericLine() (GenericInvoiceLine, error) { - if i.t != LineOrHierarchyTypeLine { - return nil, fmt.Errorf("line or hierarchy is not a line") - } - - if i.line == nil { - return nil, fmt.Errorf("line is nil") - } - - return i.line, nil -} - -func (i LineOrHierarchy) AsHierarchy() (*SplitLineHierarchy, error) { - if i.t != LineOrHierarchyTypeHierarchy { - return nil, fmt.Errorf("line or hierarchy is not a hierarchy") - } - - if i.splitLineHierarchy == nil { - return nil, fmt.Errorf("split line hierarchy is nil") - } - - return i.splitLineHierarchy, nil -} - -func (i LineOrHierarchy) ChildUniqueReferenceID() *string { - switch i.t { - case LineOrHierarchyTypeLine: - return i.line.GetChildUniqueReferenceID() - case LineOrHierarchyTypeHierarchy: - return i.splitLineHierarchy.Group.UniqueReferenceID - } - - return nil -} - -func (i LineOrHierarchy) ServicePeriod() timeutil.ClosedPeriod { - switch i.t { - case LineOrHierarchyTypeLine: - return i.line.GetServicePeriod() - case LineOrHierarchyTypeHierarchy: - return i.splitLineHierarchy.Group.ServicePeriod - } - - return timeutil.ClosedPeriod{} -} - -type GetSplitLineGroupHeadersInput struct { - Namespace string - SplitLineGroupIDs []string -} - -type SplitLineGroupHeaders = []SplitLineGroup - -func (i GetSplitLineGroupHeadersInput) Validate() error { - var errs []error - - if i.Namespace == "" { - errs = append(errs, errors.New("namespace is required")) - } - - return errors.Join(errs...) -} diff --git a/openmeter/billing/invoicing/legacy/splitlinegroup/adapter.go b/openmeter/billing/invoicing/legacy/splitlinegroup/adapter.go new file mode 100644 index 0000000000..a996b78bb7 --- /dev/null +++ b/openmeter/billing/invoicing/legacy/splitlinegroup/adapter.go @@ -0,0 +1,19 @@ +package splitlinegroup + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/framework/entutils" +) + +type Adapter interface { + GetSplitLineGroupsForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) ([]SplitLineHierarchy, error) + CreateSplitLineGroup(ctx context.Context, input CreateSplitLineGroupAdapterInput) (SplitLineGroup, error) + UpdateSplitLineGroup(ctx context.Context, input UpdateSplitLineGroupInput) (SplitLineGroup, error) + DeleteSplitLineGroup(ctx context.Context, input DeleteSplitLineGroupInput) error + GetSplitLineGroup(ctx context.Context, input GetSplitLineGroupInput) (SplitLineHierarchy, error) + GetSplitLineGroupHeaders(ctx context.Context, input GetSplitLineGroupHeadersInput) (SplitLineGroupHeaders, error) + + entutils.TxCreator +} diff --git a/openmeter/billing/adapter/invoicelinesplitgroup.go b/openmeter/billing/invoicing/legacy/splitlinegroup/adapter/invoicelinesplitgroup.go similarity index 89% rename from openmeter/billing/adapter/invoicelinesplitgroup.go rename to openmeter/billing/invoicing/legacy/splitlinegroup/adapter/invoicelinesplitgroup.go index e6ee34c5be..8adfdb50ea 100644 --- a/openmeter/billing/adapter/invoicelinesplitgroup.go +++ b/openmeter/billing/invoicing/legacy/splitlinegroup/adapter/invoicelinesplitgroup.go @@ -18,6 +18,53 @@ import ( var _ billing.InvoiceSplitLineGroupAdapter = (*adapter)(nil) +func (a *adapter) GetSplitLineGroupsForSubscription(ctx context.Context, in billing.GetLinesForSubscriptionInput) ([]billing.SplitLineHierarchy, error) { + if err := in.Validate(); err != nil { + return nil, billing.ValidationError{ + Err: err, + } + } + + return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) ([]billing.SplitLineHierarchy, error) { + dbGroups, err := tx.db.BillingInvoiceSplitLineGroup.Query(). + Where(billinginvoicesplitlinegroup.Namespace(in.Namespace)). + Where(billinginvoicesplitlinegroup.SubscriptionID(in.SubscriptionID)). + WithBillingInvoiceLines(func(q *db.BillingInvoiceLineQuery) { + tx.expandLineItems(q) + q.WithBillingInvoice(func(q *db.BillingInvoiceQuery) { + q.WithBillingWorkflowConfig(workflowConfigWithTaxCode) + }) + }). + Where(billinginvoicesplitlinegroup.DeletedAtIsNil()). + All(ctx) + if err != nil { + return nil, fmt.Errorf("fetching split line groups: %w", err) + } + + groups, err := slicesx.MapWithErr(dbGroups, func(dbGroup *db.BillingInvoiceSplitLineGroup) (billing.SplitLineHierarchy, error) { + group, err := tx.mapSplitLineGroupFromDB(dbGroup) + if err != nil { + return billing.SplitLineHierarchy{}, err + } + + lines, err := tx.mapSplitLineHierarchyLinesFromDB(ctx, dbGroup.Edges.BillingInvoiceLines) + if err != nil { + return billing.SplitLineHierarchy{}, err + } + + return billing.SplitLineHierarchy{ + Group: group, + Lines: lines, + }, nil + }) + if err != nil { + return nil, fmt.Errorf("mapping split line groups: %w", err) + } + + return groups, nil + }) +} + func (a *adapter) CreateSplitLineGroup(ctx context.Context, input billing.CreateSplitLineGroupAdapterInput) (billing.SplitLineGroup, error) { if err := input.Validate(); err != nil { return billing.SplitLineGroup{}, billing.ValidationError{ diff --git a/openmeter/billing/invoicing/legacy/splitlinegroup/headers.go b/openmeter/billing/invoicing/legacy/splitlinegroup/headers.go new file mode 100644 index 0000000000..1d61500f65 --- /dev/null +++ b/openmeter/billing/invoicing/legacy/splitlinegroup/headers.go @@ -0,0 +1,137 @@ +package splitlinegroup + +import ( + "fmt" + "time" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + "github.com/openmeterio/openmeter/pkg/models" + timeutil "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type LineHeaderAccessor interface { + GetDeletedAt() *time.Time + GetServicePeriod() timeutil.ClosedPeriod + GetAnnotations() models.Annotations + GetManagedBy() billing.InvoiceLineManagedBy + GetInvoiceID() models.NamespacedID + GetID() billing.LineID +} + +var _ LineHeaderAccessor = (*StandardLine)(nil) + +type StandardLines []StandardLine + +type StandardLine struct { + ID billing.LineID + DeletedAt *time.Time + Annotations models.Annotations + ManagedBy billing.InvoiceLineManagedBy + + Invoice InvoiceHeader + + ServicePeriod timeutil.ClosedPeriod + Totals totals.Totals + Subscription *billing.SubscriptionReference +} + +func (l StandardLine) GetDeletedAt() *time.Time { + return l.DeletedAt +} + +func (l StandardLine) GetServicePeriod() timeutil.ClosedPeriod { + return l.ServicePeriod +} + +func (l StandardLine) GetAnnotations() models.Annotations { + return l.Annotations +} + +func (l StandardLine) GetManagedBy() billing.InvoiceLineManagedBy { + return l.ManagedBy +} + +func (l StandardLine) GetInvoiceID() models.NamespacedID { + return models.NamespacedID{ + Namespace: l.ID.Namespace, + ID: l.Invoice.ID, + } +} + +func (l StandardLine) GetID() billing.LineID { + return l.ID +} + +func (l StandardLine) Clone() (StandardLine, error) { + var err error + l.Annotations, err = l.Annotations.Clone() + if err != nil { + return StandardLine{}, fmt.Errorf("cloning annotations: %w", err) + } + + return l, nil +} + +var _ LineHeaderAccessor = (*GatheringLine)(nil) + +type GatheringLine struct { + DeletedAt *time.Time + ID billing.LineID + Annotations models.Annotations + ManagedBy billing.InvoiceLineManagedBy + + Invoice InvoiceHeader + + ServicePeriod timeutil.ClosedPeriod + InvoiceAt time.Time + Subscription *billing.SubscriptionReference +} + +func (l GatheringLine) GetDeletedAt() *time.Time { + return l.DeletedAt +} + +func (l GatheringLine) GetServicePeriod() timeutil.ClosedPeriod { + return l.ServicePeriod +} + +func (l GatheringLine) GetAnnotations() models.Annotations { + return l.Annotations +} + +func (l GatheringLine) GetManagedBy() billing.InvoiceLineManagedBy { + return l.ManagedBy +} + +func (l GatheringLine) GetInvoiceID() models.NamespacedID { + return models.NamespacedID{ + Namespace: l.ID.Namespace, + ID: l.Invoice.ID, + } +} + +func (l GatheringLine) GetID() billing.LineID { + return l.ID +} + +func (l *GatheringLine) CloneOrNil() (*GatheringLine, error) { + if l == nil { + return nil, nil + } + + out := *l + annotations, err := out.Annotations.Clone() + if err != nil { + return nil, fmt.Errorf("cloning annotations: %w", err) + } + + out.Annotations = annotations + + return &out, nil +} + +type InvoiceHeader struct { + ID string + DeletedAt *time.Time +} diff --git a/openmeter/billing/invoicing/legacy/splitlinegroup/invoicelinesplitgroup.go b/openmeter/billing/invoicing/legacy/splitlinegroup/invoicelinesplitgroup.go new file mode 100644 index 0000000000..de7ee982bd --- /dev/null +++ b/openmeter/billing/invoicing/legacy/splitlinegroup/invoicelinesplitgroup.go @@ -0,0 +1,294 @@ +package splitlinegroup + +import ( + "errors" + "fmt" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" + timeutil "github.com/openmeterio/openmeter/pkg/timeutil" +) + +type SplitLineGroupMutableFields struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Metadata models.Metadata `json:"metadata,omitempty"` + + ServicePeriod timeutil.ClosedPeriod `json:"period"` + + RatecardDiscounts billing.Discounts `json:"ratecardDiscounts"` +} + +func (i SplitLineGroupMutableFields) ValidateForPrice(price *productcatalog.Price) error { + var errs []error + + if i.Name == "" { + errs = append(errs, errors.New("name is required")) + } + + if err := i.ServicePeriod.Validate(); err != nil { + errs = append(errs, err) + } + + if err := i.RatecardDiscounts.ValidateForPrice(price); err != nil { + errs = append(errs, err) + } + + return errors.Join(errs...) +} + +func (i SplitLineGroupMutableFields) Clone() SplitLineGroupMutableFields { + clone := i + clone.RatecardDiscounts = i.RatecardDiscounts.Clone() + + return clone +} + +type SplitLineGroupCreate struct { + Namespace string `json:"namespace"` + + SplitLineGroupMutableFields `json:",inline"` + + Price *productcatalog.Price `json:"price"` + FeatureKey *string `json:"featureKey,omitempty"` + Subscription *billing.SubscriptionReference `json:"subscription,omitempty"` + Currency currencyx.Code `json:"currency"` + UniqueReferenceID *string `json:"childUniqueReferenceId,omitempty"` +} + +func (i SplitLineGroupCreate) Validate() error { + var errs []error + + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + + if err := i.SplitLineGroupMutableFields.ValidateForPrice(i.Price); err != nil { + errs = append(errs, err) + } + + if i.Price == nil { + errs = append(errs, errors.New("price is required")) + } else { + if err := i.Price.Validate(); err != nil { + errs = append(errs, err) + } + } + + if i.Subscription != nil { + if err := i.Subscription.Validate(); err != nil { + errs = append(errs, err) + } + } + + if i.Currency == "" { + errs = append(errs, errors.New("currency is required")) + } + + if i.UniqueReferenceID != nil && *i.UniqueReferenceID == "" { + errs = append(errs, errors.New("unique reference id is required")) + } + + return errors.Join(errs...) +} + +type SplitLineGroupUpdate struct { + models.NamespacedID `json:",inline"` + + SplitLineGroupMutableFields `json:",inline"` +} + +func (i SplitLineGroupUpdate) ValidateWithPrice(price *productcatalog.Price) error { + var errs []error + + if err := i.SplitLineGroupMutableFields.ValidateForPrice(price); err != nil { + errs = append(errs, err) + } + + if err := i.NamespacedID.Validate(); err != nil { + errs = append(errs, err) + } + + return errors.Join(errs...) +} + +type SplitLineGroup struct { + models.ManagedModel `json:",inline"` + models.NamespacedID `json:",inline"` + SplitLineGroupMutableFields `json:",inline"` + + Price *productcatalog.Price `json:"price"` + FeatureKey *string `json:"featureKey,omitempty"` + Subscription *billing.SubscriptionReference `json:"subscription,omitempty"` + Currency currencyx.Code `json:"currency"` + UniqueReferenceID *string `json:"childUniqueReferenceId,omitempty"` +} + +func (i SplitLineGroup) Validate() error { + var errs []error + + if err := i.SplitLineGroupMutableFields.ValidateForPrice(i.Price); err != nil { + errs = append(errs, err) + } + + if i.Price != nil { + if err := i.Price.Validate(); err != nil { + errs = append(errs, err) + } + } + + if i.Currency == "" { + errs = append(errs, errors.New("currency is required")) + } + + return errors.Join(errs...) +} + +func (i SplitLineGroup) ToUpdate() SplitLineGroupUpdate { + return SplitLineGroupUpdate{ + NamespacedID: i.NamespacedID, + SplitLineGroupMutableFields: i.SplitLineGroupMutableFields.Clone(), + } +} + +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, + } +} + +type SplitLineHierarchy struct { + Group SplitLineGroup + + StandardLines StandardLines + GatheringLine *GatheringLine +} + +func (h SplitLineHierarchy) Clone() (SplitLineHierarchy, error) { + standardLines, err := lo.MapErr(h.StandardLines, func(line StandardLine, _ int) (StandardLine, error) { + return line.Clone() + }) + if err != nil { + return SplitLineHierarchy{}, fmt.Errorf("cloning standard lines: %w", err) + } + + gatheringLine, err := h.GatheringLine.CloneOrNil() + if err != nil { + return SplitLineHierarchy{}, fmt.Errorf("cloning gathering line: %w", err) + } + + return SplitLineHierarchy{ + Group: h.Group.Clone(), + StandardLines: standardLines, + GatheringLine: gatheringLine, + }, nil +} + +func (h SplitLineHierarchy) Lines() []LineHeaderAccessor { + lines := lo.Map(h.StandardLines, func(line StandardLine, _ int) LineHeaderAccessor { + return line + }) + + if h.GatheringLine != nil { + lines = append(lines, *h.GatheringLine) + } + return lines +} + +type SumNetAmountInput struct { + PeriodEndLTE time.Time + 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. +func (h *SplitLineHierarchy) SumNetAmount(in SumNetAmountInput) (alpacadecimal.Decimal, error) { + netAmount := alpacadecimal.Zero + + err := h.ForEachStandardLine(ForEachStandardLineInput{ + PeriodEndLTE: in.PeriodEndLTE, + Callback: func(line StandardLine) error { + netAmount = netAmount.Add(line.Totals.Amount) + + if in.IncludeCharges { + netAmount = netAmount.Add(line.Totals.ChargesTotal) + } + + return nil + }, + }) + if err != nil { + return alpacadecimal.Zero, err + } + + return netAmount, nil +} + +type ForEachStandardLineInput struct { + PeriodEndLTE time.Time + Callback func(line StandardLine) error +} + +func (h *SplitLineHierarchy) ForEachStandardLine(in ForEachStandardLineInput) error { + for _, line := range h.StandardLines { + // The line is not in scope + if !in.PeriodEndLTE.IsZero() && line.ServicePeriod.To.After(in.PeriodEndLTE) { + continue + } + + if line.DeletedAt != nil { + continue + } + + if line.Invoice.DeletedAt != nil { + continue + } + + if err := in.Callback(line); err != nil { + return err + } + } + + return nil +} + +// Adapter +type ( + // TODO: Remove type aliases + CreateSplitLineGroupAdapterInput = SplitLineGroupCreate + UpdateSplitLineGroupInput = SplitLineGroupUpdate + DeleteSplitLineGroupInput = models.NamespacedID + GetSplitLineGroupInput = models.NamespacedID +) + +type GetSplitLineGroupHeadersInput struct { + Namespace string + SplitLineGroupIDs []string +} + +type SplitLineGroupHeaders = []SplitLineGroup + +func (i GetSplitLineGroupHeadersInput) Validate() error { + var errs []error + + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + + return errors.Join(errs...) +} diff --git a/openmeter/billing/invoicelinesplitgroup_test.go b/openmeter/billing/invoicing/legacy/splitlinegroup/invoicelinesplitgroup_test.go similarity index 98% rename from openmeter/billing/invoicelinesplitgroup_test.go rename to openmeter/billing/invoicing/legacy/splitlinegroup/invoicelinesplitgroup_test.go index d73691c636..fe6c1ad042 100644 --- a/openmeter/billing/invoicelinesplitgroup_test.go +++ b/openmeter/billing/invoicing/legacy/splitlinegroup/invoicelinesplitgroup_test.go @@ -1,4 +1,4 @@ -package billing +package splitlinegroup import ( "encoding/json" diff --git a/openmeter/billing/invoicing/legacy/splitlinegroup/service.go b/openmeter/billing/invoicing/legacy/splitlinegroup/service.go new file mode 100644 index 0000000000..16f64ad50a --- /dev/null +++ b/openmeter/billing/invoicing/legacy/splitlinegroup/service.go @@ -0,0 +1,14 @@ +package splitlinegroup + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/billing" +) + +type Service interface { + DeleteSplitLineGroup(ctx context.Context, input DeleteSplitLineGroupInput) error + UpdateSplitLineGroup(ctx context.Context, input UpdateSplitLineGroupInput) (SplitLineGroup, error) + // GetSplitLineGroupsForSubscription returns the active split-line hierarchies required for subscription sync. + GetSplitLineGroupsForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) ([]SplitLineHierarchy, error) +} diff --git a/openmeter/billing/service/invoicelinesplitgroup.go b/openmeter/billing/invoicing/legacy/splitlinegroup/service/invoicelinesplitgroup.go similarity index 82% rename from openmeter/billing/service/invoicelinesplitgroup.go rename to openmeter/billing/invoicing/legacy/splitlinegroup/service/invoicelinesplitgroup.go index bab7b6413c..a87eff0287 100644 --- a/openmeter/billing/service/invoicelinesplitgroup.go +++ b/openmeter/billing/invoicing/legacy/splitlinegroup/service/invoicelinesplitgroup.go @@ -1,4 +1,4 @@ -package billingservice +package service import ( "context" @@ -8,6 +8,18 @@ import ( "github.com/openmeterio/openmeter/pkg/framework/transaction" ) +func (s *Service) GetSplitLineGroupsForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) ([]billing.SplitLineHierarchy, error) { + if err := input.Validate(); err != nil { + return nil, billing.ValidationError{ + Err: err, + } + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) ([]billing.SplitLineHierarchy, error) { + return s.adapter.GetSplitLineGroupsForSubscription(ctx, input) + }) +} + func (s *Service) DeleteSplitLineGroup(ctx context.Context, input billing.DeleteSplitLineGroupInput) error { if err := input.Validate(); err != nil { return billing.ValidationError{ diff --git a/openmeter/billing/lineengine/engine.go b/openmeter/billing/lineengine/engine.go index f1a77bd49f..0a157494b4 100644 --- a/openmeter/billing/lineengine/engine.go +++ b/openmeter/billing/lineengine/engine.go @@ -9,20 +9,21 @@ import ( "github.com/openmeterio/openmeter/openmeter/billing" "github.com/openmeterio/openmeter/openmeter/billing/rating" "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/openmeter/streaming" "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/equal" "github.com/openmeterio/openmeter/pkg/slicesx" ) -var ( - _ billing.LineEngine = (*Engine)(nil) - _ billing.LineCalculator = (*Engine)(nil) -) +var _ billing.LineEngine = (*Engine)(nil) type Config struct { - SplitLineGroupAdapter SplitLineGroupAdapter - QuantitySnapshotter QuantitySnapshotter - RatingService rating.Service + SplitLineGroupAdapter SplitLineGroupAdapter + RatingService rating.Service + FeatureService feature.FeatureConnector + StreamingConnector streaming.Connector + MaxParallelQuantitySnapshots int } func (c Config) Validate() error { @@ -30,21 +31,31 @@ func (c Config) Validate() error { return fmt.Errorf("split line group adapter is required") } - if c.QuantitySnapshotter == nil { - return fmt.Errorf("quantity snapshotter is required") - } - if c.RatingService == nil { return fmt.Errorf("rating service is required") } + if c.FeatureService == nil { + return fmt.Errorf("feature service is required") + } + + if c.StreamingConnector == nil { + return fmt.Errorf("streaming connector is required") + } + + if c.MaxParallelQuantitySnapshots < 1 { + return fmt.Errorf("max parallel quantity snapshots must be greater than 0") + } + return nil } type Engine struct { - adapter SplitLineGroupAdapter - quantitySnapshotter QuantitySnapshotter - ratingService rating.Service + adapter SplitLineGroupAdapter + ratingService rating.Service + featureService feature.FeatureConnector + streamingConnector streaming.Connector + maxParallelQuantitySnapshots int } func New(config Config) (*Engine, error) { @@ -53,9 +64,11 @@ func New(config Config) (*Engine, error) { } return &Engine{ - adapter: config.SplitLineGroupAdapter, - quantitySnapshotter: config.QuantitySnapshotter, - ratingService: config.RatingService, + adapter: config.SplitLineGroupAdapter, + ratingService: config.RatingService, + featureService: config.FeatureService, + streamingConnector: config.StreamingConnector, + maxParallelQuantitySnapshots: config.MaxParallelQuantitySnapshots, }, nil } @@ -79,7 +92,12 @@ func (e *Engine) OnCollectionCompleted(ctx context.Context, input billing.OnColl return input.Lines, nil } - if err := e.quantitySnapshotter.SnapshotLineQuantities(ctx, input.Invoice, input.Lines); err != nil { + linesWithSplitLineHierarchy, err := e.ResolveSplitLineGroupHeaders(ctx, input.Invoice.Namespace, input.Lines) + if err != nil { + return nil, fmt.Errorf("resolving split line group headers: %w", err) + } + + if err := e.SnapshotLineQuantities(ctx, input.Invoice, linesWithSplitLineHierarchy); err != nil { if _, isInvalidDatabaseState := lo.ErrorsAs[*billing.ErrSnapshotInvalidDatabaseState](err); isInvalidDatabaseState { return nil, billing.ValidationIssue{ Severity: billing.ValidationIssueSeverityCritical, @@ -92,7 +110,15 @@ func (e *Engine) OnCollectionCompleted(ctx context.Context, input billing.OnColl return nil, fmt.Errorf("snapshotting lines: %w", err) } - return input.Lines, nil + calculatedLines, err := e.calculateLines(calculateLinesInput{ + Invoice: input.Invoice, + Lines: linesWithSplitLineHierarchy, + }) + if err != nil { + return nil, fmt.Errorf("calculating lines: %w", err) + } + + return calculatedLines.AsStandardLines(), nil } func (e *Engine) ValidateMutableInvoiceLineEditViaAPI(_ context.Context, input billing.OnMutableInvoiceUpdateInput) error { @@ -174,11 +200,28 @@ func (e *Engine) snapshotManualStandardLineOverrideIfNeeded(ctx context.Context, return nil, fmt.Errorf("getting standard line: %w", err) } - if err := e.quantitySnapshotter.SnapshotLineQuantities(ctx, standardInvoice, billing.StandardLines{&standardLine}); err != nil { + linesWithSplitLineHierarchy, err := e.ResolveSplitLineGroupHeaders(ctx, invoice.GetInvoiceID().Namespace, billing.StandardLines{&standardLine}) + if err != nil { + return nil, fmt.Errorf("resolving split line group headers: %w", err) + } + + if err := e.SnapshotLineQuantities(ctx, standardInvoice, linesWithSplitLineHierarchy); err != nil { return nil, fmt.Errorf("snapshotting line quantity: %w", err) } - return standardLine.AsGenericLine(), nil + calculatedLines, err := e.calculateLines(calculateLinesInput{ + Invoice: standardInvoice, + Lines: linesWithSplitLineHierarchy, + }) + if err != nil { + return nil, fmt.Errorf("calculating lines: %w", err) + } + + if len(calculatedLines) != 1 { + return nil, fmt.Errorf("expected 1 line, got %d [line_id=%s]", len(calculatedLines), standardLine.GetID()) + } + + return calculatedLines[0].AsGenericLine(), nil } func validateLegacyLineOverride(override billing.InvoiceLineOverride) error { diff --git a/openmeter/billing/service/quantitysnapshot.go b/openmeter/billing/lineengine/quantitysnapshot.go similarity index 56% rename from openmeter/billing/service/quantitysnapshot.go rename to openmeter/billing/lineengine/quantitysnapshot.go index d7feba5ea3..276a5dd316 100644 --- a/openmeter/billing/service/quantitysnapshot.go +++ b/openmeter/billing/lineengine/quantitysnapshot.go @@ -1,4 +1,4 @@ -package billingservice +package lineengine import ( "context" @@ -15,48 +15,111 @@ import ( "github.com/openmeterio/openmeter/openmeter/meter" "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" "github.com/openmeterio/openmeter/openmeter/streaming" + "github.com/openmeterio/openmeter/pkg/ref" ) -func (s *Service) SnapshotLineQuantity(ctx context.Context, input billing.SnapshotLineQuantityInput) (*billing.StandardLine, error) { +type SnapshotLineQuantityInput struct { + Invoice *billing.StandardInvoice + Line *billing.StandardLine +} + +func (i SnapshotLineQuantityInput) Validate() error { + var errs []error + if i.Invoice == nil { + errs = append(errs, errors.New("invoice is required")) + } + + if i.Line == nil { + errs = append(errs, errors.New("line is required")) + } + + return errors.Join(errs...) +} + +// SnapshotLineQuantity snapshots the quantity of a standard line, this is an external API for invoice updater as needed. +// This is only needed for the invoiceupdater to try to minimize unnecessary immutable invoice updates, but generally should be an internal affair +// of the lineengine itself. +func (e *Engine) SnapshotLineQuantity(ctx context.Context, input SnapshotLineQuantityInput) (*billing.StandardLine, error) { if err := input.Validate(); err != nil { return nil, billing.ValidationError{ Err: err, } } - featureMeters, err := s.resolveFeatureMeters(ctx, input.Invoice.Namespace, billing.StandardLines{input.Line}) + linesWithHierarchy, err := e.ResolveSplitLineGroupHeaders(ctx, input.Invoice.Namespace, billing.StandardLines{input.Line}) + if err != nil { + return nil, fmt.Errorf("resolving split line group headers: %w", err) + } + + featureMeters, err := e.resolveFeatureMeters(ctx, input.Invoice.Namespace, linesWithHierarchy) if err != nil { return nil, fmt.Errorf("line[%s]: %w", input.Line.ID, err) } - err = s.snapshotLineQuantity(ctx, input.Invoice.Customer, input.Line, featureMeters) + if len(linesWithHierarchy) != 1 { + return nil, fmt.Errorf("expected 1 line with hierarchy, got %d [line_id: %s]", len(linesWithHierarchy), input.Line.ID) + } + + err = e.snapshotLineQuantity(ctx, input.Invoice.Customer, linesWithHierarchy[0], featureMeters) if err != nil { return nil, err } - return input.Line, nil + return linesWithHierarchy[0].StandardLine, nil } -func (s *Service) SnapshotLineQuantities(ctx context.Context, invoice billing.StandardInvoice, lines billing.StandardLines) error { - featureMeters, err := s.resolveFeatureMeters(ctx, invoice.Namespace, lines) +func (e *Engine) SnapshotLineQuantities(ctx context.Context, invoice billing.StandardInvoice, lines StandardLinesWithSplitLineHierarchy) error { + featureMeters, err := e.resolveFeatureMeters(ctx, invoice.Namespace, lines) if err != nil { return fmt.Errorf("resolving feature meters: %w", err) } - if err := s.snapshotLineQuantitiesInParallel(ctx, invoice.Customer, lines, featureMeters); err != nil { + if err := e.snapshotLineQuantitiesInParallel(ctx, invoice.Customer, lines, featureMeters); err != nil { return fmt.Errorf("snapshotting lines: %w", err) } return nil } -func (s *Service) snapshotMeteredLineQuantity(ctx context.Context, line *billing.StandardLine, customer billing.InvoiceCustomer, featureMeters feature.FeatureMeters) error { +func (e *Engine) resolveFeatureMeters(ctx context.Context, namespace string, lines StandardLinesWithSplitLineHierarchy) (feature.FeatureMeters, error) { + keys, err := lines.GetReferencedFeatureKeys() + if err != nil { + return nil, fmt.Errorf("getting referenced feature keys: %w", err) + } + + featureMeters, err := e.featureService.ResolveFeatureMeters(ctx, namespace, lo.Map(keys, func(key string, _ int) ref.IDOrKey { + return ref.IDOrKey{Key: key} + })...) + if err != nil { + return nil, fmt.Errorf("resolving feature meters: %w", err) + } + + return featureMetersErrorWrapper{featureMeters}, nil +} + +// featureMetersErrorWrapper returns ErrSnapshotInvalidDatabaseState when a feature meter is unavailable during snapshotting. +type featureMetersErrorWrapper struct { + feature.FeatureMeters +} + +func (w featureMetersErrorWrapper) Get(featureKey string, requireMeter bool) (feature.FeatureMeter, error) { + featureMeter, err := w.FeatureMeters.Get(featureKey, requireMeter) + if err != nil { + return feature.FeatureMeter{}, &billing.ErrSnapshotInvalidDatabaseState{ + Err: err, + } + } + + return featureMeter, nil +} + +func (e *Engine) snapshotMeteredLineQuantity(ctx context.Context, line StandardLineWithSplitLineHierarchy, customer billing.InvoiceCustomer, featureMeters feature.FeatureMeters) error { featureMeter, err := featureMeters.Get(line.UsageBased.FeatureKey, true) if err != nil { return err } - usage, err := s.getFeatureUsage(ctx, + usage, err := e.getFeatureUsage(ctx, getFeatureUsageInput{ Line: line, Feature: featureMeter.Feature, @@ -76,7 +139,7 @@ func (s *Service) snapshotMeteredLineQuantity(ctx context.Context, line *billing return nil } -func (s *Service) snapshotFlatPriceLineQuantity(_ context.Context, line *billing.StandardLine) error { +func (e *Engine) snapshotFlatPriceLineQuantity(_ context.Context, line StandardLineWithSplitLineHierarchy) error { line.UsageBased.MeteredQuantity = lo.ToPtr(alpacadecimal.NewFromInt(1)) line.UsageBased.Quantity = lo.ToPtr(alpacadecimal.NewFromInt(1)) line.UsageBased.PreLinePeriodQuantity = lo.ToPtr(alpacadecimal.Zero) @@ -84,16 +147,16 @@ func (s *Service) snapshotFlatPriceLineQuantity(_ context.Context, line *billing return nil } -func (s *Service) snapshotLineQuantity(ctx context.Context, customer billing.InvoiceCustomer, line *billing.StandardLine, featureMeters feature.FeatureMeters) error { +func (e *Engine) snapshotLineQuantity(ctx context.Context, customer billing.InvoiceCustomer, line StandardLineWithSplitLineHierarchy, featureMeters feature.FeatureMeters) error { if !line.DependsOnMeteredQuantity() { - return s.snapshotFlatPriceLineQuantity(ctx, line) + return e.snapshotFlatPriceLineQuantity(ctx, line) } - return s.snapshotMeteredLineQuantity(ctx, line, customer, featureMeters) + return e.snapshotMeteredLineQuantity(ctx, line, customer, featureMeters) } -func (s *Service) snapshotLineQuantitiesInParallel(ctx context.Context, customer billing.InvoiceCustomer, lines billing.StandardLines, featureMeters feature.FeatureMeters) error { - workerCount := s.maxParallelQuantitySnapshots +func (e *Engine) snapshotLineQuantitiesInParallel(ctx context.Context, customer billing.InvoiceCustomer, lines StandardLinesWithSplitLineHierarchy, featureMeters feature.FeatureMeters) error { + workerCount := e.maxParallelQuantitySnapshots if workerCount <= 0 { workerCount = 1 } @@ -128,7 +191,7 @@ func (s *Service) snapshotLineQuantitiesInParallel(ctx context.Context, customer } }() - err = s.snapshotLineQuantity(ctx, customer, line, featureMeters) + err = e.snapshotLineQuantity(ctx, customer, line, featureMeters) }) } @@ -148,14 +211,14 @@ func (s *Service) snapshotLineQuantitiesInParallel(ctx context.Context, customer } type getFeatureUsageInput struct { - Line *billing.StandardLine + Line StandardLineWithSplitLineHierarchy Meter meter.Meter Feature feature.Feature Customer billing.InvoiceCustomer } func (i getFeatureUsageInput) Validate() error { - if i.Line == nil { + if i.Line.StandardLine == nil { return fmt.Errorf("line is required") } @@ -170,10 +233,11 @@ func (i getFeatureUsageInput) Validate() error { // TODO[OM-160]: We need to have this check to make sure that usage discounts are properly accounted for // but we seem to have a bug in syncing progressively billed lines, so let's address this as a separate pr. + // TODO[BeforeMerge]: Make sure we have a test for this. - // if i.Line.SplitLineGroupID != nil && i.Line.SplitLineHierarchy == nil { - // return fmt.Errorf("split line group id is set but split line hierarchy is not expanded") - // } + if i.Line.SplitLineGroupID != nil && i.Line.SplitLineHierarchy == nil { + return fmt.Errorf("split line group id is set but split line hierarchy is not expanded") + } if err := i.Customer.Validate(); err != nil { return fmt.Errorf("customer: %w", err) @@ -189,7 +253,7 @@ type featureUsageResponse struct { PreLinePeriodQty alpacadecimal.Decimal } -func (s *Service) getFeatureUsage(ctx context.Context, in getFeatureUsageInput) (*featureUsageResponse, error) { +func (e *Engine) getFeatureUsage(ctx context.Context, in getFeatureUsageInput) (*featureUsageResponse, error) { // Validation if err := in.Validate(); err != nil { return nil, err @@ -206,7 +270,7 @@ func (s *Service) getFeatureUsage(ctx context.Context, in getFeatureUsageInput) // If we are the first line in the split, we don't need to calculate the pre period if lineHierarchy == nil || lineHierarchy.Group.ServicePeriod.From.Equal(in.Line.Period.From) { - meterValues, err := s.streamingConnector.QueryMeter( + meterValues, err := e.streamingConnector.QueryMeter( ctx, in.Line.Namespace, in.Meter, @@ -226,7 +290,7 @@ func (s *Service) getFeatureUsage(ctx context.Context, in getFeatureUsageInput) preLineQuery.From = &lineHierarchy.Group.ServicePeriod.From preLineQuery.To = &in.Line.Period.From - preLineResult, err := s.streamingConnector.QueryMeter( + preLineResult, err := e.streamingConnector.QueryMeter( ctx, in.Line.Namespace, in.Meter, @@ -243,7 +307,7 @@ func (s *Service) getFeatureUsage(ctx context.Context, in getFeatureUsageInput) upToLineEnd.From = &lineHierarchy.Group.ServicePeriod.From upToLineEnd.To = &in.Line.Period.To - upToLineEndResult, err := s.streamingConnector.QueryMeter( + upToLineEndResult, err := e.streamingConnector.QueryMeter( ctx, in.Line.Namespace, in.Meter, diff --git a/openmeter/billing/lineengine/splitlinegroup.go b/openmeter/billing/lineengine/splitlinegroup.go index cc4488afe4..e82550bc50 100644 --- a/openmeter/billing/lineengine/splitlinegroup.go +++ b/openmeter/billing/lineengine/splitlinegroup.go @@ -7,15 +7,17 @@ import ( "github.com/samber/lo" "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/invoicing/legacy/splitlinegroup" "github.com/openmeterio/openmeter/openmeter/productcatalog" "github.com/openmeterio/openmeter/openmeter/streaming" "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/timeutil" ) +// TODO: Remove this and use the splitline adapter type SplitLineGroupAdapter interface { - CreateSplitLineGroup(ctx context.Context, input billing.CreateSplitLineGroupAdapterInput) (billing.SplitLineGroup, error) - GetSplitLineGroupHeaders(ctx context.Context, input billing.GetSplitLineGroupHeadersInput) (billing.SplitLineGroupHeaders, error) + CreateSplitLineGroup(ctx context.Context, input splitlinegroup.CreateSplitLineGroupAdapterInput) (splitlinegroup.SplitLineGroup, error) + GetSplitLineGroupHeaders(ctx context.Context, input splitlinegroup.GetSplitLineGroupHeadersInput) (splitlinegroup.SplitLineGroupHeaders, error) } func (e *Engine) SplitGatheringLine(ctx context.Context, in billing.SplitGatheringLineInput) (billing.SplitGatheringLineResult, error) { @@ -33,9 +35,9 @@ func (e *Engine) SplitGatheringLine(ctx context.Context, in billing.SplitGatheri var splitLineGroupID string if line.SplitLineGroupID == nil { - splitLineGroup, err := e.adapter.CreateSplitLineGroup(ctx, billing.CreateSplitLineGroupAdapterInput{ + splitLineGroup, err := e.adapter.CreateSplitLineGroup(ctx, splitlinegroup.CreateSplitLineGroupAdapterInput{ Namespace: line.Namespace, - SplitLineGroupMutableFields: billing.SplitLineGroupMutableFields{ + SplitLineGroupMutableFields: splitlinegroup.SplitLineGroupMutableFields{ Name: line.Name, Description: line.Description, ServicePeriod: timeutil.ClosedPeriod{ @@ -113,7 +115,7 @@ func (e *Engine) SplitGatheringLine(ctx context.Context, in billing.SplitGatheri }, nil } -func (e *Engine) ResolveSplitLineGroupHeaders(ctx context.Context, ns string, lines billing.StandardLines) error { +func (e *Engine) ResolveSplitLineGroupHeaders(ctx context.Context, ns string, lines billing.StandardLines) ([]StandardLineWithSplitLineHierarchy, error) { splitLineGroupIDs := lo.Uniq( lo.Filter( lo.Map(lines, func(line *billing.StandardLine, _ int) string { return lo.FromPtr(line.SplitLineGroupID) }), @@ -122,37 +124,39 @@ func (e *Engine) ResolveSplitLineGroupHeaders(ctx context.Context, ns string, li ) if len(splitLineGroupIDs) == 0 { - return nil + return lo.Map(lines, func(line *billing.StandardLine, _ int) StandardLineWithSplitLineHierarchy { + return StandardLineWithSplitLineHierarchy{StandardLine: line} + }), nil } - splitLineGroupHeaders, err := e.adapter.GetSplitLineGroupHeaders(ctx, billing.GetSplitLineGroupHeadersInput{ + splitLineGroupHeaders, err := e.adapter.GetSplitLineGroupHeaders(ctx, splitlinegroup.GetSplitLineGroupHeadersInput{ Namespace: ns, SplitLineGroupIDs: splitLineGroupIDs, }) if err != nil { - return fmt.Errorf("getting split line group headers: %w", err) + return nil, fmt.Errorf("getting split line group headers: %w", err) } - splitLineGroupHeadersByID := lo.SliceToMap(splitLineGroupHeaders, func(header billing.SplitLineGroup) (string, billing.SplitLineGroup) { + splitLineGroupHeadersByID := lo.SliceToMap(splitLineGroupHeaders, func(header splitlinegroup.SplitLineGroup) (string, splitlinegroup.SplitLineGroup) { return header.ID, header }) - for idx := range lines { - if lines[idx].SplitLineGroupID == nil { - continue + return lo.MapErr(lines, func(line *billing.StandardLine, _ int) (StandardLineWithSplitLineHierarchy, error) { + if line.SplitLineGroupID == nil { + return StandardLineWithSplitLineHierarchy{StandardLine: line}, nil } - splitLineGroupHeader, ok := splitLineGroupHeadersByID[lo.FromPtr(lines[idx].SplitLineGroupID)] + splitLineGroupHeader, ok := splitLineGroupHeadersByID[lo.FromPtr(line.SplitLineGroupID)] if !ok { - return fmt.Errorf("split line group header not found for line[%s]: id[%s]", lines[idx].ID, lo.FromPtr(lines[idx].SplitLineGroupID)) + return StandardLineWithSplitLineHierarchy{StandardLine: line}, fmt.Errorf("split line group header not found for line[%s]: id[%s]", line.ID, lo.FromPtr(line.SplitLineGroupID)) } - lines[idx].SplitLineHierarchy = &billing.SplitLineHierarchy{ - Group: splitLineGroupHeader, - } - } - - return nil + return StandardLineWithSplitLineHierarchy{ + StandardLine: line, SplitLineHierarchy: &splitlinegroup.SplitLineHierarchy{ + Group: splitLineGroupHeader, + }, + }, nil + }) } func isPeriodEmptyConsideringTruncations(line billing.GatheringLine) (bool, error) { diff --git a/openmeter/billing/lineengine/stdinvoice.go b/openmeter/billing/lineengine/stdinvoice.go index ef7f27eb52..691e9e5c5b 100644 --- a/openmeter/billing/lineengine/stdinvoice.go +++ b/openmeter/billing/lineengine/stdinvoice.go @@ -2,6 +2,7 @@ package lineengine import ( "context" + "errors" "fmt" "github.com/samber/lo" @@ -10,20 +11,8 @@ import ( "github.com/openmeterio/openmeter/openmeter/billing/service/invoicecalc" ) -type QuantitySnapshotter interface { - SnapshotLineQuantities(ctx context.Context, invoice billing.StandardInvoice, lines billing.StandardLines) error -} - func (e *Engine) BuildStandardInvoiceLines(ctx context.Context, input billing.BuildStandardInvoiceLinesInput) (billing.StandardLines, error) { - stdLines, err := e.buildStandardInvoiceLinesWithQuantitySnapshot(ctx, input) - if err != nil { - return nil, err - } - - return e.CalculateLines(billing.CalculateLinesInput{ - Invoice: input.Invoice, - Lines: stdLines, - }) + return e.buildStandardInvoiceLinesWithQuantitySnapshot(ctx, input) } func (e *Engine) BuildStandardLinesForGatheringPreview(ctx context.Context, input billing.BuildStandardInvoiceLinesInput) (billing.StandardLines, error) { @@ -44,33 +33,62 @@ func (e *Engine) buildStandardInvoiceLinesWithQuantitySnapshot(ctx context.Conte return nil, err } - if err := e.ResolveSplitLineGroupHeaders(ctx, input.Invoice.Namespace, stdLines); err != nil { + linesWithSplitLineHierarchy, err := e.ResolveSplitLineGroupHeaders(ctx, input.Invoice.Namespace, stdLines) + if err != nil { return nil, fmt.Errorf("resolving split line group headers: %w", err) } - if err := e.quantitySnapshotter.SnapshotLineQuantities(ctx, input.Invoice, stdLines); err != nil { + if err := e.SnapshotLineQuantities(ctx, input.Invoice, linesWithSplitLineHierarchy); err != nil { return nil, fmt.Errorf("snapshotting line quantities: %w", err) } - return stdLines, nil + calculatedLines, err := e.calculateLines(calculateLinesInput{ + Invoice: input.Invoice, + Lines: linesWithSplitLineHierarchy, + }) + if err != nil { + return nil, err + } + + return calculatedLines.AsStandardLines(), nil } -func (e *Engine) CalculateLines(input billing.CalculateLinesInput) (billing.StandardLines, error) { - if input.Invoice.ID == "" { - return nil, fmt.Errorf("invoice id is required") +type calculateLinesInput struct { + Invoice billing.StandardInvoice + Lines StandardLinesWithSplitLineHierarchy +} + +func (i calculateLinesInput) Validate() error { + var errs []error + + if i.Invoice.ID == "" { + errs = append(errs, fmt.Errorf("invoice id is required")) } - if len(input.Lines) == 0 { - return nil, fmt.Errorf("lines are required") + if len(i.Lines) == 0 { + errs = append(errs, fmt.Errorf("lines are required")) + } + + if err := i.Lines.Validate(); err != nil { + errs = append(errs, fmt.Errorf("lines: %w", err)) + } + + return errors.Join(errs...) +} + +// TODO: Do not implement the calculator here, so that we don't need to pass the splitline groups to the rating service +func (e *Engine) calculateLines(input calculateLinesInput) (StandardLinesWithSplitLineHierarchy, error) { + if err := input.Validate(); err != nil { + return nil, fmt.Errorf("validating input: %w", err) } for _, stdLine := range input.Lines { - generatedDetailedLines, err := e.ratingService.GenerateDetailedLines(stdLine) + generatedDetailedLines, err := e.ratingService.GenerateProgressiveBilledDetailedLines(stdLine) if err != nil { return nil, fmt.Errorf("generating detailed lines for line[%s]: %w", stdLine.ID, err) } - if err := invoicecalc.MergeGeneratedDetailedLines(stdLine, generatedDetailedLines); err != nil { + if err := invoicecalc.MergeGeneratedDetailedLines(stdLine.StandardLine, generatedDetailedLines); err != nil { return nil, fmt.Errorf("merging generated detailed lines for line[%s]: %w", stdLine.ID, err) } diff --git a/openmeter/billing/lineengine/stdline.go b/openmeter/billing/lineengine/stdline.go new file mode 100644 index 0000000000..0427e14638 --- /dev/null +++ b/openmeter/billing/lineengine/stdline.go @@ -0,0 +1,89 @@ +package lineengine + +import ( + "errors" + "fmt" + + "github.com/alpacahq/alpacadecimal" + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/invoicing/legacy/splitlinegroup" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" + "github.com/samber/lo" +) + +type StandardLineWithSplitLineHierarchy struct { + *billing.StandardLine + SplitLineHierarchy *splitlinegroup.SplitLineHierarchy +} + +func (i StandardLineWithSplitLineHierarchy) Validate() error { + if i.StandardLine == nil { + return models.NewNillableGenericValidationError(errors.New("standard line is required")) + } + + var errs []error + + if err := i.StandardLine.Validate(); err != nil { + errs = append(errs, err) + } + + if i.StandardLine.SplitLineGroupID != nil { + if i.SplitLineHierarchy == nil { + errs = append(errs, errors.New("split line hierarchy is required")) + } + } + + return errors.Join(errs...) +} + +func (l StandardLineWithSplitLineHierarchy) IsProgressivelyBilled() bool { + return l.SplitLineGroupID != nil +} + +func (i StandardLineWithSplitLineHierarchy) GetProgressivelyBilledServicePeriod() (timeutil.ClosedPeriod, error) { + if i.SplitLineGroupID == nil { + return timeutil.ClosedPeriod{ + From: i.Period.From, + To: i.Period.To, + }, nil + } + + if i.SplitLineHierarchy == nil { + return timeutil.ClosedPeriod{}, errors.New("split line hierarchy is required") + } + + return i.SplitLineHierarchy.Group.ServicePeriod, nil +} + +func (i StandardLineWithSplitLineHierarchy) GetPreviouslyBilledAmount() (alpacadecimal.Decimal, error) { + if i.SplitLineGroupID == nil { + return alpacadecimal.Zero, nil + } + + if i.SplitLineHierarchy == nil { + return alpacadecimal.Zero, fmt.Errorf("line[%s] does not have a progressive line hierarchy, but is a progressive billed line", i.ID) + } + + return i.SplitLineHierarchy.SumNetAmount(splitlinegroup.SumNetAmountInput{ + PeriodEndLTE: i.Period.From, + }) +} + +type StandardLinesWithSplitLineHierarchy []StandardLineWithSplitLineHierarchy + +func (i StandardLinesWithSplitLineHierarchy) Validate() error { + return errors.Join(lo.Map(i, func(line StandardLineWithSplitLineHierarchy, _ int) error { + return line.Validate() + })...) +} + +func (i StandardLinesWithSplitLineHierarchy) AsStandardLines() billing.StandardLines { + return lo.Map(i, func(line StandardLineWithSplitLineHierarchy, _ int) *billing.StandardLine { + return line.StandardLine + }) +} + +func (i StandardLinesWithSplitLineHierarchy) GetReferencedFeatureKeys() ([]string, error) { + return i.AsStandardLines().GetReferencedFeatureKeys() +} diff --git a/openmeter/billing/rating/line.go b/openmeter/billing/rating/line.go index de7caf0973..2d2cf75370 100644 --- a/openmeter/billing/rating/line.go +++ b/openmeter/billing/rating/line.go @@ -44,6 +44,10 @@ type StandardLineAccessor interface { GetUnitConfig() *productcatalog.UnitConfig // GetStandardLineDiscounts returns the standard line discounts for the line GetStandardLineDiscounts() billing.StandardLineDiscounts +} + +type ProgressiveBilledLineAccessor interface { + StandardLineAccessor // Progressive billing related information // IsProgressivelyBilled returns true if the line is progressively billed diff --git a/openmeter/billing/rating/service.go b/openmeter/billing/rating/service.go index 562a3054d5..cbaa3a8fe2 100644 --- a/openmeter/billing/rating/service.go +++ b/openmeter/billing/rating/service.go @@ -14,6 +14,11 @@ import ( type Service interface { ResolveBillablePeriod(in ResolveBillablePeriodInput) (*timeutil.ClosedPeriod, error) + // GenerateProgressiveBilledDetailedLines is a specialized method for generating detailed lines for progressively billed lines. Charges + // never uses this, as it's rating logic does not rely on interval slicing. Once legacy billing is removed, this must be removed, making + // rating code easier to understand/less complex. + GenerateProgressiveBilledDetailedLines(in ProgressiveBilledLineAccessor, opts ...GenerateDetailedLinesOption) (GenerateDetailedLinesResult, error) + // GenerateDetailedLines is the general method for generating detailed lines for standard lines. Progressive billing is not supported. GenerateDetailedLines(in StandardLineAccessor, opts ...GenerateDetailedLinesOption) (GenerateDetailedLinesResult, error) } diff --git a/openmeter/billing/rating/service/detailedline.go b/openmeter/billing/rating/service/detailedline.go index fd08639b9d..d5008735d6 100644 --- a/openmeter/billing/rating/service/detailedline.go +++ b/openmeter/billing/rating/service/detailedline.go @@ -12,10 +12,11 @@ import ( "github.com/openmeterio/openmeter/openmeter/billing/rating/service/rate" "github.com/openmeterio/openmeter/openmeter/productcatalog" "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/timeutil" ) // ValidateStandardLine validates the standard line and returns an error if the line is invalid/inconsistent -func validateStandardLine(in rating.StandardLineAccessor) error { +func validateStandardLine(in rating.ProgressiveBilledLineAccessor) error { if in == nil { return fmt.Errorf("line is nil") } @@ -38,7 +39,7 @@ func validateStandardLine(in rating.StandardLineAccessor) error { return nil } -func (s *service) GenerateDetailedLines(in rating.StandardLineAccessor, opts ...rating.GenerateDetailedLinesOption) (rating.GenerateDetailedLinesResult, error) { +func (s *service) GenerateProgressiveBilledDetailedLines(in rating.ProgressiveBilledLineAccessor, opts ...rating.GenerateDetailedLinesOption) (rating.GenerateDetailedLinesResult, error) { if err := validateStandardLine(in); err != nil { return rating.GenerateDetailedLinesResult{}, fmt.Errorf("validating billable line: %w", err) } @@ -63,7 +64,7 @@ func (s *service) GenerateDetailedLines(in rating.StandardLineAccessor, opts ... } input := rate.PricerCalculateInput{ - StandardLineAccessor: in, + ProgressiveBilledLineAccessor: in, CurrencyCalculator: currency, FullProgressivelyBilledServicePeriod: fullProgressivelyBilledServicePeriod, StandardLineDiscounts: in.GetStandardLineDiscounts(), @@ -100,6 +101,31 @@ func (s *service) GenerateDetailedLines(in rating.StandardLineAccessor, opts ... return outWithTotals, nil } +type nonProgressiveBilledLine struct { + rating.StandardLineAccessor +} + +func (l nonProgressiveBilledLine) GetProgressivelyBilledServicePeriod() (timeutil.ClosedPeriod, error) { + return l.GetServicePeriod(), nil +} + +func (l nonProgressiveBilledLine) IsProgressivelyBilled() bool { + return false +} + +func (l nonProgressiveBilledLine) GetPreviouslyBilledAmount() (alpacadecimal.Decimal, error) { + return alpacadecimal.Zero, nil +} + +func (s *service) GenerateDetailedLines(in rating.StandardLineAccessor, opts ...rating.GenerateDetailedLinesOption) (rating.GenerateDetailedLinesResult, error) { + if _, ok := in.(rating.ProgressiveBilledLineAccessor); ok { + // This should never happen, but if we are feeding the rating a progresively billed line, that's the only way to prevent overcharging. + return rating.GenerateDetailedLinesResult{}, fmt.Errorf("line is progressively billed, use GenerateProgressiveBilledDetailedLines instead") + } + + return s.GenerateProgressiveBilledDetailedLines(nonProgressiveBilledLine{StandardLineAccessor: in}, opts...) +} + // UpdateTotalsFromDetailedLines is a helper method to update the totals of a line from its detailed lines. func getTotalsFromDetailedLines(in rating.GenerateDetailedLinesResult, currency currencyx.Currency) rating.GenerateDetailedLinesResult { // Calculate the line totals diff --git a/openmeter/billing/rating/service/mutator/credits_test.go b/openmeter/billing/rating/service/mutator/credits_test.go index fe44255b23..3b95cf7c08 100644 --- a/openmeter/billing/rating/service/mutator/credits_test.go +++ b/openmeter/billing/rating/service/mutator/credits_test.go @@ -406,11 +406,13 @@ func TestCreditsMutatorSkipsNonPositiveDetailedLineTotals(t *testing.T) { require.NoError(t, err) out, err := (&mutator.Credits{}).Mutate(rate.PricerCalculateInput{ - StandardLineAccessor: &billing.StandardLine{ - StandardLineBase: billing.StandardLineBase{ - CreditsApplied: billing.CreditsApplied{ - { - Amount: alpacadecimal.NewFromFloat(25), + ProgressiveBilledLineAccessor: &billing.StandardLineWithSplitLineHierarchy{ + StandardLine: &billing.StandardLine{ + StandardLineBase: billing.StandardLineBase{ + CreditsApplied: billing.CreditsApplied{ + { + Amount: alpacadecimal.NewFromFloat(25), + }, }, }, }, @@ -449,11 +451,13 @@ func TestCreditsMutatorAllocatesOnlyRemainingPayableAmount(t *testing.T) { require.NoError(t, err) out, err := (&mutator.Credits{}).Mutate(rate.PricerCalculateInput{ - StandardLineAccessor: &billing.StandardLine{ - StandardLineBase: billing.StandardLineBase{ - CreditsApplied: billing.CreditsApplied{ - { - Amount: alpacadecimal.NewFromFloat(30), + ProgressiveBilledLineAccessor: &billing.StandardLineWithSplitLineHierarchy{ + StandardLine: &billing.StandardLine{ + StandardLineBase: billing.StandardLineBase{ + CreditsApplied: billing.CreditsApplied{ + { + Amount: alpacadecimal.NewFromFloat(30), + }, }, }, }, diff --git a/openmeter/billing/rating/service/mutator/forbidunitconfig_test.go b/openmeter/billing/rating/service/mutator/forbidunitconfig_test.go index fb33348693..80b4ad294e 100644 --- a/openmeter/billing/rating/service/mutator/forbidunitconfig_test.go +++ b/openmeter/billing/rating/service/mutator/forbidunitconfig_test.go @@ -17,10 +17,12 @@ func TestForbidUnitConfigMutator(t *testing.T) { newInput := func(unitConfig *productcatalog.UnitConfig) rate.PricerCalculateInput { return rate.PricerCalculateInput{ - StandardLineAccessor: unitConfigTestLine{ - StandardLine: &billing.StandardLine{}, - price: unitPrice, - unitConfig: unitConfig, + ProgressiveBilledLineAccessor: unitConfigTestLine{ + StandardLineWithSplitLineHierarchy: billing.StandardLineWithSplitLineHierarchy{ + StandardLine: &billing.StandardLine{}, + }, + price: unitPrice, + unitConfig: unitConfig, }, Usage: &rating.Usage{ Quantity: alpacadecimal.NewFromFloat(1400), diff --git a/openmeter/billing/rating/service/mutator/unitconfig_test.go b/openmeter/billing/rating/service/mutator/unitconfig_test.go index 1c4df1ed97..cf0274b703 100644 --- a/openmeter/billing/rating/service/mutator/unitconfig_test.go +++ b/openmeter/billing/rating/service/mutator/unitconfig_test.go @@ -27,7 +27,7 @@ const ( // use it (rather than a real RateableIntent) so a single fixture can exercise the // Pre-populated cumulative case, which the charges path never produces at rating time. type unitConfigTestLine struct { - *billing.StandardLine + billing.StandardLineWithSplitLineHierarchy price *productcatalog.Price unitConfig *productcatalog.UnitConfig @@ -48,10 +48,12 @@ func mutateUnitConfig(t *testing.T, price *productcatalog.Price, unitConfig *pro t.Helper() input := rate.PricerCalculateInput{ - StandardLineAccessor: unitConfigTestLine{ - StandardLine: &billing.StandardLine{}, - price: price, - unitConfig: unitConfig, + ProgressiveBilledLineAccessor: unitConfigTestLine{ + StandardLineWithSplitLineHierarchy: billing.StandardLineWithSplitLineHierarchy{ + StandardLine: &billing.StandardLine{}, + }, + price: price, + unitConfig: unitConfig, }, Usage: &rating.Usage{ Quantity: alpacadecimal.NewFromFloat(quantity), @@ -128,10 +130,12 @@ func TestUnitConfigMutator(t *testing.T) { }) input := rate.PricerCalculateInput{ - StandardLineAccessor: unitConfigTestLine{ - StandardLine: &billing.StandardLine{}, - price: packagePrice, - unitConfig: newUnitConfig(opDivide, 1000, roundCeiling), + ProgressiveBilledLineAccessor: unitConfigTestLine{ + StandardLineWithSplitLineHierarchy: billing.StandardLineWithSplitLineHierarchy{ + StandardLine: &billing.StandardLine{}, + }, + price: packagePrice, + unitConfig: newUnitConfig(opDivide, 1000, roundCeiling), }, Usage: &rating.Usage{ Quantity: alpacadecimal.NewFromFloat(1400), @@ -148,10 +152,12 @@ func TestUnitConfigMutator(t *testing.T) { // validator makes a zero factor unreachable through normal writes, but a // corrupt import must fail the mutation instead of panicking the billing worker. input := rate.PricerCalculateInput{ - StandardLineAccessor: unitConfigTestLine{ - StandardLine: &billing.StandardLine{}, - price: unitPrice, - unitConfig: newUnitConfig(opDivide, 0, roundCeiling), + ProgressiveBilledLineAccessor: unitConfigTestLine{ + StandardLineWithSplitLineHierarchy: billing.StandardLineWithSplitLineHierarchy{ + StandardLine: &billing.StandardLine{}, + }, + price: unitPrice, + unitConfig: newUnitConfig(opDivide, 0, roundCeiling), }, Usage: &rating.Usage{ Quantity: alpacadecimal.NewFromFloat(1400), @@ -167,10 +173,12 @@ func TestUnitConfigMutator(t *testing.T) { // Re-rating reads from the raw metered quantity each run; the mutator must not // double-convert, which it guarantees by never mutating the input usage in place. input := rate.PricerCalculateInput{ - StandardLineAccessor: unitConfigTestLine{ - StandardLine: &billing.StandardLine{}, - price: unitPrice, - unitConfig: newUnitConfig(opDivide, 1000, roundCeiling), + ProgressiveBilledLineAccessor: unitConfigTestLine{ + StandardLineWithSplitLineHierarchy: billing.StandardLineWithSplitLineHierarchy{ + StandardLine: &billing.StandardLine{}, + }, + price: unitPrice, + unitConfig: newUnitConfig(opDivide, 1000, roundCeiling), }, Usage: &rating.Usage{ Quantity: alpacadecimal.NewFromFloat(1400), diff --git a/openmeter/billing/rating/service/rate/types.go b/openmeter/billing/rating/service/rate/types.go index f8c162d2db..bc04b0f07a 100644 --- a/openmeter/billing/rating/service/rate/types.go +++ b/openmeter/billing/rating/service/rate/types.go @@ -21,7 +21,7 @@ type Pricer interface { } type PricerCalculateInput struct { - rating.StandardLineAccessor + rating.ProgressiveBilledLineAccessor CurrencyCalculator currencyx.Currency FullProgressivelyBilledServicePeriod timeutil.ClosedPeriod @@ -38,8 +38,8 @@ func (i PricerCalculateInput) Validate() error { return fmt.Errorf("full service period is required") } - if i.StandardLineAccessor == nil { - return fmt.Errorf("standard line accessor is required") + if i.ProgressiveBilledLineAccessor == nil { + return fmt.Errorf("progressively billed line accessor is required") } return nil diff --git a/openmeter/billing/rating/service/testutil/ubptest.go b/openmeter/billing/rating/service/testutil/ubptest.go index ed5961fb11..02b691635a 100644 --- a/openmeter/billing/rating/service/testutil/ubptest.go +++ b/openmeter/billing/rating/service/testutil/ubptest.go @@ -56,37 +56,23 @@ type CalculationTestCase struct { UnitConfigEnabled bool } -// unitConfigLineAccessor wraps a StandardLine to expose a unit_config, which the -// StandardLine type itself never carries. -type unitConfigLineAccessor struct { - *billing.StandardLine - - unitConfig *productcatalog.UnitConfig -} - -func (l unitConfigLineAccessor) GetUnitConfig() *productcatalog.UnitConfig { - return l.unitConfig -} - -type Service interface { - GenerateDetailedLines(in rating.StandardLineAccessor, opts ...rating.GenerateDetailedLinesOption) (rating.GenerateDetailedLinesResult, error) -} - func RunCalculationTestCase(t *testing.T, tc CalculationTestCase) { t.Helper() - line := &billing.StandardLine{ - StandardLineBase: billing.StandardLineBase{ - ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ - ID: "fake-line", - Name: "feature", - }), - Currency: "USD", - RateCardDiscounts: tc.Discounts, - CreditsApplied: tc.CreditsApplied, - }, - UsageBased: &billing.UsageBasedLine{ - Price: lo.ToPtr(tc.Price), + line := &billing.StandardLineWithSplitLineHierarchy{ + StandardLine: &billing.StandardLine{ + StandardLineBase: billing.StandardLineBase{ + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ + ID: "fake-line", + Name: "feature", + }), + Currency: "USD", + RateCardDiscounts: tc.Discounts, + CreditsApplied: tc.CreditsApplied, + }, + UsageBased: &billing.UsageBasedLine{ + Price: lo.ToPtr(tc.Price), + }, }, } @@ -143,14 +129,9 @@ func RunCalculationTestCase(t *testing.T, tc CalculationTestCase) { line.UsageBased.PreLinePeriodQuantity = &tc.Usage.PreLinePeriodQty line.UsageBased.MeteredPreLinePeriodQuantity = &tc.Usage.PreLinePeriodQty - var accessor rating.StandardLineAccessor = line - if tc.UnitConfig != nil { - accessor = unitConfigLineAccessor{StandardLine: line, unitConfig: tc.UnitConfig} - } - svc := service.New(service.Config{UnitConfigEnabled: tc.UnitConfigEnabled}) - res, err := svc.GenerateDetailedLines(accessor, tc.Options...) + res, err := svc.GenerateProgressiveBilledDetailedLines(line, tc.Options...) if err != nil { if tc.ExpectErrorIs != nil { require.ErrorIs(t, err, tc.ExpectErrorIs) diff --git a/openmeter/billing/service.go b/openmeter/billing/service.go index f50bb5e88c..eb6a27e1c7 100644 --- a/openmeter/billing/service.go +++ b/openmeter/billing/service.go @@ -11,9 +11,7 @@ import ( type Service interface { ProfileService CustomerOverrideService - InvoiceLineService LineEngineService - SplitLineGroupService InvoiceService GatheringInvoiceService StandardInvoiceService @@ -45,20 +43,6 @@ type CustomerOverrideService interface { ListCustomerOverrides(ctx context.Context, input ListCustomerOverridesInput) (ListCustomerOverridesResult, error) } -type InvoiceLineService interface { - // GetLinesForSubscription returns the lines or hierarchies required for subscription sync. - // - // It does not include any deleted lines or hierarchy unless the deleted line is manually edited. - // - // This logic prevents reusing old entities that might have dirty state, but the manually edited lines are - // included so that subscription sync can understand the user intent that they don't want to invoice - // that line. - GetLinesForSubscription(ctx context.Context, input GetLinesForSubscriptionInput) ([]LineOrHierarchy, error) - // SnapshotLineQuantity returns an updated line with the quantity snapshoted from meters - // the invoice is used as contextual information to the call. - SnapshotLineQuantity(ctx context.Context, input SnapshotLineQuantityInput) (*StandardLine, error) -} - type LineEngineService interface { RegisterLineEngine(engine LineEngine) error RegisterCreateLineRouter(router CreateLineRouter) error @@ -70,11 +54,6 @@ type LineEngineService interface { OnUnsupportedCreditNote(ctx context.Context, input OnUnsupportedCreditNoteInput) error } -type SplitLineGroupService interface { - DeleteSplitLineGroup(ctx context.Context, input DeleteSplitLineGroupInput) error - UpdateSplitLineGroup(ctx context.Context, input UpdateSplitLineGroupInput) (SplitLineGroup, error) -} - type InvoiceService interface { InvoicePendingLines(ctx context.Context, input InvoicePendingLinesInput, opts ...InvoicePendingLinesOption) ([]StandardInvoice, error) @@ -107,6 +86,9 @@ type InvoiceService interface { } type StandardInvoiceService interface { + // GetStandardLinesForSubscription returns standard lines required for subscription sync. + // Deleted lines are excluded unless they are manually managed, preserving explicit user intent during reconciliation. + GetStandardLinesForSubscription(ctx context.Context, input GetLinesForSubscriptionInput) (StandardLines, error) // UpdateStandardInvoice updates a standard invoice as a whole UpdateStandardInvoice(ctx context.Context, input UpdateStandardInvoiceInput) (StandardInvoice, error) // GetStandardInvoiceById gets a standard invoice by its ID @@ -122,6 +104,9 @@ type StandardInvoiceService interface { } type GatheringInvoiceService interface { + // GetGatheringLinesForSubscription returns gathering lines required for subscription sync. + // Deleted lines are excluded unless they are manually managed, preserving explicit user intent during reconciliation. + GetGatheringLinesForSubscription(ctx context.Context, input GetLinesForSubscriptionInput) (GatheringLines, error) // CreatePendingInvoiceLines creates pending invoice lines for a customer, if the lines are zero valued, the response is nil CreatePendingInvoiceLines(ctx context.Context, input CreatePendingInvoiceLinesInput) (*CreatePendingInvoiceLinesResult, error) diff --git a/openmeter/billing/service/gatheringinvoiceline.go b/openmeter/billing/service/gatheringinvoiceline.go new file mode 100644 index 0000000000..6a5fc1b8e9 --- /dev/null +++ b/openmeter/billing/service/gatheringinvoiceline.go @@ -0,0 +1,20 @@ +package billingservice + +import ( + "context" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/pkg/framework/transaction" +) + +func (s *Service) GetGatheringLinesForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) (billing.GatheringLines, error) { + if err := input.Validate(); err != nil { + return nil, billing.ValidationError{ + Err: err, + } + } + + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (billing.GatheringLines, error) { + return s.adapter.GetGatheringLinesForSubscription(ctx, input) + }) +} diff --git a/openmeter/billing/service/service.go b/openmeter/billing/service/service.go index d1c57a7a2d..ad8307428d 100644 --- a/openmeter/billing/service/service.go +++ b/openmeter/billing/service/service.go @@ -15,7 +15,6 @@ import ( "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/openmeter/meter" "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" - "github.com/openmeterio/openmeter/openmeter/streaming" "github.com/openmeterio/openmeter/openmeter/taxcode" "github.com/openmeterio/openmeter/openmeter/watermill/eventbus" "github.com/openmeterio/openmeter/pkg/framework/transaction" @@ -25,43 +24,40 @@ import ( var _ billing.Service = (*Service)(nil) type Service struct { - adapter billing.Adapter - sequenceService sequence.Service - customerService customer.Service - appService app.Service - taxCodeService taxcode.Service - logger *slog.Logger - invoiceCalculator invoicecalc.Calculator - lineEngines *engineRegistry - ratingService rating.Service - featureService feature.FeatureConnector - meterService meter.Service - streamingConnector streaming.Connector + adapter billing.Adapter + sequenceService sequence.Service + customerService customer.Service + appService app.Service + taxCodeService taxcode.Service + logger *slog.Logger + invoiceCalculator invoicecalc.Calculator + lineEngines *engineRegistry + ratingService rating.Service + featureService feature.FeatureConnector + meterService meter.Service publisher eventbus.Publisher - advancementStrategy billing.AdvancementStrategy - fsNamespaceLockdown []string - maxParallelQuantitySnapshots int + advancementStrategy billing.AdvancementStrategy + fsNamespaceLockdown []string standardInvoiceHooks *billing.StandardInvoiceHooks } type Config struct { - Adapter billing.Adapter - SequenceService sequence.Service - CustomerService customer.Service - AppService app.Service - TaxCodeService taxcode.Service - RatingService rating.Service - Logger *slog.Logger - FeatureService feature.FeatureConnector - MeterService meter.Service - StreamingConnector streaming.Connector - Publisher eventbus.Publisher - AdvancementStrategy billing.AdvancementStrategy - FSNamespaceLockdown []string - MaxParallelQuantitySnapshots int + Adapter billing.Adapter + SequenceService sequence.Service + CustomerService customer.Service + AppService app.Service + TaxCodeService taxcode.Service + RatingService rating.Service + LegacyBillingLineEngine *billinglineengine.Engine + Logger *slog.Logger + FeatureService feature.FeatureConnector + MeterService meter.Service + Publisher eventbus.Publisher + AdvancementStrategy billing.AdvancementStrategy + FSNamespaceLockdown []string } func (c Config) Validate() error { @@ -89,6 +85,10 @@ func (c Config) Validate() error { return errors.New("rating service cannot be null") } + if c.LegacyBillingLineEngine == nil { + return errors.New("legacy billing line engine cannot be null") + } + if c.Logger == nil { return errors.New("logger cannot be null") } @@ -101,10 +101,6 @@ func (c Config) Validate() error { return errors.New("meter repo cannot be null") } - if c.StreamingConnector == nil { - return errors.New("streaming connector cannot be null") - } - if c.Publisher == nil { return errors.New("publisher cannot be null") } @@ -113,10 +109,6 @@ func (c Config) Validate() error { return fmt.Errorf("validating advancement strategy: %w", err) } - if c.MaxParallelQuantitySnapshots < 1 { - return errors.New("max parallel snapshots must be greater than 0") - } - return nil } @@ -126,35 +118,24 @@ func New(config Config) (*Service, error) { } svc := &Service{ - adapter: config.Adapter, - sequenceService: config.SequenceService, - customerService: config.CustomerService, - appService: config.AppService, - taxCodeService: config.TaxCodeService, - logger: config.Logger, - ratingService: config.RatingService, - featureService: config.FeatureService, - meterService: config.MeterService, - streamingConnector: config.StreamingConnector, - publisher: config.Publisher, - advancementStrategy: config.AdvancementStrategy, - fsNamespaceLockdown: config.FSNamespaceLockdown, - maxParallelQuantitySnapshots: config.MaxParallelQuantitySnapshots, - invoiceCalculator: invoicecalc.New(), - lineEngines: newEngineRegistry(), - standardInvoiceHooks: models.NewServiceHookRegistry[billing.StandardInvoice](), - } - - invoiceEngine, err := billinglineengine.New(billinglineengine.Config{ - SplitLineGroupAdapter: config.Adapter, - QuantitySnapshotter: svc, - RatingService: config.RatingService, - }) - if err != nil { - return nil, fmt.Errorf("creating invoice engine: %w", err) - } - - if err := svc.RegisterLineEngine(invoiceEngine); err != nil { + adapter: config.Adapter, + sequenceService: config.SequenceService, + customerService: config.CustomerService, + appService: config.AppService, + taxCodeService: config.TaxCodeService, + logger: config.Logger, + ratingService: config.RatingService, + featureService: config.FeatureService, + meterService: config.MeterService, + publisher: config.Publisher, + advancementStrategy: config.AdvancementStrategy, + fsNamespaceLockdown: config.FSNamespaceLockdown, + invoiceCalculator: invoicecalc.New(), + lineEngines: newEngineRegistry(), + standardInvoiceHooks: models.NewServiceHookRegistry[billing.StandardInvoice](), + } + + if err := svc.RegisterLineEngine(config.LegacyBillingLineEngine); err != nil { return nil, fmt.Errorf("registering invoice engine: %w", err) } diff --git a/openmeter/billing/service/stdinvoiceline.go b/openmeter/billing/service/stdinvoiceline.go index 2c68cb5ca4..2814ccaa68 100644 --- a/openmeter/billing/service/stdinvoiceline.go +++ b/openmeter/billing/service/stdinvoiceline.go @@ -21,8 +21,6 @@ import ( "github.com/openmeterio/openmeter/pkg/sortx" ) -var _ billing.InvoiceLineService = (*Service)(nil) - // TODO[later]: Move this to gatheringinvoice.go func (s *Service) CreatePendingInvoiceLines(ctx context.Context, input billing.CreatePendingInvoiceLinesInput) (*billing.CreatePendingInvoiceLinesResult, error) { for i := range input.Lines { @@ -262,14 +260,14 @@ func (s *Service) upsertGatheringInvoiceForCurrency(ctx context.Context, currenc }, nil } -func (s *Service) GetLinesForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) ([]billing.LineOrHierarchy, error) { +func (s *Service) GetStandardLinesForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) (billing.StandardLines, error) { if err := input.Validate(); err != nil { return nil, billing.ValidationError{ Err: err, } } - return transaction.Run(ctx, s.adapter, func(ctx context.Context) ([]billing.LineOrHierarchy, error) { - return s.adapter.GetLinesForSubscription(ctx, input) + return transaction.Run(ctx, s.adapter, func(ctx context.Context) (billing.StandardLines, error) { + return s.adapter.GetStandardLinesForSubscription(ctx, input) }) } diff --git a/openmeter/billing/stdinvoiceedit.go b/openmeter/billing/stdinvoiceedit.go index c7ae62ed76..2a88cf986e 100644 --- a/openmeter/billing/stdinvoiceedit.go +++ b/openmeter/billing/stdinvoiceedit.go @@ -421,23 +421,3 @@ func applyExistingLineOverrideToGatheringLine(o ExistingLineOverride, line *Gath line.RateCardDiscounts = val.Clone().UpsertCorrelationIDs() } } - -func (l LineWithInvoiceHeader) Validate() error { - if l.Line == nil { - return errors.New("line is required") - } - - if err := l.Line.Validate(); err != nil { - return fmt.Errorf("line: %w", err) - } - - if l.Invoice == nil { - return errors.New("invoice is required") - } - - if l.Invoice.GetID() == "" { - return errors.New("invoice id is required") - } - - return nil -} diff --git a/openmeter/billing/stdinvoiceline.go b/openmeter/billing/stdinvoiceline.go index d6a5422d78..ab9706049a 100644 --- a/openmeter/billing/stdinvoiceline.go +++ b/openmeter/billing/stdinvoiceline.go @@ -192,10 +192,6 @@ func (i StandardLineBase) GetName() string { return i.Name } -func (i StandardLineBase) IsProgressivelyBilled() bool { - return i.SplitLineGroupID != nil -} - type SubscriptionReference struct { SubscriptionID string `json:"subscriptionID"` PhaseID string `json:"phaseID"` @@ -308,8 +304,7 @@ type StandardLine struct { UsageBased *UsageBasedLine `json:"usageBased,omitempty"` - DetailedLines DetailedLines `json:"detailedLines,omitempty"` - SplitLineHierarchy *SplitLineHierarchy `json:"progressiveLineHierarchy,omitempty"` + DetailedLines DetailedLines `json:"detailedLines,omitempty"` Discounts StandardLineDiscounts `json:"discounts,omitempty"` @@ -408,43 +403,10 @@ func (i StandardLine) GetMeteredPreLinePeriodQuantity() (*alpacadecimal.Decimal, return i.UsageBased.MeteredPreLinePeriodQuantity, nil } -func (i StandardLine) GetProgressivelyBilledServicePeriod() (timeutil.ClosedPeriod, error) { - if i.SplitLineGroupID == nil { - return timeutil.ClosedPeriod{ - From: i.Period.From, - To: i.Period.To, - }, nil - } - - if i.SplitLineHierarchy == nil { - return timeutil.ClosedPeriod{}, errors.New("split line hierarchy is required") - } - - return i.SplitLineHierarchy.Group.ServicePeriod, nil -} - -func (i StandardLine) GetPreviouslyBilledAmount() (alpacadecimal.Decimal, error) { - if i.SplitLineGroupID == nil { - return alpacadecimal.Zero, nil - } - - if i.SplitLineHierarchy == nil { - return alpacadecimal.Zero, fmt.Errorf("line[%s] does not have a progressive line hierarchy, but is a progressive billed line", i.ID) - } - - return i.SplitLineHierarchy.SumNetAmount(SumNetAmountInput{ - PeriodEndLTE: i.Period.From, - }) -} - func (i StandardLine) GetStandardLineDiscounts() StandardLineDiscounts { return i.Discounts } -func (i *StandardLine) SetSplitLineHierarchy(hierarchy *SplitLineHierarchy) { - i.SplitLineHierarchy = hierarchy -} - // ToGatheringLineBase converts the standard line to a gathering line base. // This is temporary until the full gathering invoice functionality is split. func (i StandardLine) ToGatheringLineBase() (GatheringLineBase, error) { @@ -507,7 +469,6 @@ func (i StandardLine) CloneWithoutDependencies(edits ...StandardLineEditFunction clone.DeletedAt = nil clone.ParentLineID = nil - clone.SplitLineHierarchy = nil clone.SplitLineGroupID = nil if clone.UsageBased != nil { @@ -528,11 +489,6 @@ func (i StandardLine) WithoutDBState() *StandardLine { return &i } -func (i StandardLine) WithoutSplitLineHierarchy() *StandardLine { - i.SplitLineHierarchy = nil - return &i -} - func (i StandardLine) RemoveCircularReferences() (*StandardLine, error) { clone, err := i.Clone() if err != nil { @@ -646,15 +602,6 @@ func (i StandardLine) clone(opts cloneOptions) (*StandardLine, error) { res.Discounts = i.Discounts.Clone() } - if i.SplitLineHierarchy != nil { - cloned, err := i.SplitLineHierarchy.Clone() - if err != nil { - return nil, fmt.Errorf("cloning split line hierarchy: %w", err) - } - - res.SplitLineHierarchy = lo.ToPtr(cloned) - } - return res, nil } @@ -830,6 +777,29 @@ func (i *StandardLine) SortDetailedLines() { }) } +type StandardLineWithInvoiceHeader struct { + Line *StandardLine + Invoice StandardInvoice +} + +func (l StandardLineWithInvoiceHeader) Validate() error { + if l.Line == nil { + return fmt.Errorf("line is required") + } + + if err := l.Line.Validate(); err != nil { + return fmt.Errorf("line: %w", err) + } + + // This transport only carries the parent invoice entity as a header object, + // so we validate invoice identity here instead of requiring a fully fetched invoice aggregate. + if l.Invoice.ID == "" { + return fmt.Errorf("invoice id is required") + } + + return nil +} + // helper functions for generating new lines type NewFlatFeeLineInput struct { ID string @@ -1177,20 +1147,3 @@ type GetInvoiceLineInput = LineID type GetInvoiceLineOwnershipAdapterInput = LineID type DeleteInvoiceLineInput = LineID - -type SnapshotLineQuantityInput struct { - Invoice *StandardInvoice - Line *StandardLine -} - -func (i SnapshotLineQuantityInput) Validate() error { - if i.Invoice == nil { - return errors.New("invoice is required") - } - - if i.Line == nil { - return errors.New("line is required") - } - - return nil -} diff --git a/openmeter/billing/worker/subscriptionsync/service/base_test.go b/openmeter/billing/worker/subscriptionsync/service/base_test.go index 9237156d3b..391ed92d1b 100644 --- a/openmeter/billing/worker/subscriptionsync/service/base_test.go +++ b/openmeter/billing/worker/subscriptionsync/service/base_test.go @@ -70,6 +70,7 @@ func (s *SuiteBase) SetupSuite() { service, err := New(Config{ BillingService: s.BillingService, + LegacyBillingLineEngine: s.LegacyBillingLineEngine, Logger: slog.Default(), Tracer: noop.NewTracerProvider().Tracer("test"), SubscriptionSyncAdapter: adapter, @@ -93,6 +94,7 @@ func (s *SuiteBase) setupChargesService(config chargestestutils.Config) { service, err := New(Config{ BillingService: s.BillingService, + LegacyBillingLineEngine: s.LegacyBillingLineEngine, ChargesService: s.Charges, Logger: slog.Default(), Tracer: noop.NewTracerProvider().Tracer("test"), diff --git a/openmeter/billing/worker/subscriptionsync/service/persistedstate/item.go b/openmeter/billing/worker/subscriptionsync/service/persistedstate/item.go index babd4e6269..31ab37a02e 100644 --- a/openmeter/billing/worker/subscriptionsync/service/persistedstate/item.go +++ b/openmeter/billing/worker/subscriptionsync/service/persistedstate/item.go @@ -7,6 +7,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased" + "github.com/openmeterio/openmeter/openmeter/billing/invoicing/legacy/splitlinegroup" "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/timeutil" ) @@ -34,7 +35,7 @@ type LineGetter interface { } type SplitLineHierarchyGetter interface { - GetSplitLineHierarchy() *billing.SplitLineHierarchy + GetSplitLineHierarchy() *splitlinegroup.SplitLineHierarchy } type UsageBasedChargeGetter interface { @@ -109,7 +110,7 @@ func ItemAsLine(in Item) (billing.GenericInvoiceLine, error) { } type persistedSplitLineHierarchy struct { - hierarchy *billing.SplitLineHierarchy + hierarchy *splitlinegroup.SplitLineHierarchy } var ( @@ -120,7 +121,7 @@ var ( // newPersistedSplitLineHierarchy constructs a persisted split line hierarchy item. // Kept private so persistedstate controls construction-time validation and Item // implementations can expose non-erroring accessors. -func newPersistedSplitLineHierarchy(hierarchy *billing.SplitLineHierarchy) (persistedSplitLineHierarchy, error) { +func newPersistedSplitLineHierarchy(hierarchy *splitlinegroup.SplitLineHierarchy) (persistedSplitLineHierarchy, error) { if hierarchy == nil { return persistedSplitLineHierarchy{}, fmt.Errorf("split line hierarchy is nil") } @@ -140,7 +141,7 @@ func (i persistedSplitLineHierarchy) ServicePeriod() timeutil.ClosedPeriod { return i.hierarchy.Group.ServicePeriod } -func (i persistedSplitLineHierarchy) GetSplitLineHierarchy() *billing.SplitLineHierarchy { +func (i persistedSplitLineHierarchy) GetSplitLineHierarchy() *splitlinegroup.SplitLineHierarchy { return i.hierarchy } @@ -169,11 +170,11 @@ func (i persistedSplitLineHierarchy) HasLastLineAnnotation(annotation string) bo return child.GetAnnotations().GetBool(annotation) } -func (i persistedSplitLineHierarchy) getLastLineForAnnotations() billing.GenericInvoiceLine { +func (i persistedSplitLineHierarchy) getLastLineForAnnotations() splitlinegroup.LineHeaderAccessor { 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 _, child := range i.hierarchy.Lines() { + if child.GetServicePeriod().To.Equal(servicePeriod.To) && child.GetDeletedAt() == nil { + return child } } @@ -181,7 +182,7 @@ func (i persistedSplitLineHierarchy) getLastLineForAnnotations() billing.Generic } // ItemAsSplitLineHierarchy returns the wrapped hierarchy when the persisted item is hierarchy-backed. -func ItemAsSplitLineHierarchy(in Item) (*billing.SplitLineHierarchy, error) { +func ItemAsSplitLineHierarchy(in Item) (*splitlinegroup.SplitLineHierarchy, error) { hierarchyGetter, ok := in.(SplitLineHierarchyGetter) if !ok { return nil, fmt.Errorf("persisted item does not implement split line hierarchy getter: %s", getErrorDetails(in)) @@ -190,35 +191,6 @@ func ItemAsSplitLineHierarchy(in Item) (*billing.SplitLineHierarchy, error) { return hierarchyGetter.GetSplitLineHierarchy(), nil } -func NewItemFromLineOrHierarchy(lineOrHierarchy billing.LineOrHierarchy) (Item, error) { - switch lineOrHierarchy.Type() { - case billing.LineOrHierarchyTypeLine: - line, err := lineOrHierarchy.AsGenericLine() - if err != nil { - return nil, fmt.Errorf("getting line: %w", err) - } - - if line == nil { - return nil, fmt.Errorf("line is nil") - } - - return newPersistedLine(line) - case billing.LineOrHierarchyTypeHierarchy: - hierarchy, err := lineOrHierarchy.AsHierarchy() - if err != nil { - return nil, fmt.Errorf("getting hierarchy: %w", err) - } - - if hierarchy == nil { - return nil, fmt.Errorf("hierarchy is nil") - } - - return newPersistedSplitLineHierarchy(hierarchy) - default: - return nil, fmt.Errorf("unsupported line or hierarchy type: %s", lineOrHierarchy.Type()) - } -} - type persistedUsageBasedCharge struct { charge usagebased.Charge baseIntent usagebased.Intent diff --git a/openmeter/billing/worker/subscriptionsync/service/persistedstate/loader.go b/openmeter/billing/worker/subscriptionsync/service/persistedstate/loader.go index f6bbc27a29..cc29c1c119 100644 --- a/openmeter/billing/worker/subscriptionsync/service/persistedstate/loader.go +++ b/openmeter/billing/worker/subscriptionsync/service/persistedstate/loader.go @@ -3,78 +3,130 @@ package persistedstate import ( "context" "fmt" + "slices" "github.com/samber/lo" "github.com/openmeterio/openmeter/openmeter/billing" "github.com/openmeterio/openmeter/openmeter/billing/charges" "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/invoicing/legacy/splitlinegroup" "github.com/openmeterio/openmeter/openmeter/streaming" "github.com/openmeterio/openmeter/openmeter/subscription" "github.com/openmeterio/openmeter/pkg/pagination" - "github.com/openmeterio/openmeter/pkg/slicesx" "github.com/openmeterio/openmeter/pkg/timeutil" ) type billingService interface { - GetLinesForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) ([]billing.LineOrHierarchy, error) + GetStandardLinesForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) (billing.StandardLines, error) + GetGatheringLinesForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) (billing.GatheringLines, error) ListInvoices(ctx context.Context, input billing.ListInvoicesInput) (billing.ListInvoicesResponse, error) } +type splitLineGroupService interface { + GetSplitLineGroupsForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) ([]splitlinegroup.SplitLineHierarchy, error) +} + type chargeService interface { ListCharges(ctx context.Context, input charges.ListChargesInput) (pagination.Result[charges.Charge], error) } type Loader struct { - billingService billingService - chargeService chargeService + billingService billingService + chargeService chargeService + splitLineGroupService splitLineGroupService } -func NewLoader(billingService billingService, chargeService chargeService) Loader { +func NewLoader(billingService billingService, chargeService chargeService, splitLineGroupService splitLineGroupService) Loader { return Loader{ - billingService: billingService, - chargeService: chargeService, + billingService: billingService, + chargeService: chargeService, + splitLineGroupService: splitLineGroupService, } } func (l Loader) LoadForSubscription(ctx context.Context, subs subscription.Subscription) (State, error) { - lines, err := l.billingService.GetLinesForSubscription(ctx, billing.GetLinesForSubscriptionInput{ + getLinesInput := billing.GetLinesForSubscriptionInput{ Namespace: subs.Namespace, SubscriptionID: subs.ID, CustomerID: subs.CustomerId, // Charge-managed invoice lines are edited through charge patches, so subscription sync loads the // charge entities instead of reconciling those lines directly. IncludeChargeManaged: false, - }) + } + + standardLines, err := l.billingService.GetStandardLinesForSubscription(ctx, getLinesInput) + if err != nil { + return State{}, fmt.Errorf("getting existing standard lines: %w", err) + } + + gatheringLines, err := l.billingService.GetGatheringLinesForSubscription(ctx, getLinesInput) if err != nil { - return State{}, fmt.Errorf("getting existing lines: %w", err) + return State{}, fmt.Errorf("getting existing gathering lines: %w", err) } - lines, err = slicesx.MapWithErr(lines, normalizePersistedLineOrHierarchy) + splitLineGroups, err := l.splitLineGroupService.GetSplitLineGroupsForSubscription(ctx, getLinesInput) + if err != nil { + return State{}, fmt.Errorf("getting existing split line groups: %w", err) + } + + invoiceLines := slices.Concat(standardLines.AsGenericLines(), gatheringLines.AsGenericLines()) + lineItems, err := lo.MapErr(invoiceLines, func(line billing.GenericInvoiceLine, _ int) (Item, error) { + normalizedLine, err := normalizePersistedLine(line) + if err != nil { + return nil, fmt.Errorf("normalizing existing invoice line: %w", err) + } + + item, err := newPersistedLine(normalizedLine) + if err != nil { + return nil, fmt.Errorf("creating persisted invoice line item: %w", err) + } + + return item, nil + }) if err != nil { - return State{}, fmt.Errorf("normalizing existing lines: %w", err) + return State{}, fmt.Errorf("assembling persisted invoice line items: %w", err) } - byUniqueID := make(map[string]Item, len(lines)) - for _, line := range lines { - uniqueID := line.ChildUniqueReferenceID() - if uniqueID == nil { - continue + hierarchyItems, err := lo.MapErr(splitLineGroups, func(hierarchy splitlinegroup.SplitLineHierarchy, _ int) (Item, error) { + normalizedHierarchy, err := normalizePersistedSplitLineHierarchy(hierarchy) + if err != nil { + return nil, fmt.Errorf("normalizing existing split line hierarchy: %w", err) } - item, err := NewItemFromLineOrHierarchy(line) + item, err := newPersistedSplitLineHierarchy(normalizedHierarchy) if err != nil { - return State{}, fmt.Errorf("creating persisted item[%s]: %w", *uniqueID, err) + return nil, fmt.Errorf("creating persisted split line hierarchy item: %w", err) } - if _, ok := byUniqueID[*uniqueID]; ok { - return State{}, fmt.Errorf("duplicate unique ids in the existing lines") + return item, nil + }) + if err != nil { + return State{}, fmt.Errorf("assembling persisted split line hierarchy items: %w", err) + } + + invoiceItems := slices.Concat(lineItems, hierarchyItems) + itemsByUniqueID := lo.GroupBy( + lo.Filter(invoiceItems, func(item Item, _ int) bool { + return item.ChildUniqueReferenceID() != nil + }), + func(item Item) string { + return *item.ChildUniqueReferenceID() + }, + ) + + byUniqueID, err := lo.MapValuesErr(itemsByUniqueID, func(items []Item, uniqueID string) (Item, error) { + if len(items) > 1 { + return nil, fmt.Errorf("duplicate unique id in persisted invoice items [unique_id=%s, existing_type=%s, duplicate_type=%s]", uniqueID, items[0].Type(), items[1].Type()) } - byUniqueID[*uniqueID] = item + return items[0], nil + }) + if err != nil { + return State{}, err } - invoices, err := l.loadInvoicesForSubscriptionLines(ctx, subs, lines) + invoices, err := l.loadInvoicesForSubscriptionItems(ctx, subs, invoiceItems) if err != nil { return State{}, err } @@ -176,27 +228,34 @@ func (l Loader) loadChargesForSubscription(ctx context.Context, subs subscriptio return byUniqueID, nil } -func (l Loader) loadInvoicesForSubscriptionLines(ctx context.Context, subs subscription.Subscription, lines []billing.LineOrHierarchy) (Invoices, error) { +func (l Loader) loadInvoicesForSubscriptionItems(ctx context.Context, subs subscription.Subscription, items []Item) (Invoices, error) { invoiceIDs := make(map[string]struct{}) - for _, line := range lines { - switch line.Type() { - case billing.LineOrHierarchyTypeLine: - genericLine, err := line.AsGenericLine() + for _, item := range items { + switch item.Type() { + case ItemTypeInvoiceLine: + line, err := ItemAsLine(item) if err != nil { return Invoices{}, fmt.Errorf("getting line invoice id: %w", err) } - invoiceIDs[genericLine.GetInvoiceID()] = struct{}{} - case billing.LineOrHierarchyTypeHierarchy: - hierarchy, err := line.AsHierarchy() + invoiceIDs[line.GetInvoiceID()] = struct{}{} + case ItemTypeInvoiceSplitLineGroup: + hierarchy, err := ItemAsSplitLineHierarchy(item) if err != nil { return Invoices{}, fmt.Errorf("getting hierarchy invoice ids: %w", err) } - for _, child := range hierarchy.Lines { - invoiceIDs[child.Invoice.GetID()] = struct{}{} + for _, child := range hierarchy.StandardLines { + invoiceIDs[child.Invoice.ID] = struct{}{} } + + if hierarchy.GatheringLine != nil { + invoiceIDs[hierarchy.GatheringLine.Invoice.ID] = struct{}{} + } + + default: + return Invoices{}, fmt.Errorf("unsupported persisted invoice item type: %s", item.Type()) } } @@ -241,7 +300,7 @@ func (l Loader) loadInvoices(ctx context.Context, namespace string, invoiceIDs [ return Invoices(byID), nil } -func normalizePersistedLineOrHierarchy(lineOrHierarchy billing.LineOrHierarchy) (billing.LineOrHierarchy, error) { +func normalizePersistedLine(line billing.GenericInvoiceLine) (billing.GenericInvoiceLine, error) { // Subscription sync diffs against meter-compatible time windows. Historical persisted // lines can still carry sub-second timestamps from older writes, but the meter engine // only supports MinimumWindowSizeDuration precision. We normalize persisted state on @@ -249,76 +308,50 @@ func normalizePersistedLineOrHierarchy(lineOrHierarchy billing.LineOrHierarchy) // preserved finer precision than the target state can legally represent. // TODO: Add a migration to normalize existing billing timestamps to the precision // supported by meter queries. - switch lineOrHierarchy.Type() { - case billing.LineOrHierarchyTypeLine: - line, err := lineOrHierarchy.AsGenericLine() - if err != nil { - return billing.LineOrHierarchy{}, fmt.Errorf("getting line: %w", err) - } - - cloned, err := line.Clone() - if err != nil { - return billing.LineOrHierarchy{}, fmt.Errorf("cloning line: %w", err) - } - - cloned.UpdateServicePeriod(func(period *timeutil.ClosedPeriod) { - *period = period.Truncate(streaming.MinimumWindowSizeDuration) - }) - - if invoiceAtAccessor, ok := cloned.(billing.InvoiceAtAccessor); ok { - invoiceAtAccessor.SetInvoiceAt(invoiceAtAccessor.GetInvoiceAt().Truncate(streaming.MinimumWindowSizeDuration)) - } + if line == nil { + return nil, fmt.Errorf("line is nil") + } - normalizeSubscriptionReference(cloned.GetSubscriptionReference()) + cloned, err := line.Clone() + if err != nil { + return nil, fmt.Errorf("cloning line: %w", err) + } - invoiceLine := cloned.AsInvoiceLine() - switch invoiceLine.Type() { - case billing.InvoiceLineTypeStandard: - standardLine, err := invoiceLine.AsStandardLine() - if err != nil { - return billing.LineOrHierarchy{}, fmt.Errorf("getting standard line: %w", err) - } + cloned.UpdateServicePeriod(func(period *timeutil.ClosedPeriod) { + *period = period.Truncate(streaming.MinimumWindowSizeDuration) + }) - return billing.NewLineOrHierarchy(&standardLine), nil - case billing.InvoiceLineTypeGathering: - gatheringLine, err := invoiceLine.AsGatheringLine() - if err != nil { - return billing.LineOrHierarchy{}, fmt.Errorf("getting gathering line: %w", err) - } + if invoiceAtAccessor, ok := cloned.(billing.InvoiceAtAccessor); ok { + invoiceAtAccessor.SetInvoiceAt(invoiceAtAccessor.GetInvoiceAt().Truncate(streaming.MinimumWindowSizeDuration)) + } - return billing.NewLineOrHierarchy(gatheringLine), nil - default: - return billing.LineOrHierarchy{}, fmt.Errorf("unsupported invoice line type: %s", invoiceLine.Type()) - } - case billing.LineOrHierarchyTypeHierarchy: - hierarchy, err := lineOrHierarchy.AsHierarchy() - if err != nil { - return billing.LineOrHierarchy{}, fmt.Errorf("getting hierarchy: %w", err) - } + normalizeSubscriptionReference(cloned.GetSubscriptionReference()) - cloned, err := hierarchy.Clone() - if err != nil { - return billing.LineOrHierarchy{}, fmt.Errorf("cloning hierarchy: %w", err) - } + return cloned, nil +} - cloned.Group.ServicePeriod = cloned.Group.ServicePeriod.Truncate(streaming.MinimumWindowSizeDuration) +func normalizePersistedSplitLineHierarchy(hierarchy splitlinegroup.SplitLineHierarchy) (*splitlinegroup.SplitLineHierarchy, error) { + cloned, err := hierarchy.Clone() + if err != nil { + return nil, fmt.Errorf("cloning hierarchy: %w", err) + } - for i := range cloned.Lines { - cloned.Lines[i].Line.UpdateServicePeriod(func(period *timeutil.ClosedPeriod) { - *period = period.Truncate(streaming.MinimumWindowSizeDuration) - }) + cloned.Group.ServicePeriod = cloned.Group.ServicePeriod.Truncate(streaming.MinimumWindowSizeDuration) - if invoiceAtAccessor, ok := cloned.Lines[i].Line.(billing.InvoiceAtAccessor); ok { - invoiceAtAccessor.SetInvoiceAt(invoiceAtAccessor.GetInvoiceAt().Truncate(streaming.MinimumWindowSizeDuration)) - } + for i, line := range cloned.StandardLines { + line.ServicePeriod = line.ServicePeriod.Truncate(streaming.MinimumWindowSizeDuration) + normalizeSubscriptionReference(line.Subscription) - normalizeSubscriptionReference(cloned.Lines[i].Line.GetSubscriptionReference()) - } + cloned.StandardLines[i] = line + } - return billing.NewLineOrHierarchy(&cloned), nil - default: - return lineOrHierarchy, nil + if cloned.GatheringLine != nil { + cloned.GatheringLine.ServicePeriod = cloned.GatheringLine.ServicePeriod.Truncate(streaming.MinimumWindowSizeDuration) + cloned.GatheringLine.InvoiceAt = cloned.GatheringLine.InvoiceAt.Truncate(streaming.MinimumWindowSizeDuration) + normalizeSubscriptionReference(cloned.GatheringLine.Subscription) } + + return &cloned, nil } func normalizeSubscriptionReference(ref *billing.SubscriptionReference) { diff --git a/openmeter/billing/worker/subscriptionsync/service/reconciler/invoiceupdater/invoiceupdate.go b/openmeter/billing/worker/subscriptionsync/service/reconciler/invoiceupdater/invoiceupdate.go index cc4aa82fcb..2f967e8d83 100644 --- a/openmeter/billing/worker/subscriptionsync/service/reconciler/invoiceupdater/invoiceupdate.go +++ b/openmeter/billing/worker/subscriptionsync/service/reconciler/invoiceupdater/invoiceupdate.go @@ -2,6 +2,7 @@ package invoiceupdater import ( "context" + "errors" "fmt" "log/slog" "strings" @@ -9,6 +10,8 @@ import ( "github.com/samber/lo" "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/invoicing/legacy/splitlinegroup" + billinglineengine "github.com/openmeterio/openmeter/openmeter/billing/lineengine" "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/openmeter/streaming" "github.com/openmeterio/openmeter/pkg/clock" @@ -18,16 +21,59 @@ import ( const subscriptionSyncComponentName billing.ComponentName = "subscription-sync" +// TODO: Move invoiceupdater into billing/lineengine: invoice patches are only used by the legacy +// invoicing backend, which is why this package declares the snapshotter dependency directly. +type QuantitySnapshotter interface { + SnapshotLineQuantity(ctx context.Context, input billinglineengine.SnapshotLineQuantityInput) (*billing.StandardLine, error) +} + +type Config struct { + BillingService billing.Service + SplitLineGroupService splitlinegroup.Service + QuantitySnapshotter QuantitySnapshotter + Logger *slog.Logger +} + +func (c Config) Validate() error { + var errs []error + + if c.BillingService == nil { + errs = append(errs, errors.New("billing service is required")) + } + + if c.SplitLineGroupService == nil { + errs = append(errs, errors.New("split line group service is required")) + } + + if c.QuantitySnapshotter == nil { + errs = append(errs, errors.New("quantity snapshotter is required")) + } + + if c.Logger == nil { + errs = append(errs, errors.New("logger is required")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + type Updater struct { - billingService billing.Service - logger *slog.Logger + billingService billing.Service + quantitySnapshotter QuantitySnapshotter + splitLineGroupService splitlinegroup.Service + logger *slog.Logger } -func New(billingService billing.Service, logger *slog.Logger) *Updater { - return &Updater{ - billingService: billingService, - logger: logger, +func New(config Config) (*Updater, error) { + if err := config.Validate(); err != nil { + return nil, err } + + return &Updater{ + billingService: config.BillingService, + quantitySnapshotter: config.QuantitySnapshotter, + splitLineGroupService: config.SplitLineGroupService, + logger: config.Logger, + }, nil } func (u *Updater) ApplyPatches(ctx context.Context, customerID customer.CustomerID, patches []Patch) error { @@ -214,7 +260,7 @@ type invoicePatches struct { type splitLineGroupPatches struct { deleted []models.NamespacedID - updated []billing.SplitLineGroupUpdate + updated []splitlinegroup.SplitLineGroupUpdate } func (u *Updater) parsePatches(patches []Patch) (patchesParsed, error) { @@ -320,16 +366,6 @@ func (u *Updater) updateMutableStandardInvoice(ctx context.Context, invoice bill return fmt.Errorf("line[%s] not found in the invoice, cannot update", targetStandardLine.ID) } - updatedQtyLine, err := u.billingService.SnapshotLineQuantity(ctx, billing.SnapshotLineQuantityInput{ - Invoice: invoice, - Line: &targetStandardLine, - }) - if err != nil { - return fmt.Errorf("recalculating line[%s]: %w", targetStandardLine.ID, err) - } - - targetStandardLine = *updatedQtyLine - if ok := invoice.Lines.ReplaceByID(targetStandardLine.ID, &targetStandardLine); !ok { return fmt.Errorf("line[%s/%s] not found in the invoice, cannot update", targetStandardLine.ID, lo.FromPtrOr(targetStandardLine.ChildUniqueReferenceID, "nil")) } @@ -465,7 +501,7 @@ func (u *Updater) updateImmutableInvoice(ctx context.Context, invoice billing.St return fmt.Errorf("line[%s] is not a standard line, cannot update: %w", targetState.GetID(), err) } - targetStateWithUpdatedQty, err := u.billingService.SnapshotLineQuantity(ctx, billing.SnapshotLineQuantityInput{ + targetStateWithUpdatedQty, err := u.quantitySnapshotter.SnapshotLineQuantity(ctx, billinglineengine.SnapshotLineQuantityInput{ Invoice: &invoice, Line: &targetStandardLine, }) @@ -543,13 +579,13 @@ func (u *Updater) upsertSplitLineGroups(ctx context.Context, customerID customer } for _, groupID := range changes.deleted { - if err := u.billingService.DeleteSplitLineGroup(ctx, groupID); err != nil { + if err := u.splitLineGroupService.DeleteSplitLineGroup(ctx, groupID); err != nil { return fmt.Errorf("deleting split line group: %w", err) } } for _, group := range changes.updated { - if _, err := u.billingService.UpdateSplitLineGroup(ctx, group); err != nil { + if _, err := u.splitLineGroupService.UpdateSplitLineGroup(ctx, group); err != nil { return fmt.Errorf("upserting split line group: %w", err) } } diff --git a/openmeter/billing/worker/subscriptionsync/service/reconciler/invoiceupdater/patch.go b/openmeter/billing/worker/subscriptionsync/service/reconciler/invoiceupdater/patch.go index 66814e186a..6fb09cee31 100644 --- a/openmeter/billing/worker/subscriptionsync/service/reconciler/invoiceupdater/patch.go +++ b/openmeter/billing/worker/subscriptionsync/service/reconciler/invoiceupdater/patch.go @@ -5,6 +5,7 @@ import ( "log/slog" "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/invoicing/legacy/splitlinegroup" "github.com/openmeterio/openmeter/pkg/models" ) @@ -36,7 +37,7 @@ type PatchSplitLineGroupDelete struct { } type PatchSplitLineGroupUpdate struct { - TargetState billing.SplitLineGroupUpdate + TargetState splitlinegroup.SplitLineGroupUpdate } type Patch struct { @@ -122,7 +123,7 @@ func NewDeleteSplitLineGroupPatch(groupID models.NamespacedID) Patch { } } -func NewUpdateSplitLineGroupPatch(group billing.SplitLineGroupUpdate) Patch { +func NewUpdateSplitLineGroupPatch(group splitlinegroup.SplitLineGroupUpdate) Patch { return Patch{ op: PatchOpSplitLineGroupUpdate, updateSplitLineGroupPatch: PatchSplitLineGroupUpdate{ @@ -156,58 +157,3 @@ func (p Patch) Log(logger *slog.Logger) { logger.Info("unknown patch operation", "operation", p.op) } } - -func GetDeletePatchesForLine(lineOrHierarchy billing.LineOrHierarchy) ([]Patch, error) { - switch lineOrHierarchy.Type() { - case billing.LineOrHierarchyTypeLine: - line, err := lineOrHierarchy.AsGenericLine() - if err != nil { - return nil, fmt.Errorf("getting line: %w", err) - } - - if line.GetAnnotations().GetBool(billing.AnnotationSubscriptionSyncIgnore) { - return nil, nil - } - - if line.GetDeletedAt() != nil { - return nil, nil - } - - return []Patch{ - NewDeleteLinePatch(line.GetLineID(), line.GetInvoiceID()), - }, nil - case billing.LineOrHierarchyTypeHierarchy: - group, err := lineOrHierarchy.AsHierarchy() - if err != nil { - return nil, fmt.Errorf("getting split line hierarchy: %w", err) - } - - out := make([]Patch, 0, 1+len(group.Lines)) - - // Skip the group if any of the lines are ignored - for _, line := range group.Lines { - if line.Line.GetAnnotations().GetBool(billing.AnnotationSubscriptionSyncIgnore) { - return nil, nil - } - } - - if group.Group.DeletedAt == nil { - out = append(out, NewDeleteSplitLineGroupPatch(models.NamespacedID{ - Namespace: group.Group.Namespace, - ID: group.Group.ID, - })) - } - - for _, line := range group.Lines { - if line.Line.GetDeletedAt() != nil { - continue - } - - out = append(out, NewDeleteLinePatch(line.Line.GetLineID(), line.Invoice.GetID())) - } - - return out, nil - } - - return nil, fmt.Errorf("unsupported line or hierarchy type: %s", lineOrHierarchy.Type()) -} diff --git a/openmeter/billing/worker/subscriptionsync/service/reconciler/patchhelpers.go b/openmeter/billing/worker/subscriptionsync/service/reconciler/patchhelpers.go index 6fa6496bcd..45e88cf7f6 100644 --- a/openmeter/billing/worker/subscriptionsync/service/reconciler/patchhelpers.go +++ b/openmeter/billing/worker/subscriptionsync/service/reconciler/patchhelpers.go @@ -6,6 +6,7 @@ import ( "github.com/samber/lo" "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/invoicing/legacy/splitlinegroup" "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/service/persistedstate" "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/service/reconciler/invoiceupdater" "github.com/openmeterio/openmeter/openmeter/streaming" @@ -28,13 +29,13 @@ func shouldSkipLinePatch(existingLine billing.GenericInvoiceLine, expectedLine b return false } -func shouldSkipHierarchyPatch(existingHierarchy *billing.SplitLineHierarchy, expectedLine billing.GatheringLine) bool { +func shouldSkipHierarchyPatch(existingHierarchy *splitlinegroup.SplitLineHierarchy, expectedLine billing.GatheringLine) bool { if expectedLine.Annotations.GetBool(billing.AnnotationSubscriptionSyncIgnore) { return true } - for _, line := range existingHierarchy.Lines { - if line.Line.GetAnnotations().GetBool(billing.AnnotationSubscriptionSyncIgnore) { + for _, line := range existingHierarchy.Lines() { + if line.GetAnnotations().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 1e0087b8e7..b47249327c 100644 --- a/openmeter/billing/worker/subscriptionsync/service/reconciler/patchinvoicelinehierarchy.go +++ b/openmeter/billing/worker/subscriptionsync/service/reconciler/patchinvoicelinehierarchy.go @@ -38,10 +38,13 @@ func (c *lineHierarchyPatchCollection) AddDelete(uniqueID string, existing persi if err != nil { return err } - patches := make([]invoiceupdater.Patch, 0, 1+len(group.Lines)) - for _, line := range group.Lines { - if line.Line.GetAnnotations().GetBool(billing.AnnotationSubscriptionSyncIgnore) { + lines := group.Lines() + + patches := make([]invoiceupdater.Patch, 0, 1+len(lines)) + + for _, line := range lines { + if line.GetAnnotations().GetBool(billing.AnnotationSubscriptionSyncIgnore) { return nil } } @@ -50,12 +53,12 @@ func (c *lineHierarchyPatchCollection) AddDelete(uniqueID string, existing persi patches = append(patches, invoiceupdater.NewDeleteSplitLineGroupPatch(group.Group.NamespacedID)) } - for _, line := range group.Lines { - if line.Line.GetDeletedAt() != nil { + for _, line := range lines { + if line.GetDeletedAt() != nil { continue } - patches = append(patches, invoiceupdater.NewDeleteLinePatch(line.Line.GetLineID(), line.Invoice.GetID())) + patches = append(patches, invoiceupdater.NewDeleteLinePatch(line.GetID(), line.GetInvoiceID().ID)) } if len(patches) == 0 { @@ -70,6 +73,7 @@ func (c *lineHierarchyPatchCollection) AddShrink(uniqueID string, existing persi if err != nil { return err } + expectedLine, err := target.GetExpectedLineOrErr() if err != nil { return err @@ -83,9 +87,11 @@ func (c *lineHierarchyPatchCollection) AddShrink(uniqueID string, existing persi return fmt.Errorf("shrink patch requires target end before existing hierarchy end: existing=%s..%s target=%s..%s", existingHierarchy.Group.ServicePeriod.From, existingHierarchy.Group.ServicePeriod.To, expectedLine.ServicePeriod.From, expectedLine.ServicePeriod.To) } - patches := make([]invoiceupdater.Patch, 0, len(existingHierarchy.Lines)+1) + lines := existingHierarchy.Lines() + + patches := make([]invoiceupdater.Patch, 0, len(lines)+1) - for _, child := range existingHierarchy.Lines { + for _, child := range lines { if child.Line.GetServicePeriod().To.Before(expectedLine.ServicePeriod.To) { continue } diff --git a/openmeter/billing/worker/subscriptionsync/service/reconciler/reconciler.go b/openmeter/billing/worker/subscriptionsync/service/reconciler/reconciler.go index 57c8144e33..2201a756ab 100644 --- a/openmeter/billing/worker/subscriptionsync/service/reconciler/reconciler.go +++ b/openmeter/billing/worker/subscriptionsync/service/reconciler/reconciler.go @@ -12,6 +12,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/billing" "github.com/openmeterio/openmeter/openmeter/billing/charges" + billinglineengine "github.com/openmeterio/openmeter/openmeter/billing/lineengine" "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/service/persistedstate" "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/service/reconciler/invoiceupdater" "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/service/targetstate" @@ -29,6 +30,7 @@ type Reconciler interface { type Config struct { BillingService billing.Service + LegacyBillingLineEngine *billinglineengine.Engine ChargesService charges.Service EnableCreditThenInvoice bool Logger *slog.Logger @@ -40,6 +42,10 @@ func (c Config) Validate() error { return fmt.Errorf("billing service is required") } + if c.LegacyBillingLineEngine == nil { + return fmt.Errorf("legacy billing line engine is required") + } + if c.Logger == nil { return fmt.Errorf("logger is required") } @@ -70,10 +76,19 @@ func New(config Config) (*Service, error) { return nil, err } + invoiceUpdater, err := invoiceupdater.New(invoiceupdater.Config{ + BillingService: config.BillingService, + QuantitySnapshotter: config.LegacyBillingLineEngine, + Logger: config.Logger, + }) + if err != nil { + return nil, fmt.Errorf("creating invoice updater: %w", err) + } + return &Service{ billingService: config.BillingService, logger: config.Logger, - invoiceUpdater: invoiceupdater.New(config.BillingService, config.Logger), + invoiceUpdater: invoiceUpdater, chargesService: config.ChargesService, enableCreditThenInvoice: config.EnableCreditThenInvoice && config.ChargesService != nil, featureGate: config.FeatureGate, diff --git a/openmeter/billing/worker/subscriptionsync/service/service.go b/openmeter/billing/worker/subscriptionsync/service/service.go index 19240b4371..45b0c41124 100644 --- a/openmeter/billing/worker/subscriptionsync/service/service.go +++ b/openmeter/billing/worker/subscriptionsync/service/service.go @@ -10,6 +10,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/billing" "github.com/openmeterio/openmeter/openmeter/billing/charges" + billinglineengine "github.com/openmeterio/openmeter/openmeter/billing/lineengine" "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync" "github.com/openmeterio/openmeter/openmeter/billing/worker/subscriptionsync/service/reconciler" "github.com/openmeterio/openmeter/openmeter/subscription" @@ -26,7 +27,8 @@ type FeatureFlags struct { } type Config struct { - BillingService billing.Service + BillingService billing.Service + LegacyBillingLineEngine *billinglineengine.Engine // ChargesService is required for credit-only sync and charge-based provisioning. ChargesService charges.Service SubscriptionService subscription.Service @@ -43,6 +45,10 @@ func (c Config) Validate() error { return fmt.Errorf("billing service is required") } + if c.LegacyBillingLineEngine == nil { + return fmt.Errorf("legacy billing line engine is required") + } + if c.SubscriptionService == nil { return fmt.Errorf("subscription service is required") } @@ -86,6 +92,7 @@ func New(config Config) (*Service, error) { } reconcilerSvc, err := reconciler.New(reconciler.Config{ BillingService: config.BillingService, + LegacyBillingLineEngine: config.LegacyBillingLineEngine, ChargesService: config.ChargesService, EnableCreditThenInvoice: config.FeatureFlags.EnableCreditThenInvoice, Logger: config.Logger, diff --git a/openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go b/openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go index c8bfa5aa66..281cd11f9c 100644 --- a/openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go +++ b/openmeter/billing/worker/subscriptionsync/service/sync_credittheninvoice_test.go @@ -130,6 +130,7 @@ func (s *CreditThenInvoiceTestSuite) SetupSuite() { service, err := New(Config{ BillingService: s.BillingService, + LegacyBillingLineEngine: s.LegacyBillingLineEngine, ChargesService: s.Charges, Logger: s.Service.logger, Tracer: s.Service.tracer, diff --git a/openmeter/server/server_test.go b/openmeter/server/server_test.go index 2438a81961..058dee4403 100644 --- a/openmeter/server/server_test.go +++ b/openmeter/server/server_test.go @@ -1860,12 +1860,12 @@ func (n NoopBillingService) OnUnsupportedCreditNote(ctx context.Context, input b return nil } -func (n NoopBillingService) GetLinesForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) ([]billing.LineOrHierarchy, error) { - return []billing.LineOrHierarchy{}, nil +func (n NoopBillingService) GetStandardLinesForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) (billing.StandardLines, error) { + return billing.StandardLines{}, nil } -func (n NoopBillingService) SnapshotLineQuantity(ctx context.Context, input billing.SnapshotLineQuantityInput) (*billing.StandardLine, error) { - return &billing.StandardLine{}, nil +func (n NoopBillingService) GetGatheringLinesForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) (billing.GatheringLines, error) { + return billing.GatheringLines{}, nil } // InvoiceSplitLineGroupService methods @@ -1877,6 +1877,10 @@ func (n NoopBillingService) UpdateSplitLineGroup(ctx context.Context, input bill return billing.SplitLineGroup{}, nil } +func (n NoopBillingService) GetSplitLineGroupsForSubscription(ctx context.Context, input billing.GetLinesForSubscriptionInput) ([]billing.SplitLineHierarchy, error) { + return []billing.SplitLineHierarchy{}, nil +} + func (n NoopBillingService) GetSplitLineGroup(ctx context.Context, input billing.GetSplitLineGroupInput) (billing.SplitLineHierarchy, error) { return billing.SplitLineHierarchy{}, nil } diff --git a/test/app/testenv.go b/test/app/testenv.go index 8f50fb17a1..0efaa38224 100644 --- a/test/app/testenv.go +++ b/test/app/testenv.go @@ -18,6 +18,7 @@ import ( appstripeservice "github.com/openmeterio/openmeter/openmeter/app/stripe/service" "github.com/openmeterio/openmeter/openmeter/billing" billingadapter "github.com/openmeterio/openmeter/openmeter/billing/adapter" + billinglineengine "github.com/openmeterio/openmeter/openmeter/billing/lineengine" billingratingservice "github.com/openmeterio/openmeter/openmeter/billing/rating/service" billingsequenceadapter "github.com/openmeterio/openmeter/openmeter/billing/sequence/adapter" billingsequenceservice "github.com/openmeterio/openmeter/openmeter/billing/sequence/service" @@ -280,20 +281,27 @@ func InitBillingService(t *testing.T, ctx context.Context, in InitBillingService // identically). Lines that carry a unit_config are exercised by the dedicated // unit_config suites. billingRatingService := billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: true}) - - return billingservice.New(billingservice.Config{ - Adapter: billingAdapter, - SequenceService: billingSequenceService, + legacyBillingLineEngine, err := billinglineengine.New(billinglineengine.Config{ + SplitLineGroupAdapter: billingAdapter, RatingService: billingRatingService, - CustomerService: in.CustomerService, - AppService: in.AppService, - Logger: slog.Default(), FeatureService: featureService, - MeterService: meterAdapter, StreamingConnector: mockStreamingConnector, - Publisher: eventbus.NewMock(t), - AdvancementStrategy: billing.ForegroundAdvancementStrategy, MaxParallelQuantitySnapshots: 2, - TaxCodeService: taxCodeService, + }) + require.NoError(t, err) + + return billingservice.New(billingservice.Config{ + Adapter: billingAdapter, + SequenceService: billingSequenceService, + RatingService: billingRatingService, + LegacyBillingLineEngine: legacyBillingLineEngine, + CustomerService: in.CustomerService, + AppService: in.AppService, + Logger: slog.Default(), + FeatureService: featureService, + MeterService: meterAdapter, + Publisher: eventbus.NewMock(t), + AdvancementStrategy: billing.ForegroundAdvancementStrategy, + TaxCodeService: taxCodeService, }) } diff --git a/test/billing/subscription_test.go b/test/billing/subscription_test.go index 26b23bb878..a7f12ef8a6 100644 --- a/test/billing/subscription_test.go +++ b/test/billing/subscription_test.go @@ -50,6 +50,7 @@ func (s *SubscriptionTestSuite) SetupSuite() { service, err := subscriptionsyncservice.New(subscriptionsyncservice.Config{ BillingService: s.BillingService, + LegacyBillingLineEngine: s.LegacyBillingLineEngine, Logger: slog.Default(), Tracer: noop.NewTracerProvider().Tracer("test"), SubscriptionSyncAdapter: subscriptionSyncAdapter, diff --git a/test/billing/suite.go b/test/billing/suite.go index 60cdf2aff1..c6b22ab148 100644 --- a/test/billing/suite.go +++ b/test/billing/suite.go @@ -30,6 +30,7 @@ import ( appservice "github.com/openmeterio/openmeter/openmeter/app/service" "github.com/openmeterio/openmeter/openmeter/billing" billingadapter "github.com/openmeterio/openmeter/openmeter/billing/adapter" + billinglineengine "github.com/openmeterio/openmeter/openmeter/billing/lineengine" "github.com/openmeterio/openmeter/openmeter/billing/models/totals" billingratingservice "github.com/openmeterio/openmeter/openmeter/billing/rating/service" billingsequence "github.com/openmeterio/openmeter/openmeter/billing/sequence" @@ -71,10 +72,11 @@ type BaseSuite struct { TestDB *testutils.TestDB DBClient *db.Client - BillingAdapter billing.Adapter - BillingService billing.Service - SequenceService billingsequence.Service - InvoiceCalculator *invoicecalc.MockableInvoiceCalculator + BillingAdapter billing.Adapter + BillingService billing.Service + LegacyBillingLineEngine *billinglineengine.Engine + SequenceService billingsequence.Service + InvoiceCalculator *invoicecalc.MockableInvoiceCalculator FeatureService feature.FeatureConnector FeatureRepo feature.FeatureRepo @@ -236,20 +238,30 @@ func (s *BaseSuite) setupSuite() { require.NoError(t, err) s.SequenceService = billingSequenceService - billingService, err := billingservice.New(billingservice.Config{ - Adapter: billingAdapter, - SequenceService: billingSequenceService, - RatingService: billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: true}), - CustomerService: s.CustomerService, - AppService: s.AppService, - Logger: slog.Default(), + billingRatingService := billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: true}) + legacyBillingLineEngine, err := billinglineengine.New(billinglineengine.Config{ + SplitLineGroupAdapter: billingAdapter, + RatingService: billingRatingService, FeatureService: s.FeatureService, - MeterService: s.MeterAdapter, StreamingConnector: s.MockStreamingConnector, - Publisher: publisher, - AdvancementStrategy: billing.ForegroundAdvancementStrategy, MaxParallelQuantitySnapshots: 2, - TaxCodeService: taxCodeService, + }) + require.NoError(t, err) + s.LegacyBillingLineEngine = legacyBillingLineEngine + + billingService, err := billingservice.New(billingservice.Config{ + Adapter: billingAdapter, + SequenceService: billingSequenceService, + RatingService: billingRatingService, + LegacyBillingLineEngine: legacyBillingLineEngine, + CustomerService: s.CustomerService, + AppService: s.AppService, + Logger: slog.Default(), + FeatureService: s.FeatureService, + MeterService: s.MeterAdapter, + Publisher: publisher, + AdvancementStrategy: billing.ForegroundAdvancementStrategy, + TaxCodeService: taxCodeService, }) require.NoError(t, err) diff --git a/test/customer/testenv.go b/test/customer/testenv.go index c422fd4fe3..d4f17912a2 100644 --- a/test/customer/testenv.go +++ b/test/customer/testenv.go @@ -18,6 +18,7 @@ import ( appservice "github.com/openmeterio/openmeter/openmeter/app/service" "github.com/openmeterio/openmeter/openmeter/billing" billingadapter "github.com/openmeterio/openmeter/openmeter/billing/adapter" + billinglineengine "github.com/openmeterio/openmeter/openmeter/billing/lineengine" billingratingservice "github.com/openmeterio/openmeter/openmeter/billing/rating/service" billingsequenceadapter "github.com/openmeterio/openmeter/openmeter/billing/sequence/adapter" billingsequenceservice "github.com/openmeterio/openmeter/openmeter/billing/sequence/service" @@ -424,20 +425,31 @@ func NewTestEnv(t *testing.T, ctx context.Context) (TestEnv, error) { return nil, fmt.Errorf("failed to create billing sequence service: %w", err) } - billingService, err := billingservice.New(billingservice.Config{ - Adapter: billingAdapter, - SequenceService: billingSequenceService, - RatingService: billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: true}), - CustomerService: customerService, - AppService: appService, - Logger: logger.WithGroup("billing"), + billingRatingService := billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: true}) + legacyBillingLineEngine, err := billinglineengine.New(billinglineengine.Config{ + SplitLineGroupAdapter: billingAdapter, + RatingService: billingRatingService, FeatureService: entitlementRegistry.Feature, - MeterService: meterService, StreamingConnector: streamingConnector, - Publisher: publisher, - AdvancementStrategy: billing.ForegroundAdvancementStrategy, MaxParallelQuantitySnapshots: 2, - TaxCodeService: taxCodeService, + }) + if err != nil { + return nil, fmt.Errorf("failed to create invoice line engine: %w", err) + } + + billingService, err := billingservice.New(billingservice.Config{ + Adapter: billingAdapter, + SequenceService: billingSequenceService, + RatingService: billingRatingService, + LegacyBillingLineEngine: legacyBillingLineEngine, + CustomerService: customerService, + AppService: appService, + Logger: logger.WithGroup("billing"), + FeatureService: entitlementRegistry.Feature, + MeterService: meterService, + Publisher: publisher, + AdvancementStrategy: billing.ForegroundAdvancementStrategy, + TaxCodeService: taxCodeService, }) if err != nil { return nil, fmt.Errorf("failed to create billing service: %w", err) diff --git a/test/subscription/framework_test.go b/test/subscription/framework_test.go index c701d37f55..a99279bcec 100644 --- a/test/subscription/framework_test.go +++ b/test/subscription/framework_test.go @@ -16,6 +16,7 @@ import ( appservice "github.com/openmeterio/openmeter/openmeter/app/service" "github.com/openmeterio/openmeter/openmeter/billing" billingadapter "github.com/openmeterio/openmeter/openmeter/billing/adapter" + billinglineengine "github.com/openmeterio/openmeter/openmeter/billing/lineengine" billingratingservice "github.com/openmeterio/openmeter/openmeter/billing/rating/service" billingsequenceadapter "github.com/openmeterio/openmeter/openmeter/billing/sequence/adapter" billingsequenceservice "github.com/openmeterio/openmeter/openmeter/billing/sequence/service" @@ -112,20 +113,29 @@ func setup(t *testing.T, _ setupConfig) testDeps { }) require.NoError(t, err) - billingService, err := billingservice.New(billingservice.Config{ - Adapter: billingAdapter, - SequenceService: billingSequenceService, - RatingService: billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: true}), - CustomerService: deps.CustomerService, - AppService: appService, - Logger: slog.Default(), + billingRatingService := billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: true}) + legacyBillingLineEngine, err := billinglineengine.New(billinglineengine.Config{ + SplitLineGroupAdapter: billingAdapter, + RatingService: billingRatingService, FeatureService: deps.FeatureConnector, - MeterService: deps.MeterService, StreamingConnector: deps.MockStreamingConnector, - Publisher: publisher, - AdvancementStrategy: billing.ForegroundAdvancementStrategy, MaxParallelQuantitySnapshots: 2, - TaxCodeService: taxCodeService, + }) + require.NoError(t, err) + + billingService, err := billingservice.New(billingservice.Config{ + Adapter: billingAdapter, + SequenceService: billingSequenceService, + RatingService: billingRatingService, + LegacyBillingLineEngine: legacyBillingLineEngine, + CustomerService: deps.CustomerService, + AppService: appService, + Logger: slog.Default(), + FeatureService: deps.FeatureConnector, + MeterService: deps.MeterService, + Publisher: publisher, + AdvancementStrategy: billing.ForegroundAdvancementStrategy, + TaxCodeService: taxCodeService, }) require.NoError(t, err) @@ -140,6 +150,7 @@ func setup(t *testing.T, _ setupConfig) testDeps { subscriptionSyncService, err := subscriptionsyncservice.New(subscriptionsyncservice.Config{ BillingService: billingService, + LegacyBillingLineEngine: legacyBillingLineEngine, Logger: slog.Default(), Tracer: noop.NewTracerProvider().Tracer("test"), SubscriptionSyncAdapter: subscriptionSyncAdapter,