Skip to content

Commit 5ecf671

Browse files
authored
Persist unit config - OM-395 (#4579)
1 parent 1d4286f commit 5ecf671

44 files changed

Lines changed: 1285 additions & 37 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

openmeter/billing/charges/usagebased/adapter/charge.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ func (a *adapter) UpdateCharge(ctx context.Context, charge usagebased.ChargeBase
4949
SetStatusDetailed(charge.Status).
5050
SetOrClearCurrentRealizationRunID(charge.State.CurrentRealizationRunID)
5151

52+
if baseIntent.UnitConfig != nil {
53+
update = update.SetUnitConfig(baseIntent.UnitConfig)
54+
} else {
55+
update = update.ClearUnitConfig()
56+
}
57+
5258
update, err = chargemeta.Update(update, chargemeta.UpdateInput{
5359
ManagedResource: charge.ManagedResource,
5460
IntentMutableFields: baseIntent.IntentMutableFields.IntentMutableFields,
@@ -311,6 +317,10 @@ func (a *adapter) buildCreateUsageBasedCharge(ctx context.Context, ns string, in
311317
SetInvoiceAt(meta.NormalizeTimestamp(baseIntent.InvoiceAt).In(time.UTC)).
312318
SetSettlementMode(baseIntent.SettlementMode)
313319

320+
if baseIntent.UnitConfig != nil {
321+
create = create.SetUnitConfig(baseIntent.UnitConfig)
322+
}
323+
314324
create, err := chargemeta.Create[*db.ChargeUsageBasedCreate](create, chargemeta.CreateInput{
315325
Namespace: ns,
316326
Intent: baseIntent.Intent,

openmeter/billing/charges/usagebased/adapter/intentoverride.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ func mapIntentOverrideFromDB(dbOverride *entdb.ChargeUsageBasedOverride) *usageb
4242
FeatureKey: dbOverride.FeatureKey,
4343
Price: lo.FromPtr(dbOverride.Price),
4444
Discounts: lo.FromPtr(dbOverride.Discounts),
45+
UnitConfig: dbOverride.UnitConfig,
4546
}
4647
}
4748

@@ -155,6 +156,9 @@ func (a *adapter) createIntentOverride(ctx context.Context, chargeID meta.Charge
155156
SetFeatureKey(normalized.FeatureKey).
156157
SetPrice(&normalized.Price).
157158
SetDiscounts(&normalized.Discounts)
159+
if normalized.UnitConfig != nil {
160+
create = create.SetUnitConfig(normalized.UnitConfig)
161+
}
158162
if normalized.Metadata != nil {
159163
create = create.SetMetadata(&normalized.Metadata)
160164
}
@@ -190,6 +194,11 @@ func (a *adapter) updateIntentOverride(ctx context.Context, chargeID meta.Charge
190194
SetFeatureKey(normalized.FeatureKey).
191195
SetPrice(&normalized.Price).
192196
SetDiscounts(&normalized.Discounts)
197+
if normalized.UnitConfig != nil {
198+
update = update.SetUnitConfig(normalized.UnitConfig)
199+
} else {
200+
update = update.ClearUnitConfig()
201+
}
193202
if normalized.Metadata == nil {
194203
update = update.ClearMetadata()
195204
} else {

openmeter/billing/charges/usagebased/adapter/intentoverride_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,108 @@ func (s *UsageBasedIntentOverrideAdapterSuite) TestDeleteChargeWithIntentOverrid
265265
s.Equal(deletedAt, *fetched.DeletedAt)
266266
}
267267

268+
// newTestUnitConfig builds a deterministic unit config for round-trip assertions.
269+
func newTestUnitConfig(factor int64, displayUnit string) *productcatalog.UnitConfig {
270+
return &productcatalog.UnitConfig{
271+
Operation: productcatalog.UnitConfigOperationDivide,
272+
ConversionFactor: alpacadecimal.NewFromInt(factor),
273+
Rounding: productcatalog.UnitConfigRoundingModeCeiling,
274+
Precision: 0,
275+
DisplayUnit: lo.ToPtr(displayUnit),
276+
}
277+
}
278+
279+
// TestUnitConfigRoundTrip verifies unit_config persists through the charge write
280+
// sites. unit_config is a mutable field in IntentMutableFields (alongside price),
281+
// so it round-trips through both the base layer (create/update/clear) and the
282+
// override layer (create/update/clear).
283+
func (s *UsageBasedIntentOverrideAdapterSuite) TestUnitConfigRoundTrip() {
284+
ctx := s.T().Context()
285+
namespace := "usagebased-unitconfig-adapter"
286+
charge := s.createCharge(namespace)
287+
288+
// base create→read: createCharge persisted a base unit_config (divide 1000)
289+
fetched, err := s.adapter.GetByID(ctx, usagebased.GetByIDInput{ChargeID: charge.GetChargeID()})
290+
s.Require().NoError(err)
291+
s.Require().NotNil(fetched.Intent.GetBaseIntent().UnitConfig)
292+
s.True(fetched.Intent.GetBaseIntent().UnitConfig.Equal(newTestUnitConfig(1000, "K")))
293+
294+
// base update→read: the base unit_config is mutable (lives alongside price)
295+
s.Require().NoError(fetched.Intent.Mutate(chargesmeta.ChangeTargetBase, func(f *usagebased.IntentMutableFields) {
296+
f.UnitConfig = newTestUnitConfig(1000000, "M")
297+
}))
298+
updated, err := s.adapter.UpdateCharge(ctx, fetched.ChargeBase)
299+
s.Require().NoError(err)
300+
s.True(updated.Intent.GetBaseIntent().UnitConfig.Equal(newTestUnitConfig(1000000, "M")))
301+
302+
fetched, err = s.adapter.GetByID(ctx, usagebased.GetByIDInput{ChargeID: charge.GetChargeID()})
303+
s.Require().NoError(err)
304+
s.True(fetched.Intent.GetBaseIntent().UnitConfig.Equal(newTestUnitConfig(1000000, "M")))
305+
306+
// base clear→read
307+
s.Require().NoError(fetched.Intent.Mutate(chargesmeta.ChangeTargetBase, func(f *usagebased.IntentMutableFields) {
308+
f.UnitConfig = nil
309+
}))
310+
updated, err = s.adapter.UpdateCharge(ctx, fetched.ChargeBase)
311+
s.Require().NoError(err)
312+
s.Nil(updated.Intent.GetBaseIntent().UnitConfig)
313+
314+
fetched, err = s.adapter.GetByID(ctx, usagebased.GetByIDInput{ChargeID: charge.GetChargeID()})
315+
s.Require().NoError(err)
316+
s.Nil(fetched.Intent.GetBaseIntent().UnitConfig)
317+
318+
// override create→read: the override layer carries its own unit_config snapshot
319+
baseIntent := fetched.Intent.GetBaseIntent()
320+
override := usagebased.IntentMutableFields{
321+
IntentMutableFields: chargesmeta.IntentMutableFields{
322+
Name: "override with unit config",
323+
TaxConfig: baseIntent.TaxConfig,
324+
ServicePeriod: baseIntent.ServicePeriod,
325+
FullServicePeriod: baseIntent.FullServicePeriod,
326+
BillingPeriod: baseIntent.BillingPeriod,
327+
},
328+
InvoiceAt: baseIntent.InvoiceAt,
329+
FeatureKey: baseIntent.FeatureKey,
330+
Price: baseIntent.Price,
331+
Discounts: baseIntent.Discounts,
332+
UnitConfig: newTestUnitConfig(1000, "K"),
333+
}
334+
withOverride, err := s.adapter.CreateChargeOverride(ctx, fetched.ChargeBase, override)
335+
s.Require().NoError(err)
336+
s.Require().NotNil(withOverride.Intent.GetOverrideLayerMutableFields())
337+
s.True(withOverride.Intent.GetOverrideLayerMutableFields().UnitConfig.Equal(newTestUnitConfig(1000, "K")))
338+
339+
fetched, err = s.adapter.GetByID(ctx, usagebased.GetByIDInput{ChargeID: charge.GetChargeID()})
340+
s.Require().NoError(err)
341+
s.Require().NotNil(fetched.Intent.GetOverrideLayerMutableFields())
342+
s.True(fetched.Intent.GetOverrideLayerMutableFields().UnitConfig.Equal(newTestUnitConfig(1000, "K")))
343+
344+
// override update→read
345+
override = *fetched.Intent.GetOverrideLayerMutableFields()
346+
override.UnitConfig = newTestUnitConfig(1000000, "M")
347+
fetched.Intent = usagebased.NewOverridableIntent(fetched.Intent.GetBaseIntent(), &override)
348+
updated, err = s.adapter.UpdateCharge(ctx, fetched.ChargeBase)
349+
s.Require().NoError(err)
350+
s.True(updated.Intent.GetOverrideLayerMutableFields().UnitConfig.Equal(newTestUnitConfig(1000000, "M")))
351+
352+
fetched, err = s.adapter.GetByID(ctx, usagebased.GetByIDInput{ChargeID: charge.GetChargeID()})
353+
s.Require().NoError(err)
354+
s.True(fetched.Intent.GetOverrideLayerMutableFields().UnitConfig.Equal(newTestUnitConfig(1000000, "M")))
355+
356+
// override clear→read
357+
override = *fetched.Intent.GetOverrideLayerMutableFields()
358+
override.UnitConfig = nil
359+
fetched.Intent = usagebased.NewOverridableIntent(fetched.Intent.GetBaseIntent(), &override)
360+
updated, err = s.adapter.UpdateCharge(ctx, fetched.ChargeBase)
361+
s.Require().NoError(err)
362+
s.Nil(updated.Intent.GetOverrideLayerMutableFields().UnitConfig)
363+
364+
fetched, err = s.adapter.GetByID(ctx, usagebased.GetByIDInput{ChargeID: charge.GetChargeID()})
365+
s.Require().NoError(err)
366+
s.Require().NotNil(fetched.Intent.GetOverrideLayerMutableFields())
367+
s.Nil(fetched.Intent.GetOverrideLayerMutableFields().UnitConfig)
368+
}
369+
268370
func (s *UsageBasedIntentOverrideAdapterSuite) requireOverrideMatches(
269371
override *usagebased.IntentMutableFields,
270372
servicePeriod timeutil.ClosedPeriod,
@@ -331,6 +433,7 @@ func (s *UsageBasedIntentOverrideAdapterSuite) createCharge(namespace string) us
331433
Price: *productcatalog.NewPriceFrom(productcatalog.UnitPrice{
332434
Amount: alpacadecimal.NewFromFloat(0.1),
333435
}),
436+
UnitConfig: newTestUnitConfig(1000, "K"),
334437
},
335438
SettlementMode: productcatalog.CreditThenInvoiceSettlementMode,
336439
}.AsOverridableIntent(),

openmeter/billing/charges/usagebased/adapter/mapper.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ func MapChargeBaseFromDB(entity *entdb.ChargeUsageBased) usagebased.ChargeBase {
5151
FeatureKey: entity.FeatureKey,
5252
Discounts: lo.FromPtr(entity.Discounts),
5353
Price: lo.FromPtr(entity.Price),
54+
UnitConfig: entity.UnitConfig,
5455
},
5556
SettlementMode: entity.SettlementMode,
5657
}

openmeter/billing/charges/usagebased/charge.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,11 @@ type IntentMutableFields struct {
526526
Price productcatalog.Price `json:"price"`
527527

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

531536
func (f IntentMutableFields) Normalized() IntentMutableFields {
@@ -541,6 +546,10 @@ func (f IntentMutableFields) Clone() IntentMutableFields {
541546
out.Price = *f.Price.Clone()
542547
out.Discounts = f.Discounts.Clone()
543548

549+
if f.UnitConfig != nil {
550+
out.UnitConfig = lo.ToPtr(f.UnitConfig.Clone())
551+
}
552+
544553
return out
545554
}
546555

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

575+
if err := f.UnitConfig.Validate(); err != nil {
576+
errs = append(errs, fmt.Errorf("unit config: %w", err))
577+
}
578+
566579
if f.FeatureKey == "" {
567580
errs = append(errs, fmt.Errorf("feature key is required"))
568581
}

openmeter/billing/worker/subscriptionsync/service/reconciler/patchchargeusagebased.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ func newUsageBasedChargeIntent(target targetstate.StateItem) (charges.ChargeInte
121121
FeatureKey: lo.FromPtr(rateCardMeta.FeatureKey),
122122
Price: *price,
123123
Discounts: rateCardMeta.Discounts,
124+
// TODO(unit-config): copy rateCardMeta.UnitConfig here (with a
125+
// round-trip test) when the "snapshot unit_config onto subscriptions"
126+
// ticket lands; the charge adapter already persists Intent.UnitConfig.
124127
},
125128
SettlementMode: target.Subscription.SettlementMode,
126129
}), nil

openmeter/ent/db/addonratecard.go

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openmeter/ent/db/addonratecard/addonratecard.go

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openmeter/ent/db/addonratecard/where.go

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)