Skip to content
Open
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
6 changes: 4 additions & 2 deletions app/common/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,8 +242,10 @@ func NewBillingCustomerOverrideService(billingRegistry BillingRegistry) billing.
return billingRegistry.Billing
}

func NewBillingRatingService() rating.Service {
return billingratingservice.New()
func NewBillingRatingService(unitConfig config.UnitConfigConfiguration) rating.Service {
return billingratingservice.New(billingratingservice.Config{
UnitConfigEnabled: unitConfig.Enabled,
})
}

func NewBillingAutoAdvancer(logger *slog.Logger, billingRegistry BillingRegistry) (*billingworkerautoadvance.AutoAdvancer, error) {
Expand Down
2 changes: 2 additions & 0 deletions app/common/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ var Config = wire.NewSet(
wire.FieldsOf(new(config.Configuration), "ReservedEventTypes"),
// Svix
wire.FieldsOf(new(config.Configuration), "Svix"),
// UnitConfig
wire.FieldsOf(new(config.Configuration), "UnitConfig"),
// Telemetry
wire.FieldsOf(new(config.Configuration), "Telemetry"),
wire.FieldsOf(new(config.TelemetryConfig), "Metrics"),
Expand Down
3 changes: 2 additions & 1 deletion cmd/billing-worker/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion cmd/jobs/internal/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion cmd/server/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 10 additions & 3 deletions openmeter/billing/charges/service/base_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ const USD = currencyx.Code(currency.USD)
type BaseSuite struct {
billingtest.BaseSuite

// UnitConfigEnabled toggles the unitConfig.enabled rating flag for the charges
// stack the suite builds. Defaults to false; a derived suite sets it in its own
// SetupSuite (before calling BaseSuite.SetupSuite) to exercise unit_config rating.
UnitConfigEnabled bool

Charges *service
UsageBasedService usagebased.Service
FlatFeeTestHandler *flatFeeTestHandler
Expand Down Expand Up @@ -92,7 +97,7 @@ func (s *BaseSuite) SetupSuite() {
Lineage: lineageService,
MetaAdapter: metaAdapter,
Locker: locker,
RatingService: billingratingservice.New(),
RatingService: billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: s.UnitConfigEnabled}),
})
s.NoError(err)

Expand All @@ -114,7 +119,7 @@ func (s *BaseSuite) SetupSuite() {
MetaAdapter: metaAdapter,
CustomerOverrideService: s.BillingService,
FeatureService: s.FeatureService,
RatingService: billingratingservice.New(),
RatingService: billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: s.UnitConfigEnabled}),
StreamingConnector: s.MockStreamingConnector,
})
s.NoError(err)
Expand All @@ -139,7 +144,7 @@ func (s *BaseSuite) SetupSuite() {
s.NoError(err)

creditPurchaseLineEngine, err := creditpurchaselineengine.New(creditpurchaselineengine.Config{
RatingService: billingratingservice.New(),
RatingService: billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: s.UnitConfigEnabled}),
})
s.NoError(err)

