Skip to content

Commit a26e0d2

Browse files
committed
refactor: extend blocking everywhere where we serialize rate cards
1 parent f64fb59 commit a26e0d2

6 files changed

Lines changed: 153 additions & 1 deletion

File tree

openmeter/productcatalog/addon/adapter/adapter_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,78 @@ func TestPostgresAdapter(t *testing.T) {
450450
})
451451
}
452452

453+
func TestListAddonsExcludeUnitConfig(t *testing.T) {
454+
ctx := context.Background()
455+
456+
env := pctestutils.NewTestEnv(t)
457+
t.Cleanup(func() { env.Close(t) })
458+
env.DBSchemaMigrate(t)
459+
460+
namespace := pctestutils.NewTestNamespace(t)
461+
462+
require.NoError(t, env.Meter.ReplaceMeters(ctx, pctestutils.NewTestMeters(t, namespace)),
463+
"replacing meters must not fail")
464+
meters, err := env.Meter.ListMeters(ctx, meter.ListMetersParams{
465+
Page: pagination.Page{PageSize: 1000, PageNumber: 1},
466+
Namespace: namespace,
467+
})
468+
require.NoError(t, err, "listing meters must not fail")
469+
require.NotEmpty(t, meters.Items, "list of meters must not be empty")
470+
471+
feat, err := env.Feature.CreateFeature(ctx, pctestutils.NewTestFeatureFromMeter(t, &meters.Items[0]))
472+
require.NoError(t, err, "creating feature must not fail")
473+
474+
// Plain add-on: flat rate card, no unit_config → v1-representable.
475+
plainInput := pctestutils.NewTestAddon(t, namespace, &productcatalog.FlatFeeRateCard{
476+
RateCardMeta: productcatalog.RateCardMeta{Key: "flat", Name: "Flat"},
477+
})
478+
plainInput.Addon.Key = "plain"
479+
_, err = env.AddonRepository.CreateAddon(ctx, plainInput)
480+
require.NoError(t, err, "creating plain add-on must not fail")
481+
482+
// unit_config add-on: usage-based rate card carrying a unit_config → not v1-representable.
483+
ucInput := pctestutils.NewTestAddon(t, namespace, &productcatalog.UsageBasedRateCard{
484+
RateCardMeta: productcatalog.RateCardMeta{
485+
Key: feat.Key,
486+
Name: "UC RateCard",
487+
FeatureKey: lo.ToPtr(feat.Key),
488+
FeatureID: lo.ToPtr(feat.ID),
489+
Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: decimal.NewFromInt(1)}),
490+
UnitConfig: &productcatalog.UnitConfig{
491+
Operation: productcatalog.UnitConfigOperationDivide,
492+
ConversionFactor: decimal.NewFromInt(1000),
493+
},
494+
},
495+
BillingCadence: MonthPeriod,
496+
})
497+
ucInput.Addon.Key = "with-uc"
498+
_, err = env.AddonRepository.CreateAddon(ctx, ucInput)
499+
require.NoError(t, err, "creating unit_config add-on must not fail")
500+
501+
t.Run("included when ExcludeUnitConfig is false", func(t *testing.T) {
502+
list, err := env.AddonRepository.ListAddons(ctx, addon.ListAddonsInput{
503+
Namespaces: []string{namespace},
504+
})
505+
require.NoError(t, err, "listing add-ons must not fail")
506+
507+
keys := lo.Map(list.Items, func(a addon.Addon, _ int) string { return a.Key })
508+
require.ElementsMatch(t, []string{"plain", "with-uc"}, keys)
509+
require.Equal(t, 2, list.TotalCount, "TotalCount must count both add-ons")
510+
})
511+
512+
t.Run("excluded when ExcludeUnitConfig is true, TotalCount stays consistent", func(t *testing.T) {
513+
list, err := env.AddonRepository.ListAddons(ctx, addon.ListAddonsInput{
514+
Namespaces: []string{namespace},
515+
ExcludeUnitConfig: true,
516+
})
517+
require.NoError(t, err, "listing add-ons must not fail")
518+
519+
keys := lo.Map(list.Items, func(a addon.Addon, _ int) string { return a.Key })
520+
require.ElementsMatch(t, []string{"plain"}, keys)
521+
require.Equal(t, 1, list.TotalCount, "TotalCount must exclude the unit_config add-on, not just the page slice")
522+
})
523+
}
524+
453525
// TestFromPlanRateCardRowMapsUnitConfig guards the cross-package mapper used when an
454526
// add-on is loaded with expanded linked plans
455527
// (FromAddonRow → FromPlanAddonRow → FromPlanRow → FromPlanPhaseRow → FromPlanRateCardRow).

openmeter/productcatalog/addon/adapter/addon.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ func (a *adapter) ListAddons(ctx context.Context, params addon.ListAddonsInput)
5050
query = filter.ApplyToQuery(query, params.Name, addondb.FieldName)
5151
query = filter.ApplyToQuery(query, params.Currency, addondb.FieldCurrency)
5252

