Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions openmeter/billing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,6 @@ context. Persisted engine operations resolve complete history in the caller's
transaction so materialization, gathering previews, collection, and mutable
updates account for previously billed amounts consistently. Snapshot-only
immutable comparisons use the group's period without rating historical amounts.
Simulation supplies transient context by split-group ID within its namespace,
independently of generated line IDs, without reading persisted groups.
Calculation never replaces pending line edits with historical children.

Rating returns amounts before credit application. Legacy invoice lines do not
Expand Down Expand Up @@ -232,6 +230,9 @@ reject the whole edit, including mixed-engine writes. Gathering edits and
invoice-deletion cleanup keep their rejection semantics for every callback error.
Simulation validates feature/meter references and explicitly rates supplied
legacy quantities on clones without persisting the transient result.
Simulation inputs with split-group IDs resolve persisted history through the
legacy engine while retaining the supplied quantities and pending line edits.
Missing groups fail without falling back to ordinary rating.
Charge simulation is unsupported; supplied charge projections are not rerated.

`ValidationIssueRecorder.Record` collects validation issues of any severity;
Expand Down
35 changes: 35 additions & 0 deletions openmeter/billing/httpdriver/invoiceline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,41 @@ import (
"github.com/openmeterio/openmeter/pkg/timeutil"
)

func TestMapSimulationLinePreservesPriorUsageAndSuppliedIdentity(t *testing.T) {
// Given ordinary HTTP simulation input with a prior-usage offset and supplied ID.
price := api.RateCardUsageBasedPrice{}
require.NoError(t, price.FromUnitPriceWithCommitments(api.UnitPriceWithCommitments{Amount: "1"}))
period := invoiceLineTestPeriod()
input := api.InvoiceSimulationLine{
Id: lo.ToPtr("supplied-line-id"),
Name: "simulated usage",
Period: api.Period(period),
InvoiceAt: period.To,
Quantity: "20",
PreLinePeriodQuantity: lo.ToPtr("40"),
RateCard: &api.InvoiceUsageBasedRateCard{
Price: &price,
FeatureKey: lo.ToPtr("api-requests"),
},
}

// When the HTTP mapper builds the supplied snapshot.
line, err := mapSimulationLineToEntity(input)
require.NoError(t, err)

// Then raw and displayed quantities survive without implying a persisted group.
require.Equal(t, "supplied-line-id", line.ID)
require.Equal(t, 20.0, line.UsageBased.MeteredQuantity.InexactFloat64())
require.Equal(t, 20.0, line.UsageBased.Quantity.InexactFloat64())
require.Equal(t, 40.0, line.UsageBased.MeteredPreLinePeriodQuantity.InexactFloat64())
require.Equal(t, 40.0, line.UsageBased.PreLinePeriodQuantity.InexactFloat64())
require.Nil(t, line.SplitLineGroupID)

input.PreLinePeriodQuantity = lo.ToPtr("invalid")
_, err = mapSimulationLineToEntity(input)
require.ErrorContains(t, err, "failed to parse preLinePeriodQuantity")
}