Expand Down Expand Up @@ -194,6 +199,7 @@ type createMockChargeIntentInput struct {
currency currencyx.Code
servicePeriod timeutil.ClosedPeriod
price *productcatalog.Price
unitConfig *productcatalog.UnitConfig
featureKey string
name string
settlementMode productcatalog.SettlementMode
Expand Down Expand Up @@ -283,6 +289,7 @@ func (s *BaseSuite) createMockChargeIntent(input createMockChargeIntentInput) ch
IntentMutableFields: usagebased.IntentMutableFields{
IntentMutableFields: intentMutableFields,
Price: lo.FromPtr(input.price),
UnitConfig: input.unitConfig,
InvoiceAt: invoiceAt,
},
SettlementMode: lo.CoalesceOrEmpty(input.settlementMode, productcatalog.CreditThenInvoiceSettlementMode),
Expand Down
174 changes: 174 additions & 0 deletions openmeter/billing/charges/service/unitconfig_rating_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package service

import (
"testing"
"time"

"github.com/alpacahq/alpacadecimal"
"github.com/samber/lo"
"github.com/stretchr/testify/suite"

"github.com/openmeterio/openmeter/openmeter/billing"
"github.com/openmeterio/openmeter/openmeter/billing/charges"
"github.com/openmeterio/openmeter/openmeter/billing/rating/service/mutator"
"github.com/openmeterio/openmeter/openmeter/productcatalog"
"github.com/openmeterio/openmeter/pkg/clock"
"github.com/openmeterio/openmeter/pkg/datetime"
"github.com/openmeterio/openmeter/pkg/timeutil"
billingtest "github.com/openmeterio/openmeter/test/billing"
)

// These suites drive the REAL charges path end-to-end: a usage-based charge whose
// intent carries a unit_config is created, usage is seeded, and the charge is
// invoiced — exercising rating.GenerateDetailedLines via RateableIntent.GetUnitConfig.
// The enabled/disabled split proves the unitConfig.enabled flag actually gates the
// converted amount through the production wiring (not just the in-memory mutator).

func TestUsageBasedUnitConfigRatingEnabled(t *testing.T) {
suite.Run(t, new(unitConfigRatingEnabledSuite))
}

type unitConfigRatingEnabledSuite struct {
BaseSuite
}

func (s *unitConfigRatingEnabledSuite) SetupSuite() {
s.UnitConfigEnabled = true
s.BaseSuite.SetupSuite()
}

func (s *unitConfigRatingEnabledSuite) TearDownTest() {
s.BaseSuite.TearDownTest()
}

func (s *unitConfigRatingEnabledSuite) TestRatesConvertedQuantity() {
// flag on: 7400 raw / 1000, ceiling => 8 billed units * $1 = $8.
invoices, err := s.invoiceUnitConfigChargesScenario()
s.Require().NoError(err)
s.Require().Len(invoices, 1)
s.Require().Len(invoices[0].Lines.OrEmpty(), 1)

stdLine := invoices[0].Lines.OrEmpty()[0]

// MeteredQuantity records the raw metered value and stays 7400: the unit_config
// conversion changes the priced amount (asserted below), not the recorded metered
// quantity.
//
// TODO: once the charges line-mapper converts+rounds the displayed UsageBased.Quantity,
// extend this test to also assert the converted displayed quantity.
// MeteredQuantity stays raw (7400) as the audit value.
s.Require().NotNil(stdLine.UsageBased.MeteredQuantity)
s.Equal(float64(7400), lo.FromPtr(stdLine.UsageBased.MeteredQuantity).InexactFloat64())

s.RequireTotals(billingtest.ExpectedTotals{
Amount: 8,
Total: 8,
}, stdLine.Totals)
}

func TestUsageBasedUnitConfigRatingDisabled(t *testing.T) {
suite.Run(t, new(unitConfigRatingDisabledSuite))
}

type unitConfigRatingDisabledSuite struct {
BaseSuite
}

func (s *unitConfigRatingDisabledSuite) SetupSuite() {
// UnitConfigEnabled defaults to false: the intent still carries a unit_config, so
// ForbidUnitConfig must reject rating rather than silently bill the raw quantity.
s.BaseSuite.SetupSuite()
}

func (s *unitConfigRatingDisabledSuite) TearDownTest() {
s.BaseSuite.TearDownTest()
}

func (s *unitConfigRatingDisabledSuite) TestErrorsWhenFlagOff() {
// flag off + a unit_config on the charge: ForbidUnitConfig surfaces the
// inconsistency instead of silently billing the raw quantity.
_, err := s.invoiceUnitConfigChargesScenario()
s.Require().Error(err)
s.Require().ErrorIs(err, mutator.ErrUnitConfigDisabled)
}

// invoiceUnitConfigChargesScenario creates a usage-based charge carrying a divide-by-1000
// ceiling unit_config, seeds 7400 raw units, and invoices mid-period. It returns the
// InvoicePendingLines result so each suite asserts its own outcome (converted amount
// when the flag is on, a ForbidUnitConfig error when it is off).
func (s *BaseSuite) invoiceUnitConfigChargesScenario() ([]billing.StandardInvoice, error) {
s.T().Helper()

ctx := s.T().Context()
ns := s.GetUniqueNamespace("charges-service-unit-config-rating")
s.ProvisionDefaultTaxCodes(ctx, ns)

customInvoicing := s.SetupCustomInvoicing(ns)

cust := s.CreateTestCustomer(ns, "test-subject")
s.NotEmpty(cust.ID)

_ = s.ProvisionBillingProfile(
ctx, ns, customInvoicing.App.GetID(),
billingtest.WithProgressiveBilling(),
billingtest.WithCollectionInterval(datetime.MustParseDuration(s.T(), "P2D")),
billingtest.WithManualApproval(),
)

createAt := datetime.MustParseTimeInLocation(s.T(), "2025-12-01T00:00:00Z", time.UTC).AsTime()
servicePeriod := timeutil.ClosedPeriod{
From: datetime.MustParseTimeInLocation(s.T(), "2026-01-01T00:00:00Z", time.UTC).AsTime(),
To: datetime.MustParseTimeInLocation(s.T(), "2026-02-01T00:00:00Z", time.UTC).AsTime(),
}
invoiceAt := datetime.MustParseTimeInLocation(s.T(), "2026-01-16T00:00:00Z", time.UTC).AsTime()

apiRequestsTotal := s.SetupApiRequestsTotalFeature(ctx, ns)
meterSlug := apiRequestsTotal.Feature.Key

clock.FreezeTime(createAt)
defer clock.UnFreeze()
defer s.UsageBasedTestHandler.Reset()

// Cap credit-only accrual at 0 so the full amount is invoiced (no credits).
s.UsageBasedTestHandler.onCreditsOnlyUsageAccrued, _ = newCappedCreditAllocator(0)

// Meter is in raw units, bill in thousands: divide by 1000, round up.
unitConfig := &productcatalog.UnitConfig{
Operation: productcatalog.UnitConfigOperationDivide,
ConversionFactor: alpacadecimal.NewFromInt(1000),
Rounding: productcatalog.UnitConfigRoundingModeCeiling,
}

res, err := s.Charges.Create(ctx, charges.CreateInput{
Namespace: ns,
Intents: []charges.ChargeIntent{
s.createMockChargeIntent(createMockChargeIntentInput{
customer: cust.GetID(),
currency: USD,
servicePeriod: servicePeriod,
settlementMode: productcatalog.CreditThenInvoiceSettlementMode,
price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: alpacadecimal.NewFromFloat(1)}),
unitConfig: unitConfig,
name: "usage-based-unit-config",
managedBy: billing.SubscriptionManagedLine,
uniqueReferenceID: "usage-based-unit-config",
featureKey: meterSlug,
}),
},
})
s.Require().NoError(err)
s.Require().Len(res, 1)

