Skip to content

Commit 8b22826

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

7 files changed

Lines changed: 186 additions & 36 deletions

File tree

e2e/v1readsurface_unitconfig_test.go

Lines changed: 33 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
"github.com/stretchr/testify/require"
1010

1111
api "github.com/openmeterio/openmeter/api/client/go"
12-
apiv3 "github.com/openmeterio/openmeter/api/v3"
12+
v3sdk "github.com/openmeterio/openmeter/api/v3/client"
1313
)
1414

1515
const unitConfigNotRepresentableCode = "unit_config_not_representable"
@@ -33,88 +33,86 @@ func TestV1ReadSurfaceExcludesUnitConfig(t *testing.T) {
3333
ucPlanKey := "ucread_uc_plan_" + uniq
3434
plainPlanKey := "ucread_plain_plan_" + uniq
3535

36-
var ucPlan *apiv3.BillingPlan
37-
var plainPlan *apiv3.BillingPlan
36+
var ucPlan *v3sdk.Plan
37+
var plainPlan *v3sdk.Plan
3838

3939
// given:
4040
// - a unit_config plan (usage-based rate card, divide-by-1000 ceiling) and a plain plan, both
4141
// authored and published via v3 (v1 cannot author unit_config).
4242
runRequired(t, "creates a unit_config plan and a plain plan", func(t *testing.T) {
43-
status, meter, problem := c.CreateMeter(apiv3.CreateMeterRequest{
43+
meter, err := c.Meters.Create(t.Context(), v3sdk.CreateMeterRequest{
4444
Key: meterKey,
4545
Name: "UC Read Meter " + uniq,
46-
Aggregation: apiv3.MeterAggregationSum,
46+
Aggregation: v3sdk.MeterAggregationSum,
4747
EventType: eventType,
4848
ValueProperty: lo.ToPtr("$.value"),
4949
})
50-
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
50+
c.requireStatus(http.StatusCreated, err)
5151
require.NotNil(t, meter)
5252

53-
status, feature, problem := c.CreateFeature(apiv3.CreateFeatureRequest{
53+
feature, err := c.Features.Create(t.Context(), v3sdk.CreateFeatureRequest{
5454
Key: featureKey,
5555
Name: "UC Read Feature " + uniq,
56-
Meter: &apiv3.FeatureMeterReference{Id: meter.Id},
56+
Meter: &v3sdk.FeatureMeterReferenceInput{ID: meter.ID},
5757
})
58-
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
58+
c.requireStatus(http.StatusCreated, err)
5959
require.NotNil(t, feature)
6060

61-
cadence := apiv3.ISO8601Duration("P1M")
62-
term := apiv3.BillingPricePaymentTermInArrears
63-
price := apiv3.BillingPrice{}
64-
require.NoError(t, price.FromBillingPriceUnit(apiv3.BillingPriceUnit{
65-
Type: apiv3.BillingPriceUnitTypeUnit,
61+
cadence := "P1M"
62+
term := v3sdk.PricePaymentTermInArrears
63+
price := lo.Must(v3sdk.PriceFromPriceUnit(v3sdk.PriceUnit{
6664
Amount: "0.10",
6765
}))
68-
ucRateCard := apiv3.BillingRateCard{
66+
ucRateCard := v3sdk.RateCardInput{
6967
Key: feature.Key,
7068
Name: "UC Read Rate Card " + uniq,
7169
Price: price,
7270
BillingCadence: &cadence,
7371
PaymentTerm: &term,
74-
Feature: &apiv3.FeatureReference{Id: feature.Id},
75-
UnitConfig: &apiv3.BillingUnitConfig{
76-
Operation: apiv3.BillingUnitConfigOperationDivide,
72+
Feature: &v3sdk.FeatureReference{ID: feature.ID},
73+
UnitConfig: &v3sdk.UnitConfig{
74+
Operation: v3sdk.UnitConfigOperationDivide,
7775
ConversionFactor: "1000",
78-
Rounding: lo.ToPtr(apiv3.BillingUnitConfigRoundingModeCeiling),
79-
Precision: lo.ToPtr(0),
76+
Rounding: lo.ToPtr(v3sdk.UnitConfigRoundingModeCeiling),
77+
Precision: lo.ToPtr(int64(0)),
8078
},
8179
}
8280

83-
status, createdUC, problem := c.CreatePlan(apiv3.CreatePlanRequest{
81+
createdUC, err := c.Plans.Create(t.Context(), v3sdk.CreatePlanRequest{
8482
Key: ucPlanKey,
8583
Name: "UC Read Plan " + uniq,
8684
Currency: "USD",
87-
BillingCadence: apiv3.ISO8601Duration("P1M"),
88-
Phases: []apiv3.BillingPlanPhase{{
85+
BillingCadence: "P1M",
86+
Phases: []v3sdk.PlanPhaseInput{{
8987
Key: "phase_1",
9088
Name: "UC Phase",
91-
RateCards: []apiv3.BillingRateCard{ucRateCard},
89+
RateCards: []v3sdk.RateCardInput{ucRateCard},
9290
}},
9391
})
94-
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
92+
c.requireStatus(http.StatusCreated, err)
9593
require.NotNil(t, createdUC)
96-
status, ucPlan, problem = c.PublishPlan(createdUC.Id)
97-
require.Equal(t, http.StatusOK, status, "problem: %+v", problem)
94+
ucPlan, err = c.Plans.Publish(t.Context(), createdUC.ID)
95+
c.requireStatus(http.StatusOK, err)
9896
require.NotNil(t, ucPlan)
9997

100-
status, createdPlain, problem := c.CreatePlan(apiv3.CreatePlanRequest{
98+
createdPlain, err := c.Plans.Create(t.Context(), v3sdk.CreatePlanRequest{
10199
Key: plainPlanKey,
102100
Name: "Plain Read Plan " + uniq,
103101
Currency: "USD",
104-
BillingCadence: apiv3.ISO8601Duration("P1M"),
105-
Phases: []apiv3.BillingPlanPhase{validPlanPhase("plain_phase", true /* isLast */)},
102+
BillingCadence: "P1M",
103+
Phases: []v3sdk.PlanPhaseInput{validPlanPhase("plain_phase", true /* isLast */)},
106104
})
107-
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
105+
c.requireStatus(http.StatusCreated, err)
108106
require.NotNil(t, createdPlain)
109-
status, plainPlan, problem = c.PublishPlan(createdPlain.Id)
110-
require.Equal(t, http.StatusOK, status, "problem: %+v", problem)
107+
plainPlan, err = c.Plans.Publish(t.Context(), createdPlain.ID)
108+
c.requireStatus(http.StatusOK, err)
111109
require.NotNil(t, plainPlan)
112110
})
113111

114112
// then:
115113
// - v1 GET on the unit_config plan is rejected with the typed code (not a silently-stripped 200).
116114
runRequired(t, "v1 plan GET rejects the unit_config plan", func(t *testing.T) {
117-
resp, err := v1.GetPlanWithResponse(t.Context(), ucPlan.Id, nil)
115+
resp, err := v1.GetPlanWithResponse(t.Context(), ucPlan.ID, nil)
118116
require.NoError(t, err)
119117
require.Equal(t, http.StatusBadRequest, resp.StatusCode(), "body: %s", string(resp.Body))
120118
require.NotNil(t, resp.ApplicationproblemJSON400)
@@ -124,7 +122,7 @@ func TestV1ReadSurfaceExcludesUnitConfig(t *testing.T) {
124122
// and:
125123
// - the plain plan is still gettable via v1 (the exclusion is content-derived, not a blanket block).
126124
runRequired(t, "v1 plan GET returns the plain plan", func(t *testing.T) {
127-
resp, err := v1.GetPlanWithResponse(t.Context(), plainPlan.Id, nil)
125+
resp, err := v1.GetPlanWithResponse(t.Context(), plainPlan.ID, nil)
128126
require.NoError(t, err)
129127
require.Equal(t, http.StatusOK, resp.StatusCode(), "body: %s", string(resp.Body))
130128
require.NotNil(t, resp.JSON200)

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)