53+
if params.ExcludeUnitConfig {
54+
query = query.Where(addondb.Not(addondb.HasRatecardsWith(
55+
addonratecarddb.UnitConfigNotNil(),
56+
addonratecarddb.DeletedAtIsNil(),
57+
)))
58+
}
59+
5360
if !params.IncludeDeleted {
5461
query = query.Where(addondb.DeletedAtIsNil())
5562
}

openmeter/productcatalog/addon/httpdriver/addon.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ func (h *handler) ListAddons() ListAddonsHandler {
5656
KeyVersions: lo.FromPtr(params.KeyVersion),
5757
IncludeDeleted: lo.FromPtr(params.IncludeDeleted),
5858
Status: statusFilter,
59+
// The v1 API cannot represent unit_config; exclude such add-ons from the v1 list at the query layer.
60+
ExcludeUnitConfig: true,
5961
}
6062

6163
if params.Id != nil {

openmeter/productcatalog/addon/service.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ type ListAddonsInput struct {
9595
Key *filter.FilterString
9696
Name *filter.FilterString
9797
Currency *filter.FilterString
98+
99+
// ExcludeUnitConfig omits add-ons carrying a unit_config conversion on any of their rate cards.
100+
ExcludeUnitConfig bool
98101
}
99102

100103
func (i ListAddonsInput) Validate() error {

openmeter/productcatalog/http/mapping.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ func FromRateCard(r productcatalog.RateCard) (api.RateCard, error) {
2222

2323
meta := r.AsMeta()
2424

25+
// The v1 rate card types have no unit_config field. This mapper is the single serialization choke
26+
// point for every v1 surface that emits a rate card, so it fails closed here rather than silently
27+
// dropping the conversion and misrepresenting billing: reject the object, available via the v3 API.
28+
// List surfaces exclude unit_config objects at the query layer so they never reach this mapper;
29+
// subscription items are serialized without this mapper, so subscription GET guards them via Spec.HasUnitConfig.
30+
if meta.UnitConfig != nil {
31+
return resp, productcatalog.ErrUnitConfigNotRepresentable
32+
}
33+
2534
var featureKey *string
2635
if meta.FeatureKey != nil {
2736
featureKey = meta.FeatureKey
@@ -923,7 +932,7 @@ func FromAnnotations(annotations models.Annotations) *api.Annotations {
923932
return nil
924933
}
925934

926-
return lo.ToPtr((api.Annotations)(annotations))
935+
return lo.ToPtr(api.Annotations(annotations))
927936
}
928937

929938
func FromValidationErrors(issues models.ValidationIssues) *[]api.ValidationError {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package http
2+
3+
import (
4+
"testing"
5+
6+
decimal "github.com/alpacahq/alpacadecimal"
7+
"github.com/samber/lo"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/openmeterio/openmeter/openmeter/productcatalog"
12+
"github.com/openmeterio/openmeter/pkg/datetime"
13+
"github.com/openmeterio/openmeter/pkg/models"
14+
)
15+
16+
// FromRateCard is the single v1 serialization choke point for rate cards. It must fail closed on a
17+
// unit_config-bearing card (the v1 types cannot represent it) so no v1 surface silently strips the
18+
// conversion, while a plain card still serializes normally.
19+
func TestFromRateCardRejectsUnitConfig(t *testing.T) {
20+
cadence := datetime.NewISODuration(0, 1, 0, 0, 0, 0, 0)
21+
22+
t.Run("unit_config card is rejected with the typed code", func(t *testing.T) {
23+
rc := &productcatalog.UsageBasedRateCard{
24+
RateCardMeta: productcatalog.RateCardMeta{
25+
Key: "feat-1",
26+
Name: "Feature 1",
27+
FeatureKey: lo.ToPtr("feat-1"),
28+
Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: decimal.NewFromInt(1)}),
29+
UnitConfig: &productcatalog.UnitConfig{
30+
Operation: productcatalog.UnitConfigOperationDivide,
31+
ConversionFactor: decimal.NewFromInt(1000),
32+
},
33+
},
34+
BillingCadence: cadence,
35+
}
36+
37+
_, err := FromRateCard(rc)
38+
require.Error(t, err)
39+
40+
issues, convErr := models.AsValidationIssues(err)
41+
require.NoError(t, convErr)
42+
require.Len(t, issues, 1)
43+
assert.Equal(t, productcatalog.ErrCodeUnitConfigNotRepresentable, issues[0].Code())
44+
})
45+
46+
t.Run("plain card serializes without error", func(t *testing.T) {
47+
rc := &productcatalog.FlatFeeRateCard{
48+
RateCardMeta: productcatalog.RateCardMeta{
49+
Key: "flat-1",
50+
Name: "Flat 1",
51+
Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{Amount: decimal.NewFromInt(10), PaymentTerm: productcatalog.InArrearsPaymentTerm}),
52+
},
53+
BillingCadence: &cadence,
54+
}
55+
56+
_, err := FromRateCard(rc)
57+
require.NoError(t, err)
58+
})
59+
}

0 commit comments

Comments
 (0)