// 7400 raw units. Flag on: ceil(7400/1000) = 8 billed units.
s.MockStreamingConnector.AddSimpleEvent(
meterSlug,
7400,
datetime.MustParseTimeInLocation(s.T(), "2026-01-15T00:00:00Z", time.UTC).AsTime(),
)
clock.FreezeTime(invoiceAt)

return s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{
Customer: cust.GetID(),
AsOf: lo.ToPtr(invoiceAt),
})
}
6 changes: 3 additions & 3 deletions openmeter/billing/charges/testutils/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ func NewServices(t testing.TB, config Config) (*Services, error) {
Lineage: lineageService,
MetaAdapter: metaAdapter,
Locker: locker,
RatingService: billingratingservice.New(),
RatingService: billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: true}),
})
if err != nil {
return nil, fmt.Errorf("creating flat fee service: %w", err)
Expand All @@ -180,7 +180,7 @@ func NewServices(t testing.TB, config Config) (*Services, error) {
MetaAdapter: metaAdapter,
CustomerOverrideService: config.BillingService,
FeatureService: config.FeatureService,
RatingService: billingratingservice.New(),
RatingService: billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: true}),
StreamingConnector: config.StreamingConnector,
})
if err != nil {
Expand Down Expand Up @@ -211,7 +211,7 @@ func NewServices(t testing.TB, config Config) (*Services, error) {
}

creditPurchaseLineEngine, err := creditpurchaselineengine.New(creditpurchaselineengine.Config{
RatingService: billingratingservice.New(),
RatingService: billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: true}),
})
if err != nil {
return nil, fmt.Errorf("creating credit purchase line engine: %w", err)
Expand Down
8 changes: 8 additions & 0 deletions openmeter/billing/charges/usagebased/rating.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ func (r RateableIntent) GetRateCardDiscounts() billing.Discounts {
return r.Discounts.Clone()
}

func (r RateableIntent) GetUnitConfig() *productcatalog.UnitConfig {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Subscription charges drop unit_config before rating

This new rating path only sees unit_config through RateableIntent.GetUnitConfig(), but normal subscription-created usage-based charges still never populate Intent.UnitConfig: newUsageBasedChargeIntent in openmeter/billing/worker/subscriptionsync/service/reconciler/patchchargeusagebased.go copies price and discounts from rateCardMeta but leaves UnitConfig behind. With unitConfig.enabled on, a plan/subscription rate card using unit_config will still invoice raw metered quantity, while only direct charge-intent tests exercise the converted path. Please snapshot rateCardMeta.UnitConfig into the usage-based charge intent, with a subscription round-trip/invoice test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, newUsageBasedChargeIntent now snapshots rateCardMeta.UnitConfig onto the usage-based charge intent

if r.UnitConfig == nil {
return nil
}

return lo.ToPtr(r.UnitConfig.Clone())
}

func (r RateableIntent) GetStandardLineDiscounts() billing.StandardLineDiscounts {
// A charge is never associated with user defined line discounts
return billing.StandardLineDiscounts{}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func runDeltaRatingTestCase(t *testing.T, tc deltaRatingTestCase) {
}

intent := ratingtestutils.NewIntentForTest(t, fullServicePeriod, tc.price, tc.discounts)
engine := New(billingratingservice.New())
engine := New(billingratingservice.New(billingratingservice.Config{}))
bookedDetailedLinesByPhase := make([]usagebased.DetailedLines, len(tc.phases))

for phaseIdx, phase := range tc.phases {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1173,7 +1173,7 @@ func TestRateRejectsOverlappingPriorPeriods(t *testing.T) {
productcatalog.Discounts{},
)

_, err := New(billingratingservice.New()).Rate(t.Context(), Input{
_, err := New(billingratingservice.New(billingratingservice.Config{})).Rate(t.Context(), Input{
Intent: intent,
PriorPeriods: []PriorPeriod{
{
Expand Down Expand Up @@ -1210,7 +1210,7 @@ func TestRateRejectsPriorPeriodThatIsEmptyAtMinimumStreamingWindowSize(t *testin
productcatalog.Discounts{},
)

_, err := New(billingratingservice.New()).Rate(t.Context(), Input{
_, err := New(billingratingservice.New(billingratingservice.Config{})).Rate(t.Context(), Input{
Intent: intent,
PriorPeriods: []PriorPeriod{
{
Expand Down Expand Up @@ -1242,7 +1242,7 @@ func runLateEventRatingTestCase(t *testing.T, tc lateEventRatingTestCase) {

intent := ratingtestutils.NewIntentForTest(t, fullServicePeriod, tc.price, tc.discounts)

engine := New(billingratingservice.New())
engine := New(billingratingservice.New(billingratingservice.Config{}))
bookedDetailedLinesByPhase := make([]usagebased.DetailedLines, len(tc.phases))
phaseRunIDs := make([]usagebased.RealizationRunID, len(tc.phases))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ func TestGetDetailedRatingForUsageUsesPeriodPreservingRatingEngine(t *testing.T)

svc, err := New(Config{
StreamingConnector: streamingConnector,
RatingService: billingratingservice.New(),
RatingService: billingratingservice.New(billingratingservice.Config{}),
DetailedLinesFetcher: passthroughDetailedLinesFetcher,
})
require.NoError(t, err)
Expand Down Expand Up @@ -391,7 +391,7 @@ func TestGetTotalsForUsageMinimumCommitment(t *testing.T) {

svc, err := New(Config{
StreamingConnector: streamingConnector,
RatingService: billingratingservice.New(),
RatingService: billingratingservice.New(billingratingservice.Config{}),
DetailedLinesFetcher: passthroughDetailedLinesFetcher,
})
require.NoError(t, err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1252,7 +1252,7 @@ func runSubtractRatedRunDetailsTestCases(
) {
t.Helper()

ratingService := billingratingservice.New()
ratingService := billingratingservice.New(billingratingservice.Config{})

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
Expand Down
3 changes: 3 additions & 0 deletions openmeter/billing/rating/line.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ type StandardLineAccessor interface {
GetName() string
// GetRateCardDiscounts returns the rate card discounts for the line
GetRateCardDiscounts() billing.Discounts
// GetUnitConfig returns the optional unit conversion to apply to the raw metered
// quantity before pricing. Nil means no conversion (rating is unchanged).
GetUnitConfig() *productcatalog.UnitConfig
// GetStandardLineDiscounts returns the standard line discounts for the line
GetStandardLineDiscounts() billing.StandardLineDiscounts

Expand Down
Loading
Loading