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
10 changes: 10 additions & 0 deletions openmeter/billing/charges/usagebased/adapter/charge.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ func (a *adapter) UpdateCharge(ctx context.Context, charge usagebased.ChargeBase
SetStatusDetailed(charge.Status).
SetOrClearCurrentRealizationRunID(charge.State.CurrentRealizationRunID)

if baseIntent.UnitConfig != nil {
update = update.SetUnitConfig(baseIntent.UnitConfig)
} else {
update = update.ClearUnitConfig()
}

update, err = chargemeta.Update(update, chargemeta.UpdateInput{
ManagedResource: charge.ManagedResource,
IntentMutableFields: baseIntent.IntentMutableFields.IntentMutableFields,
Expand Down Expand Up @@ -311,6 +317,10 @@ func (a *adapter) buildCreateUsageBasedCharge(ctx context.Context, ns string, in
SetInvoiceAt(meta.NormalizeTimestamp(baseIntent.InvoiceAt).In(time.UTC)).
SetSettlementMode(baseIntent.SettlementMode)

if baseIntent.UnitConfig != nil {
create = create.SetUnitConfig(baseIntent.UnitConfig)
}

create, err := chargemeta.Create[*db.ChargeUsageBasedCreate](create, chargemeta.CreateInput{
Namespace: ns,
Intent: baseIntent.Intent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func mapIntentOverrideFromDB(dbOverride *entdb.ChargeUsageBasedOverride) *usageb
FeatureKey: dbOverride.FeatureKey,
Price: lo.FromPtr(dbOverride.Price),
Discounts: lo.FromPtr(dbOverride.Discounts),
UnitConfig: dbOverride.UnitConfig,
}
}

Expand Down Expand Up @@ -155,6 +156,9 @@ func (a *adapter) createIntentOverride(ctx context.Context, chargeID meta.Charge
SetFeatureKey(normalized.FeatureKey).
SetPrice(&normalized.Price).
SetDiscounts(&normalized.Discounts)
if normalized.UnitConfig != nil {
create = create.SetUnitConfig(normalized.UnitConfig)
}
if normalized.Metadata != nil {
create = create.SetMetadata(&normalized.Metadata)
}
Expand Down Expand Up @@ -190,6 +194,11 @@ func (a *adapter) updateIntentOverride(ctx context.Context, chargeID meta.Charge
SetFeatureKey(normalized.FeatureKey).
SetPrice(&normalized.Price).
SetDiscounts(&normalized.Discounts)
if normalized.UnitConfig != nil {
update = update.SetUnitConfig(normalized.UnitConfig)
} else {
update = update.ClearUnitConfig()
}
if normalized.Metadata == nil {
update = update.ClearMetadata()
} else {
Expand Down
103 changes: 103 additions & 0 deletions openmeter/billing/charges/usagebased/adapter/intentoverride_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,108 @@ func (s *UsageBasedIntentOverrideAdapterSuite) TestDeleteChargeWithIntentOverrid
s.Equal(deletedAt, *fetched.DeletedAt)
}

// newTestUnitConfig builds a deterministic unit config for round-trip assertions.
func newTestUnitConfig(factor int64, displayUnit string) *productcatalog.UnitConfig {
return &productcatalog.UnitConfig{
Operation: productcatalog.UnitConfigOperationDivide,
ConversionFactor: alpacadecimal.NewFromInt(factor),
Rounding: productcatalog.UnitConfigRoundingModeCeiling,
Precision: 0,
DisplayUnit: lo.ToPtr(displayUnit),
}
}

// TestUnitConfigRoundTrip verifies unit_config persists through the charge write
// sites. unit_config is a mutable field in IntentMutableFields (alongside price),
// so it round-trips through both the base layer (create/update/clear) and the
// override layer (create/update/clear).
func (s *UsageBasedIntentOverrideAdapterSuite) TestUnitConfigRoundTrip() {
ctx := s.T().Context()
namespace := "usagebased-unitconfig-adapter"
charge := s.createCharge(namespace)

// base create→read: createCharge persisted a base unit_config (divide 1000)
fetched, err := s.adapter.GetByID(ctx, usagebased.GetByIDInput{ChargeID: charge.GetChargeID()})
s.Require().NoError(err)
s.Require().NotNil(fetched.Intent.GetBaseIntent().UnitConfig)
s.True(fetched.Intent.GetBaseIntent().UnitConfig.Equal(newTestUnitConfig(1000, "K")))

// base update→read: the base unit_config is mutable (lives alongside price)
s.Require().NoError(fetched.Intent.Mutate(chargesmeta.ChangeTargetBase, func(f *usagebased.IntentMutableFields) {
f.UnitConfig = newTestUnitConfig(1000000, "M")
}))
updated, err := s.adapter.UpdateCharge(ctx, fetched.ChargeBase)
s.Require().NoError(err)
s.True(updated.Intent.GetBaseIntent().UnitConfig.Equal(newTestUnitConfig(1000000, "M")))

fetched, err = s.adapter.GetByID(ctx, usagebased.GetByIDInput{ChargeID: charge.GetChargeID()})
s.Require().NoError(err)
s.True(fetched.Intent.GetBaseIntent().UnitConfig.Equal(newTestUnitConfig(1000000, "M")))

// base clear→read
s.Require().NoError(fetched.Intent.Mutate(chargesmeta.ChangeTargetBase, func(f *usagebased.IntentMutableFields) {
f.UnitConfig = nil
}))
updated, err = s.adapter.UpdateCharge(ctx, fetched.ChargeBase)
s.Require().NoError(err)
s.Nil(updated.Intent.GetBaseIntent().UnitConfig)

fetched, err = s.adapter.GetByID(ctx, usagebased.GetByIDInput{ChargeID: charge.GetChargeID()})
s.Require().NoError(err)
s.Nil(fetched.Intent.GetBaseIntent().UnitConfig)

// override create→read: the override layer carries its own unit_config snapshot
baseIntent := fetched.Intent.GetBaseIntent()
override := usagebased.IntentMutableFields{
IntentMutableFields: chargesmeta.IntentMutableFields{
Name: "override with unit config",
TaxConfig: baseIntent.TaxConfig,
ServicePeriod: baseIntent.ServicePeriod,
FullServicePeriod: baseIntent.FullServicePeriod,
BillingPeriod: baseIntent.BillingPeriod,
},
InvoiceAt: baseIntent.InvoiceAt,
FeatureKey: baseIntent.FeatureKey,
Price: baseIntent.Price,
Discounts: baseIntent.Discounts,
UnitConfig: newTestUnitConfig(1000, "K"),
}
withOverride, err := s.adapter.CreateChargeOverride(ctx, fetched.ChargeBase, override)
s.Require().NoError(err)
s.Require().NotNil(withOverride.Intent.GetOverrideLayerMutableFields())
s.True(withOverride.Intent.GetOverrideLayerMutableFields().UnitConfig.Equal(newTestUnitConfig(1000, "K")))

fetched, err = s.adapter.GetByID(ctx, usagebased.GetByIDInput{ChargeID: charge.GetChargeID()})
s.Require().NoError(err)
s.Require().NotNil(fetched.Intent.GetOverrideLayerMutableFields())
s.True(fetched.Intent.GetOverrideLayerMutableFields().UnitConfig.Equal(newTestUnitConfig(1000, "K")))

// override update→read
override = *fetched.Intent.GetOverrideLayerMutableFields()
override.UnitConfig = newTestUnitConfig(1000000, "M")
fetched.Intent = usagebased.NewOverridableIntent(fetched.Intent.GetBaseIntent(), &override)
updated, err = s.adapter.UpdateCharge(ctx, fetched.ChargeBase)
s.Require().NoError(err)
s.True(updated.Intent.GetOverrideLayerMutableFields().UnitConfig.Equal(newTestUnitConfig(1000000, "M")))

fetched, err = s.adapter.GetByID(ctx, usagebased.GetByIDInput{ChargeID: charge.GetChargeID()})
s.Require().NoError(err)
s.True(fetched.Intent.GetOverrideLayerMutableFields().UnitConfig.Equal(newTestUnitConfig(1000000, "M")))

// override clear→read
override = *fetched.Intent.GetOverrideLayerMutableFields()
override.UnitConfig = nil
fetched.Intent = usagebased.NewOverridableIntent(fetched.Intent.GetBaseIntent(), &override)
updated, err = s.adapter.UpdateCharge(ctx, fetched.ChargeBase)
s.Require().NoError(err)
s.Nil(updated.Intent.GetOverrideLayerMutableFields().UnitConfig)

fetched, err = s.adapter.GetByID(ctx, usagebased.GetByIDInput{ChargeID: charge.GetChargeID()})
s.Require().NoError(err)
s.Require().NotNil(fetched.Intent.GetOverrideLayerMutableFields())
s.Nil(fetched.Intent.GetOverrideLayerMutableFields().UnitConfig)
}

func (s *UsageBasedIntentOverrideAdapterSuite) requireOverrideMatches(
override *usagebased.IntentMutableFields,
servicePeriod timeutil.ClosedPeriod,
Expand Down Expand Up @@ -331,6 +433,7 @@ func (s *UsageBasedIntentOverrideAdapterSuite) createCharge(namespace string) us
Price: *productcatalog.NewPriceFrom(productcatalog.UnitPrice{
Amount: alpacadecimal.NewFromFloat(0.1),
}),
UnitConfig: newTestUnitConfig(1000, "K"),
},
SettlementMode: productcatalog.CreditThenInvoiceSettlementMode,
}.AsOverridableIntent(),
Expand Down
1 change: 1 addition & 0 deletions openmeter/billing/charges/usagebased/adapter/mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func MapChargeBaseFromDB(entity *entdb.ChargeUsageBased) usagebased.ChargeBase {
FeatureKey: entity.FeatureKey,
Discounts: lo.FromPtr(entity.Discounts),
Price: lo.FromPtr(entity.Price),
UnitConfig: entity.UnitConfig,
},
SettlementMode: entity.SettlementMode,
}
Expand Down
13 changes: 13 additions & 0 deletions openmeter/billing/charges/usagebased/charge.go
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,11 @@ type IntentMutableFields struct {
Price productcatalog.Price `json:"price"`

Discounts productcatalog.Discounts `json:"discounts"`

// UnitConfig is the optional unit conversion snapshotted from the effective
// rate card. Like Price it is a mutable rating input (set on create and
// update) so re-rates read the config in effect for the charge.
UnitConfig *productcatalog.UnitConfig `json:"unitConfig,omitempty"`
}

func (f IntentMutableFields) Normalized() IntentMutableFields {
Expand All @@ -541,6 +546,10 @@ func (f IntentMutableFields) Clone() IntentMutableFields {
out.Price = *f.Price.Clone()
out.Discounts = f.Discounts.Clone()

if f.UnitConfig != nil {
out.UnitConfig = lo.ToPtr(f.UnitConfig.Clone())
}

return out
}

Expand All @@ -563,6 +572,10 @@ func (f IntentMutableFields) Validate() error {
errs = append(errs, fmt.Errorf("price: %w", err))
}

if err := f.UnitConfig.Validate(); err != nil {
errs = append(errs, fmt.Errorf("unit config: %w", err))
}

if f.FeatureKey == "" {
errs = append(errs, fmt.Errorf("feature key is required"))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ func newUsageBasedChargeIntent(target targetstate.StateItem) (charges.ChargeInte
FeatureKey: lo.FromPtr(rateCardMeta.FeatureKey),
Price: *price,
Discounts: rateCardMeta.Discounts,
// TODO(unit-config): copy rateCardMeta.UnitConfig here (with a
// round-trip test) when the "snapshot unit_config onto subscriptions"
// ticket lands; the charge adapter already persists Intent.UnitConfig.
},
SettlementMode: target.Subscription.SettlementMode,
}), nil
Expand Down
15 changes: 15 additions & 0 deletions openmeter/ent/db/addonratecard.go

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

9 changes: 9 additions & 0 deletions openmeter/ent/db/addonratecard/addonratecard.go

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

10 changes: 10 additions & 0 deletions openmeter/ent/db/addonratecard/where.go

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

Loading
Loading