func TestMergeStandardLineFromInvoiceLineReplaceUpdateOverwritesTaxConfig(t *testing.T) {
price := api.RateCardUsageBasedPrice{}
require.NoError(t, price.FromUnitPriceWithCommitments(api.UnitPriceWithCommitments{
Expand Down
33 changes: 18 additions & 15 deletions openmeter/billing/lineengine/rating.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,17 @@ func (e *Engine) RecalculateStandardLine(ctx context.Context, input RecalculateS
return lines[0], err
}

type RateStandardLinesWithSplitLineHierarchiesInput struct {
Lines billing.StandardLines
Hierarchies map[billing.LineID]*billing.SplitLineHierarchy
type RateStandardLinesInput struct {
Namespace string
Lines billing.StandardLines
}

func (i RateStandardLinesWithSplitLineHierarchiesInput) Validate() error {
func (i RateStandardLinesInput) Validate() error {
var errs []error
if i.Namespace == "" {
errs = append(errs, errors.New("namespace is required"))
}

if len(i.Lines) == 0 {
errs = append(errs, errors.New("lines are required"))
}
Expand All @@ -91,22 +95,21 @@ func (i RateStandardLinesWithSplitLineHierarchiesInput) Validate() error {
return models.NewNillableGenericValidationError(errors.Join(errs...))
}

// RateStandardLinesWithSplitLineHierarchies rates transient snapshots with supplied
// read-only history keyed by namespaced line ID. It performs no group loading,
// snapshotting or persistence and preserves calculated lines alongside rating issues.
func (e *Engine) RateStandardLinesWithSplitLineHierarchies(input RateStandardLinesWithSplitLineHierarchiesInput) (billing.StandardLines, error) {
// RateStandardLines rates supplied snapshots on clones, resolving persisted
// history for split-group IDs. Supplied quantities, including prior-period usage
// offsets, remain authoritative; it performs no snapshotting or persistence and
// preserves calculated lines alongside rating issues.
func (e *Engine) RateStandardLines(ctx context.Context, input RateStandardLinesInput) (billing.StandardLines, error) {
if err := input.Validate(); err != nil {
return nil, err
}

return e.rateStandardLines(lo.Map(input.Lines, func(line *billing.StandardLine, _ int) lineWithHierarchy {
var hierarchy *billing.SplitLineHierarchy
if line.SplitLineGroupID != nil {
hierarchy = input.Hierarchies[line.GetLineID()]
}
resolvedLines, err := e.resolveSplitLineHierarchies(ctx, input.Namespace, input.Lines)
if err != nil {
return nil, fmt.Errorf("resolving standard line hierarchies: %w", err)
}

return lineWithHierarchy{StandardLine: line, hierarchy: hierarchy}
}))
return e.rateStandardLines(resolvedLines)
}

func (e *Engine) rateStandardLines(lines linesWithHierarchy) (billing.StandardLines, error) {
Expand Down
87 changes: 35 additions & 52 deletions openmeter/billing/lineengine/rating_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,10 @@ func TestRateStandardLinesUsesMeteredUsageAndPreservesIdentities(t *testing.T) {
require.NoError(t, err)

// When the supplied snapshots are rated without an invoice or persistence dependency.
lines, err := engine.RateStandardLinesWithSplitLineHierarchies(RateStandardLinesWithSplitLineHierarchiesInput{Lines: input})
lines, err := engine.RateStandardLines(t.Context(), RateStandardLinesInput{
Namespace: line.Namespace,
Lines: input,
})
require.NoError(t, err)

// Then normalization and rating affect only the result, using raw rather than net usage.
Expand Down Expand Up @@ -240,7 +243,10 @@ func TestRateStandardLinesUsesMeteredUsageAndPreservesIdentities(t *testing.T) {
require.NoError(t, err)

// When the edited state is rated again.
rerated, err := engine.RateStandardLinesWithSplitLineHierarchies(RateStandardLinesWithSplitLineHierarchiesInput{Lines: input})
rerated, err := engine.RateStandardLines(t.Context(), RateStandardLinesInput{
Namespace: line.Namespace,
Lines: input,
})
require.NoError(t, err)

// Then discounts are applied once and matching persisted identities survive.
Expand All @@ -263,17 +269,19 @@ func TestRateStandardLinesUsesMeteredUsageAndPreservesIdentities(t *testing.T) {
require.True(t, detail.AmountDiscounts[0].UpdatedAt.IsZero()) // The adapter owns the update timestamp.
require.Equal(t, 6.25, detail.AmountDiscounts[0].Amount.InexactFloat64())

repeated, err := engine.RateStandardLinesWithSplitLineHierarchies(RateStandardLinesWithSplitLineHierarchiesInput{Lines: rerated})
repeated, err := engine.RateStandardLines(t.Context(), RateStandardLinesInput{
Namespace: line.Namespace,
Lines: rerated,
})
require.NoError(t, err)
require.Equal(t, rerated, repeated)
}

func TestRateStandardLinesFailuresLeaveTheWholeInputUnchanged(t *testing.T) {
for _, tc := range []struct {
name string
invalidate func(*billing.StandardLine)
errorText string
hierarchies map[billing.LineID]*billing.SplitLineHierarchy
name string
invalidate func(*billing.StandardLine)
errorText string
}{
{
name: "rating failure",
Expand All @@ -289,18 +297,6 @@ func TestRateStandardLinesFailuresLeaveTheWholeInputUnchanged(t *testing.T) {
},
errorText: "split line hierarchy is required",
},
{
name: "history from another namespace",
invalidate: func(line *billing.StandardLine) {
line.SplitLineGroupID = lo.ToPtr("missing-group")
},
hierarchies: map[billing.LineID]*billing.SplitLineHierarchy{
{Namespace: "another-namespace", ID: "second-line"}: {
Group: billing.SplitLineGroup{SplitLineGroupMutableFields: billing.SplitLineGroupMutableFields{ServicePeriod: lineEngineOverrideTestPeriod()}},
},
},
errorText: "split line hierarchy is required",
},
{
name: "mapping failure",
invalidate: func(line *billing.StandardLine) {
Expand Down Expand Up @@ -335,10 +331,9 @@ func TestRateStandardLinesFailuresLeaveTheWholeInputUnchanged(t *testing.T) {

// When rating the batch fails on the second line.
engine := &Engine{ratingService: ratingservice.New(ratingservice.Config{})}
out, err := engine.RateStandardLinesWithSplitLineHierarchies(RateStandardLinesWithSplitLineHierarchiesInput{
Lines: lines,
Hierarchies: tc.hierarchies,
})
out, err := engine.rateStandardLines(lo.Map(lines, func(line *billing.StandardLine, _ int) lineWithHierarchy {
return lineWithHierarchy{StandardLine: line}
}))

// Then neither normalization nor earlier successful rating leaks into the input.
require.ErrorContains(t, err, tc.errorText)
Expand Down Expand Up @@ -455,18 +450,14 @@ func TestRateStandardLinesProgressiveCommitments(t *testing.T) {
hierarchy.Lines = append(hierarchy.Lines, child)
}

hierarchies := map[billing.LineID]*billing.SplitLineHierarchy{line.GetLineID(): hierarchy}
historyBefore, err := json.Marshal(hierarchy)
require.NoError(t, err)
before, err := json.Marshal(line)
require.NoError(t, err)
engine := &Engine{ratingService: ratingservice.New(ratingservice.Config{})}

// When supplied history rates the current snapshot.
lines, err := engine.RateStandardLinesWithSplitLineHierarchies(RateStandardLinesWithSplitLineHierarchiesInput{
Lines: billing.StandardLines{line},
Hierarchies: hierarchies,
})
lines, err := engine.rateStandardLines(linesWithHierarchy{{StandardLine: line, hierarchy: hierarchy}})
require.NoError(t, err)

// Then deleted history and later periods do not contribute to commitments.
Expand All @@ -488,10 +479,7 @@ func TestRateStandardLinesProgressiveCommitments(t *testing.T) {
rated.DetailedLines[0].AmountDiscounts[0].ID = "max-spend-discount-id"
}

rerated, err := engine.RateStandardLinesWithSplitLineHierarchies(RateStandardLinesWithSplitLineHierarchiesInput{
Lines: lines,
Hierarchies: hierarchies,
})
rerated, err := engine.rateStandardLines(linesWithHierarchy{{StandardLine: rated, hierarchy: hierarchy}})
require.NoError(t, err)
require.Equal(t, rated.Totals, rerated[0].Totals)
require.Len(t, rerated[0].DetailedLines, len(rated.DetailedLines))
Expand All @@ -511,29 +499,17 @@ func TestRateStandardLinesProgressiveCommitments(t *testing.T) {
require.NoError(t, err)
require.JSONEq(t, string(historyBefore), string(historyAfter))

// Ordinary rating has no history. Header-only progressive context also
// retains the existing zero-history behavior.
// Ordinary rating has no prior monetary history.
ordinaryLine, err := line.Clone()
require.NoError(t, err)
ordinaryLine.SplitLineGroupID = nil
ordinaryLines, err := engine.RateStandardLinesWithSplitLineHierarchies(RateStandardLinesWithSplitLineHierarchiesInput{Lines: billing.StandardLines{ordinaryLine}})
ordinaryLines, err := engine.RateStandardLines(t.Context(), RateStandardLinesInput{
Namespace: ordinaryLine.Namespace,
Lines: billing.StandardLines{ordinaryLine},
})
require.NoError(t, err)
require.Equal(t, tc.ordinaryCharges, ordinaryLines[0].Totals.ChargesTotal.InexactFloat64())
require.Equal(t, tc.ordinaryTotal, ordinaryLines[0].Totals.Total.InexactFloat64())

headerOnlyLine, err := line.Clone()
require.NoError(t, err)
headerOnlyLine.ID = "header-only-line"
mixedHistoryLines, err := engine.RateStandardLinesWithSplitLineHierarchies(RateStandardLinesWithSplitLineHierarchiesInput{
Lines: billing.StandardLines{line, headerOnlyLine},
Hierarchies: map[billing.LineID]*billing.SplitLineHierarchy{
line.GetLineID(): hierarchy,
headerOnlyLine.GetLineID(): {Group: hierarchy.Group},
},
})
require.NoError(t, err)
require.Equal(t, rated.Totals, mixedHistoryLines[0].Totals)
require.Equal(t, ordinaryLines[0].Totals, mixedHistoryLines[1].Totals)
})
}
}
Expand All @@ -550,7 +526,10 @@ func TestRateStandardLinesPreservesCalculatedResultsWithCriticalIssues(t *testin
ratingService.On("GenerateLegacyProgressiveBilledLines", mock.Anything, mock.Anything).
Return(result, billing.ErrInvoiceLineVolumeSplitNotSupported).Once()
engine := &Engine{ratingService: ratingService}
lines, err := engine.RateStandardLinesWithSplitLineHierarchies(RateStandardLinesWithSplitLineHierarchiesInput{Lines: billing.StandardLines{line}})
lines, err := engine.RateStandardLines(t.Context(), RateStandardLinesInput{
Namespace: line.Namespace,
Lines: billing.StandardLines{line},
})

// Then calculated state is returned with the critical issue and line attribution.
issues, systemErr := billing.ToValidationIssues(err)
Expand Down Expand Up @@ -584,7 +563,10 @@ func TestRateStandardLinesPreservesResultsWithValidationWarnings(t *testing.T) {
)).Once()
engine := &Engine{ratingService: ratingService}

lines, err := engine.RateStandardLinesWithSplitLineHierarchies(RateStandardLinesWithSplitLineHierarchiesInput{Lines: billing.StandardLines{line}})
lines, err := engine.RateStandardLines(t.Context(), RateStandardLinesInput{
Namespace: line.Namespace,
Lines: billing.StandardLines{line},
})

issues, systemErr := billing.ToValidationIssues(err)
require.NoError(t, systemErr)
Expand Down Expand Up @@ -614,7 +596,8 @@ func TestRateStandardLinesRejectsSystemErrors(t *testing.T) {
Return(rating.GenerateDetailedLinesResult{}, ratingErr).Once()
engine := &Engine{ratingService: ratingService}

lines, err := engine.RateStandardLinesWithSplitLineHierarchies(RateStandardLinesWithSplitLineHierarchiesInput{
lines, err := engine.RateStandardLines(t.Context(), RateStandardLinesInput{
Namespace: "namespace",
Lines: billing.StandardLines{
standardLineForLineEngineOverrideTest(t, lineEngineOverrideTestPeriod()),
},
Expand Down
17 changes: 3 additions & 14 deletions openmeter/billing/service/invoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -933,20 +933,9 @@ func (s Service) SimulateInvoice(ctx context.Context, input billing.SimulateInvo
continue
}

// Simulation uses only supplied transient history, without resolving persisted groups.
splitLineGroups := lo.FilterSliceToMap(grouped.Lines, func(line *billing.StandardLine) (billing.LineID, *billing.SplitLineHierarchy, bool) {
if line.SplitLineGroupID == nil {
return billing.LineID{}, nil, false
}

hierarchy := input.SplitLineHierarchies[*line.SplitLineGroupID]

return line.GetLineID(), hierarchy, hierarchy != nil
})

ratedLines, ratingErr := legacyEngine.RateStandardLinesWithSplitLineHierarchies(lineengine.RateStandardLinesWithSplitLineHierarchiesInput{
Lines: grouped.Lines,
Hierarchies: splitLineGroups,
ratedLines, ratingErr := legacyEngine.RateStandardLines(ctx, lineengine.RateStandardLinesInput{
Namespace: input.Namespace,
Lines: grouped.Lines,
})
if err := invoice.MergeValidationIssues(billing.NewLineEngineValidationError(legacyEngine, ratingErr), billing.LineEngineValidationComponent(legacyEngine.GetLineEngineType())); err != nil {
return billing.StandardInvoice{}, fmt.Errorf("rating simulation lines: %w", err)
Expand Down
4 changes: 0 additions & 4 deletions openmeter/billing/stdinvoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -977,10 +977,6 @@ type SimulateInvoiceInput struct {
Number *string
Currency currencyx.FiatCode
Lines StandardInvoiceLines

// SplitLineHierarchies supplies read-only transient history keyed by split-group
// ID within Namespace. Association survives generated line IDs and normalization.
SplitLineHierarchies map[string]*SplitLineHierarchy
}

func (i SimulateInvoiceInput) Validate() error {
Expand Down
Loading
Loading