Leon4gr45 commited on
Commit
fea99b3
·
verified ·
1 Parent(s): 11b362a

Upload folder using huggingface_hub (part 9)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. openmeter/productcatalog/adapter/transaction.go +34 -0
  2. openmeter/productcatalog/addon.go +338 -0
  3. openmeter/productcatalog/addon/adapter/adapter.go +83 -0
  4. openmeter/productcatalog/addon/adapter/adapter_test.go +548 -0
  5. openmeter/productcatalog/addon/adapter/addon.go +572 -0
  6. openmeter/productcatalog/addon/adapter/mapping.go +398 -0
  7. openmeter/productcatalog/addon/addon.go +62 -0
  8. openmeter/productcatalog/addon/assert.go +134 -0
  9. openmeter/productcatalog/addon/errors.go +72 -0
  10. openmeter/productcatalog/addon/errors_test.go +94 -0
  11. openmeter/productcatalog/addon/event.go +240 -0
  12. openmeter/productcatalog/addon/httpdriver/addon.go +398 -0
  13. openmeter/productcatalog/addon/httpdriver/driver.go +55 -0
  14. openmeter/productcatalog/addon/httpdriver/mapping.go +113 -0
  15. openmeter/productcatalog/addon/plan.go +33 -0
  16. openmeter/productcatalog/addon/ratecard.go +218 -0
  17. openmeter/productcatalog/addon/ratecard_test.go +399 -0
  18. openmeter/productcatalog/addon/repository.go +20 -0
  19. openmeter/productcatalog/addon/service.go +468 -0
  20. openmeter/productcatalog/addon/service/addon.go +685 -0
  21. openmeter/productcatalog/addon/service/service.go +62 -0
  22. openmeter/productcatalog/addon/service/service_test.go +610 -0
  23. openmeter/productcatalog/addon/service/taxcode_test.go +899 -0
  24. openmeter/productcatalog/addon/validators.go +31 -0
  25. openmeter/productcatalog/alignment.go +35 -0
  26. openmeter/productcatalog/discount.go +239 -0
  27. openmeter/productcatalog/discount_test.go +161 -0
  28. openmeter/productcatalog/driver/errors.go +24 -0
  29. openmeter/productcatalog/driver/feature.go +291 -0
  30. openmeter/productcatalog/driver/parser.go +247 -0
  31. openmeter/productcatalog/effectiveperiod.go +74 -0
  32. openmeter/productcatalog/effectiveperiod_test.go +147 -0
  33. openmeter/productcatalog/entitlement.go +422 -0
  34. openmeter/productcatalog/entitlement_test.go +153 -0
  35. openmeter/productcatalog/errors.go +667 -0
  36. openmeter/productcatalog/feature/connector.go +382 -0
  37. openmeter/productcatalog/feature/connector_test.go +66 -0
  38. openmeter/productcatalog/feature/event.go +164 -0
  39. openmeter/productcatalog/feature/feature.go +198 -0
  40. openmeter/productcatalog/feature/featuremeter.go +224 -0
  41. openmeter/productcatalog/feature/featuremeter_test.go +108 -0
  42. openmeter/productcatalog/feature/meter_group_by_filters_test.go +50 -0
  43. openmeter/productcatalog/feature/repository.go +47 -0
  44. openmeter/productcatalog/feature/unitcost.go +187 -0
  45. openmeter/productcatalog/featureresolver.go +19 -0
  46. openmeter/productcatalog/featureresolver/ratecard.go +122 -0
  47. openmeter/productcatalog/featureresolver/ratecard_test.go +280 -0
  48. openmeter/productcatalog/featureresolver/resolver.go +156 -0
  49. openmeter/productcatalog/featureresolver/resolver_test.go +188 -0
  50. openmeter/productcatalog/http/errors.go +53 -0
openmeter/productcatalog/adapter/transaction.go ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package adapter
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "fmt"
7
+
8
+ "github.com/openmeterio/openmeter/openmeter/ent/db"
9
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
10
+ "github.com/openmeterio/openmeter/pkg/framework/entutils"
11
+ "github.com/openmeterio/openmeter/pkg/framework/transaction"
12
+ )
13
+
14
+ // We implement entuitls.TxUser[T] and entuitls.TxCreator here
15
+ // There ought to be a better way....
16
+
17
+ func (e *featureDBAdapter) Tx(ctx context.Context) (context.Context, transaction.Driver, error) {
18
+ txCtx, rawConfig, eDriver, err := e.db.HijackTx(ctx, &sql.TxOptions{
19
+ ReadOnly: false,
20
+ })
21
+ if err != nil {
22
+ return nil, nil, fmt.Errorf("failed to hijack transaction: %w", err)
23
+ }
24
+ return txCtx, entutils.NewTxDriver(eDriver, rawConfig), nil
25
+ }
26
+
27
+ func (e *featureDBAdapter) WithTx(ctx context.Context, tx *entutils.TxDriver) feature.FeatureRepo {
28
+ txClient := db.NewTxClientFromRawConfig(ctx, *tx.GetConfig())
29
+ return NewPostgresFeatureRepo(txClient.Client(), e.logger)
30
+ }
31
+
32
+ func (e *featureDBAdapter) Self() feature.FeatureRepo {
33
+ return e
34
+ }
openmeter/productcatalog/addon.go ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package productcatalog
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "maps"
8
+ "slices"
9
+ "time"
10
+
11
+ "github.com/invopop/gobl/currency"
12
+ "github.com/samber/lo"
13
+
14
+ "github.com/openmeterio/openmeter/pkg/clock"
15
+ "github.com/openmeterio/openmeter/pkg/models"
16
+ )
17
+
18
+ const (
19
+ AddonStatusDraft AddonStatus = "draft"
20
+ AddonStatusActive AddonStatus = "active"
21
+ AddonStatusArchived AddonStatus = "archived"
22
+ AddonStatusInvalid AddonStatus = "invalid"
23
+ )
24
+
25
+ type AddonStatus string
26
+
27
+ func (s AddonStatus) Values() []AddonStatus {
28
+ return []AddonStatus{
29
+ AddonStatusDraft,
30
+ AddonStatusActive,
31
+ AddonStatusArchived,
32
+ }
33
+ }
34
+
35
+ func (s AddonStatus) Validate() error {
36
+ if !slices.Contains(s.Values(), s) {
37
+ return fmt.Errorf("invalid addon status: %s", s)
38
+ }
39
+ return nil
40
+ }
41
+
42
+ var (
43
+ _ models.Validator = (*AddonMeta)(nil)
44
+ _ models.Equaler[AddonMeta] = (*AddonMeta)(nil)
45
+ )
46
+
47
+ type AddonMeta struct {
48
+ EffectivePeriod
49
+
50
+ // Key is the unique key for Add-on.
51
+ Key string `json:"key"`
52
+
53
+ // Version
54
+ Version int `json:"version"`
55
+
56
+ // Name
57
+ Name string `json:"name"`
58
+
59
+ // Description
60
+ Description *string `json:"description,omitempty"`
61
+
62
+ // Currency
63
+ Currency currency.Code `json:"currency"`
64
+
65
+ // InstanceType
66
+ InstanceType AddonInstanceType `json:"instanceType"`
67
+
68
+ // Metadata
69
+ Metadata models.Metadata `json:"metadata,omitempty"`
70
+
71
+ // Annotations
72
+ Annotations models.Annotations `json:"annotations,omitempty"`
73
+ }
74
+
75
+ func (m AddonMeta) Validate() error {
76
+ var errs []error
77
+
78
+ if err := m.EffectivePeriod.Validate(); err != nil {
79
+ errs = append(errs, fmt.Errorf("invalid effective period: %w", err))
80
+ }
81
+
82
+ if m.Key == "" {
83
+ errs = append(errs, ErrAddonKeyEmpty)
84
+ }
85
+
86
+ if m.Name == "" {
87
+ errs = append(errs, ErrAddonNameEmpty)
88
+ }
89
+
90
+ if err := m.Currency.Validate(); err != nil {
91
+ errs = append(errs, ErrCurrencyInvalid)
92
+ }
93
+
94
+ if err := m.InstanceType.Validate(); err != nil {
95
+ errs = append(errs, err)
96
+ }
97
+
98
+ return models.NewNillableGenericValidationError(errors.Join(errs...))
99
+ }
100
+
101
+ func (m AddonMeta) Equal(v AddonMeta) bool {
102
+ if m.Key != v.Key {
103
+ return false
104
+ }
105
+
106
+ if m.Version != v.Version {
107
+ return false
108
+ }
109
+
110
+ if m.Name != v.Name {
111
+ return false
112
+ }
113
+
114
+ if lo.FromPtr(m.Description) != lo.FromPtr(v.Description) {
115
+ return false
116
+ }
117
+
118
+ if m.Currency != v.Currency {
119
+ return false
120
+ }
121
+
122
+ if m.InstanceType != v.InstanceType {
123
+ return false
124
+ }
125
+
126
+ if !m.EffectivePeriod.Equal(v.EffectivePeriod) {
127
+ return false
128
+ }
129
+
130
+ if !m.Metadata.Equal(v.Metadata) {
131
+ return false
132
+ }
133
+
134
+ if !maps.Equal(m.Annotations, v.Annotations) {
135
+ return false
136
+ }
137
+
138
+ return true
139
+ }
140
+
141
+ // Status returns the current status of the Addons
142
+ func (m AddonMeta) Status() AddonStatus {
143
+ return m.StatusAt(clock.Now())
144
+ }
145
+
146
+ // StatusAt returns the Addon status relative to time t.
147
+ func (m AddonMeta) StatusAt(t time.Time) AddonStatus {
148
+ from := lo.FromPtr(m.EffectiveFrom)
149
+ to := lo.FromPtr(m.EffectiveTo)
150
+
151
+ // Add-on has DraftStatus if neither the EffectiveFrom nor EffectiveTo are set
152
+ if from.IsZero() && to.IsZero() {
153
+ return AddonStatusDraft
154
+ }
155
+
156
+ // Add-on has ArchivedStatus if EffectiveTo is in the past relative to time t.
157
+ if from.Before(t) && (to.Before(t) && from.Before(to)) {
158
+ return AddonStatusArchived
159
+ }
160
+
161
+ // Add-on has ActiveStatus if EffectiveFrom is set in the past relative to time t and EffectiveTo is not set
162
+ // or in the future relative to time t.
163
+ if from.Before(t) && (to.IsZero() || to.After(t)) {
164
+ return AddonStatusActive
165
+ }
166
+
167
+ return AddonStatusInvalid
168
+ }
169
+
170
+ var (
171
+ _ models.Validator = (*Addon)(nil)
172
+ _ models.CustomValidator[Addon] = (*Addon)(nil)
173
+ _ models.Equaler[Addon] = (*Addon)(nil)
174
+ )
175
+
176
+ type Addon struct {
177
+ AddonMeta
178
+
179
+ // RateCards
180
+ RateCards RateCards `json:"rateCards"`
181
+ }
182
+
183
+ func (a Addon) ValidateWith(validators ...models.ValidatorFunc[Addon]) error {
184
+ return models.Validate(a, validators...)
185
+ }
186
+
187
+ func (a Addon) HasUnitConfig() bool {
188
+ return a.RateCards.HasUnitConfig()
189
+ }
190
+
191
+ // ValidationErrors returns a list of possible validation errors for the add-on.
192
+ // It returns nil if the add-on has no validation issues.
193
+ func (a Addon) ValidationErrors() (models.ValidationIssues, error) {
194
+ return models.AsValidationIssues(a.Validate())
195
+ }
196
+
197
+ func (a Addon) Validate() error {
198
+ return a.ValidateWith(
199
+ ValidateAddonMeta(),
200
+ ValidateAddonRateCards(),
201
+ )
202
+ }
203
+
204
+ // Publishable validates the Addon to ensure that it meets all requirements needed for being published.
205
+ // It is a stricter version of Validate. It is the caller's responsibility to handle managed resource-specific parameters
206
+ // to ensure the Addon is eligible for publishing. E.g. checking the DeletedAt attribute of the addon.Addon.
207
+ func (a Addon) Publishable() error {
208
+ return a.ValidateWith(
209
+ ValidateAddonMeta(),
210
+ ValidateAddonRateCards(),
211
+ ValidateAddonStatusPublishable(),
212
+ ValidateAddonHasSingleBillingCadence(),
213
+ ValidateAddonHasCompatiblePrices(),
214
+ )
215
+ }
216
+
217
+ func (a Addon) Equal(v Addon) bool {
218
+ if !a.AddonMeta.Equal(v.AddonMeta) {
219
+ return false
220
+ }
221
+
222
+ return a.RateCards.Equal(v.RateCards)
223
+ }
224
+
225
+ type AddonInstanceType string
226
+
227
+ const (
228
+ AddonInstanceTypeSingle AddonInstanceType = "single"
229
+ AddonInstanceTypeMultiple AddonInstanceType = "multiple"
230
+ )
231
+
232
+ func (a AddonInstanceType) Validate() error {
233
+ switch a {
234
+ case AddonInstanceTypeSingle, AddonInstanceTypeMultiple:
235
+ return nil
236
+ default:
237
+ return ErrAddonInvalidInstanceType
238
+ }
239
+ }
240
+
241
+ func (a AddonInstanceType) Values() []string {
242
+ return []string{
243
+ string(AddonInstanceTypeSingle),
244
+ string(AddonInstanceTypeMultiple),
245
+ }
246
+ }
247
+
248
+ // ValidateAddonMeta returns a validation function can be passed to the object
249
+ // which implements models.CustomValidator interface. It validates attributes in AddonMeta of Addon.
250
+ func ValidateAddonMeta() models.ValidatorFunc[Addon] {
251
+ return func(a Addon) error {
252
+ return a.AddonMeta.Validate()
253
+ }
254
+ }
255
+
256
+ // ValidateAddonRateCards returns a validation function can be passed to the object
257
+ // which implements models.CustomValidator interface. It checks for invalid and duplicated ratecards.
258
+ func ValidateAddonRateCards() models.ValidatorFunc[Addon] {
259
+ return func(a Addon) error {
260
+ if len(a.RateCards) == 0 {
261
+ return ErrAddonHasNoRateCards
262
+ }
263
+
264
+ return ValidateRateCards()(a.RateCards)
265
+ }
266
+ }
267
+
268
+ func ValidateAddonStatusPublishable() models.ValidatorFunc[Addon] {
269
+ return func(a Addon) error {
270
+ if err := ValidateAddonWithStatus(AddonStatusDraft)(a); err != nil {
271
+ return ErrAddonInvalidStatusForPublish
272
+ }
273
+
274
+ return nil
275
+ }
276
+ }
277
+
278
+ func ValidateAddonWithStatus(allowed ...AddonStatus) models.ValidatorFunc[Addon] {
279
+ return func(a Addon) error {
280
+ status := a.Status()
281
+ if lo.Contains(allowed, status) {
282
+ return nil
283
+ }
284
+
285
+ return ErrAddonInvalidStatus
286
+ }
287
+ }
288
+
289
+ func ValidateAddonHasSingleBillingCadence() models.ValidatorFunc[Addon] {
290
+ return func(a Addon) error {
291
+ if a.RateCards.SingleBillingCadence() {
292
+ return nil
293
+ }
294
+
295
+ return models.ErrorWithFieldPrefix(
296
+ models.NewFieldSelectorGroup(models.NewFieldSelector("ratecards").WithExpression(models.WildCard)),
297
+ ErrRateCardMultipleBillingCadence,
298
+ )
299
+ }
300
+ }
301
+
302
+ func ValidateAddonHasCompatiblePrices() models.ValidatorFunc[Addon] {
303
+ return func(a Addon) error {
304
+ switch a.InstanceType {
305
+ case AddonInstanceTypeSingle:
306
+ return nil
307
+ case AddonInstanceTypeMultiple:
308
+ for _, rc := range a.RateCards {
309
+ if price := rc.AsMeta().Price; price != nil && price.Type() != FlatPriceType {
310
+ return models.ErrorWithFieldPrefix(
311
+ models.NewFieldSelectorGroup(models.NewFieldSelector("ratecards").
312
+ WithExpression(models.NewFieldAttrValue("key", rc.Key()))),
313
+ ErrAddonInvalidPriceForMultiInstance,
314
+ )
315
+ }
316
+ }
317
+
318
+ return nil
319
+ default:
320
+ return ErrAddonInvalidInstanceType
321
+ }
322
+ }
323
+ }
324
+
325
+ // Determines if an Addon RateCard will effect a given Plan RateCard
326
+ // Right now we only support a single RateCard per addon effecting a single plan RateCard and we match them by key.
327
+ // FIXME(galexi): matching like this is unwieldy as sometimes we'd want to match productcatalog.RateCard, sometimes addon.RateCard, or subscriptionaddon.RateCard...
328
+ func AddonRateCardMatcherForAGivenPlanRateCard(planRateCard RateCard) func(addonRateCard RateCard) bool {
329
+ return func(addonRateCard RateCard) bool {
330
+ return addonRateCard.Key() == planRateCard.Key()
331
+ }
332
+ }
333
+
334
+ func ValidateAddonWithFeatures(ctx context.Context, resolver NamespacedFeatureResolver) models.ValidatorFunc[Addon] {
335
+ return func(a Addon) error {
336
+ return ValidateRateCardsWithFeatures(ctx, resolver)(a.RateCards)
337
+ }
338
+ }
openmeter/productcatalog/addon/adapter/adapter.go ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package adapter
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "errors"
7
+ "fmt"
8
+ "log/slog"
9
+
10
+ entdb "github.com/openmeterio/openmeter/openmeter/ent/db"
11
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/addon"
12
+ "github.com/openmeterio/openmeter/pkg/framework/entutils"
13
+ "github.com/openmeterio/openmeter/pkg/framework/transaction"
14
+ "github.com/openmeterio/openmeter/pkg/models"
15
+ )
16
+
17
+ var _ models.Validator = (*Config)(nil)
18
+
19
+ type Config struct {
20
+ Client *entdb.Client
21
+ Logger *slog.Logger
22
+ }
23
+
24
+ func (c Config) Validate() error {
25
+ var errs []error
26
+
27
+ if c.Client == nil {
28
+ errs = append(errs, errors.New("postgres client is required"))
29
+ }
30
+
31
+ if c.Logger == nil {
32
+ errs = append(errs, errors.New("logger is required"))
33
+ }
34
+
35
+ if len(errs) > 0 {
36
+ return errors.Join(errs...)
37
+ }
38
+
39
+ return nil
40
+ }
41
+
42
+ func New(config Config) (addon.Repository, error) {
43
+ if err := config.Validate(); err != nil {
44
+ return nil, err
45
+ }
46
+
47
+ return &adapter{
48
+ db: config.Client,
49
+ logger: config.Logger,
50
+ }, nil
51
+ }
52
+
53
+ var _ addon.Repository = (*adapter)(nil)
54
+
55
+ type adapter struct {
56
+ db *entdb.Client
57
+
58
+ logger *slog.Logger
59
+ }
60
+
61
+ func (a *adapter) Tx(ctx context.Context) (context.Context, transaction.Driver, error) {
62
+ ctx, rawConfig, eDriver, err := a.db.HijackTx(ctx, &sql.TxOptions{
63
+ ReadOnly: false,
64
+ })
65
+ if err != nil {
66
+ return nil, nil, fmt.Errorf("failed to hijack transaction: %w", err)
67
+ }
68
+
69
+ return ctx, entutils.NewTxDriver(eDriver, rawConfig), nil
70
+ }
71
+
72
+ func (a *adapter) WithTx(ctx context.Context, tx *entutils.TxDriver) *adapter {
73
+ txClient := entdb.NewTxClientFromRawConfig(ctx, *tx.GetConfig())
74
+
75
+ return &adapter{
76
+ db: txClient.Client(),
77
+ logger: a.logger,
78
+ }
79
+ }
80
+
81
+ func (a *adapter) Self() *adapter {
82
+ return a
83
+ }
openmeter/productcatalog/addon/adapter/adapter_test.go ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package adapter_test
2
+
3
+ import (
4
+ "context"
5
+ "testing"
6
+ "time"
7
+
8
+ decimal "github.com/alpacahq/alpacadecimal"
9
+ "github.com/samber/lo"
10
+ "github.com/stretchr/testify/assert"
11
+ "github.com/stretchr/testify/require"
12
+
13
+ entdb "github.com/openmeterio/openmeter/openmeter/ent/db"
14
+ "github.com/openmeterio/openmeter/openmeter/meter"
15
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
16
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/addon"
17
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/addon/adapter"
18
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
19
+ pctestutils "github.com/openmeterio/openmeter/openmeter/productcatalog/testutils"
20
+ "github.com/openmeterio/openmeter/openmeter/testutils"
21
+ "github.com/openmeterio/openmeter/pkg/clock"
22
+ "github.com/openmeterio/openmeter/pkg/datetime"
23
+ "github.com/openmeterio/openmeter/pkg/filter"
24
+ "github.com/openmeterio/openmeter/pkg/models"
25
+ "github.com/openmeterio/openmeter/pkg/pagination"
26
+ )
27
+
28
+ var MonthPeriod = datetime.ISODurationFromDuration(30 * 24 * time.Hour)
29
+
30
+ func TestPostgresAdapter(t *testing.T) {
31
+ ctx, cancel := context.WithCancel(context.Background())
32
+ defer cancel()
33
+
34
+ env := pctestutils.NewTestEnv(t)
35
+ t.Cleanup(func() {
36
+ env.Close(t)
37
+ })
38
+
39
+ t.Run("Addon", func(t *testing.T) {
40
+ t.Run("Create", func(t *testing.T) {
41
+ // Get new namespace ID
42
+ namespace := pctestutils.NewTestNamespace(t)
43
+
44
+ // Setup meter repository
45
+ err := env.Meter.ReplaceMeters(ctx, pctestutils.NewTestMeters(t, namespace))
46
+ require.NoError(t, err, "replacing meters must not fail")
47
+
48
+ result, err := env.Meter.ListMeters(ctx, meter.ListMetersParams{
49
+ Page: pagination.Page{
50
+ PageSize: 1000,
51
+ PageNumber: 1,
52
+ },
53
+ Namespace: namespace,
54
+ })
55
+ require.NoErrorf(t, err, "listing meters must not fail")
56
+
57
+ meters := result.Items
58
+ require.NotEmptyf(t, meters, "list of Meters must not be empty")
59
+
60
+ // Set a feature for each meter
61
+ features := make([]feature.Feature, 0, len(meters))
62
+ for _, m := range meters {
63
+ input := pctestutils.NewTestFeatureFromMeter(t, &m)
64
+
65
+ feat, err := env.Feature.CreateFeature(ctx, input)
66
+ require.NoErrorf(t, err, "creating feature must not fail")
67
+ require.NotNil(t, feat, "feature must not be empty")
68
+
69
+ features = append(features, feat)
70
+ }
71
+
72
+ addonV1Input := pctestutils.NewTestAddon(t, namespace, productcatalog.RateCards{
73
+ &productcatalog.UsageBasedRateCard{
74
+ RateCardMeta: productcatalog.RateCardMeta{
75
+ Key: features[0].Key,
76
+ Name: features[0].Name,
77
+ Description: lo.ToPtr(features[0].Name),
78
+ Metadata: models.Metadata{"name": features[0].Name},
79
+ FeatureKey: lo.ToPtr(features[0].Key),
80
+ FeatureID: lo.ToPtr(features[0].ID),
81
+ EntitlementTemplate: productcatalog.NewEntitlementTemplateFrom(productcatalog.BooleanEntitlementTemplate{}),
82
+ TaxConfig: &productcatalog.TaxConfig{
83
+ Stripe: &productcatalog.StripeTaxConfig{
84
+ Code: "txcd_10000000",
85
+ },
86
+ },
87
+ Price: productcatalog.NewPriceFrom(productcatalog.TieredPrice{
88
+ Mode: productcatalog.VolumeTieredPrice,
89
+ Tiers: []productcatalog.PriceTier{
90
+ {
91
+ UpToAmount: lo.ToPtr(decimal.NewFromInt(1000)),
92
+ FlatPrice: &productcatalog.PriceTierFlatPrice{
93
+ Amount: decimal.NewFromInt(100),
94
+ },
95
+ UnitPrice: &productcatalog.PriceTierUnitPrice{
96
+ Amount: decimal.NewFromInt(50),
97
+ },
98
+ },
99
+ {
100
+ UpToAmount: nil,
101
+ FlatPrice: &productcatalog.PriceTierFlatPrice{
102
+ Amount: decimal.NewFromInt(5),
103
+ },
104
+ UnitPrice: &productcatalog.PriceTierUnitPrice{
105
+ Amount: decimal.NewFromInt(25),
106
+ },
107
+ },
108
+ },
109
+ Commitments: productcatalog.Commitments{
110
+ MinimumAmount: lo.ToPtr(decimal.NewFromInt(1000)),
111
+ MaximumAmount: nil,
112
+ },
113
+ }),
114
+ UnitConfig: &productcatalog.UnitConfig{
115
+ Operation: productcatalog.UnitConfigOperationDivide,
116
+ ConversionFactor: decimal.NewFromInt(1000),
117
+ Rounding: productcatalog.UnitConfigRoundingModeCeiling,
118
+ Precision: 0,
119
+ DisplayUnit: lo.ToPtr("K"),
120
+ },
121
+ },
122
+ BillingCadence: MonthPeriod,
123
+ },
124
+ }...)
125
+
126
+ var addonV1 *addon.Addon
127
+
128
+ addonV1, err = env.AddonRepository.CreateAddon(ctx, addonV1Input)
129
+ require.NoErrorf(t, err, "creating new add-on must not fail")
130
+
131
+ require.NotNilf(t, addonV1, "add-on must not be nil")
132
+
133
+ addon.AssertAddonCreateInputEqual(t, addonV1Input, *addonV1)
134
+
135
+ t.Run("Get", func(t *testing.T) {
136
+ t.Run("ById", func(t *testing.T) {
137
+ getAddonV1, err := env.AddonRepository.GetAddon(ctx, addon.GetAddonInput{
138
+ NamespacedID: models.NamespacedID{
139
+ Namespace: namespace,
140
+ ID: addonV1.ID,
141
+ },
142
+ })
143
+ assert.NoErrorf(t, err, "getting add-on by id must not fail")
144
+
145
+ require.NotNilf(t, getAddonV1, "add-on must not be nil")
146
+
147
+ addon.AssertAddonEqual(t, *addonV1, *getAddonV1)
148
+ })
149
+
150
+ t.Run("ByKey", func(t *testing.T) {
151
+ getAddonV1, err := env.AddonRepository.GetAddon(ctx, addon.GetAddonInput{
152
+ NamespacedID: models.NamespacedID{
153
+ Namespace: namespace,
154
+ },
155
+ Key: addonV1Input.Key,
156
+ IncludeLatest: true,
157
+ })
158
+ assert.NoErrorf(t, err, "getting add-on by key must not fail")
159
+
160
+ require.NotNilf(t, getAddonV1, "add-on must not be nil")
161
+
162
+ addon.AssertAddonEqual(t, *addonV1, *getAddonV1)
163
+ })
164
+
165
+ t.Run("ByKeyVersion", func(t *testing.T) {
166
+ getAddonV1, err := env.AddonRepository.GetAddon(ctx, addon.GetAddonInput{
167
+ NamespacedID: models.NamespacedID{
168
+ Namespace: namespace,
169
+ },
170
+ Key: addonV1Input.Key,
171
+ Version: 1,
172
+ })
173
+ assert.NoErrorf(t, err, "getting plan by key and version must not fail")
174
+
175
+ require.NotNilf(t, getAddonV1, "plan must not be nil")
176
+
177
+ addon.AssertAddonEqual(t, *addonV1, *getAddonV1)
178
+ })
179
+ })
180
+
181
+ t.Run("List", func(t *testing.T) {
182
+ t.Run("ByIdFilter", func(t *testing.T) {
183
+ listAddonV1, err := env.AddonRepository.ListAddons(ctx, addon.ListAddonsInput{
184
+ Namespaces: []string{namespace},
185
+ ID: &filter.FilterULID{
186
+ FilterString: filter.FilterString{
187
+ Eq: &addonV1.ID,
188
+ },
189
+ },
190
+ })
191
+ assert.NoErrorf(t, err, "listing add-on by id filter must not fail")
192
+
193
+ require.Lenf(t, listAddonV1.Items, 1, "add-ons must not be empty")
194
+
195
+ addon.AssertAddonEqual(t, *addonV1, listAddonV1.Items[0])
196
+ })
197
+
198
+ t.Run("ByKeyFilter", func(t *testing.T) {
199
+ listAddonV1, err := env.AddonRepository.ListAddons(ctx, addon.ListAddonsInput{
200
+ Namespaces: []string{namespace},
201
+ Key: &filter.FilterString{
202
+ Eq: &addonV1Input.Key,
203
+ },
204
+ })
205
+ assert.NoErrorf(t, err, "getting add-on by key filter must not fail")
206
+
207
+ require.Lenf(t, listAddonV1.Items, 1, "add-ons must not be empty")
208
+
209
+ addon.AssertAddonEqual(t, *addonV1, listAddonV1.Items[0])
210
+ })
211
+
212
+ t.Run("ByNameFilter", func(t *testing.T) {
213
+ listAddonV1, err := env.AddonRepository.ListAddons(ctx, addon.ListAddonsInput{
214
+ Namespaces: []string{namespace},
215
+ Name: &filter.FilterString{
216
+ Eq: &addonV1Input.Name,
217
+ },
218
+ })
219
+ assert.NoErrorf(t, err, "getting add-on by name filter must not fail")
220
+
221
+ require.Lenf(t, listAddonV1.Items, 1, "add-ons must not be empty")
222
+
223
+ addon.AssertAddonEqual(t, *addonV1, listAddonV1.Items[0])
224
+ })
225
+
226
+ t.Run("ByCurrencyFilter", func(t *testing.T) {
227
+ currencyStr := string(addonV1Input.Currency)
228
+ listAddonV1, err := env.AddonRepository.ListAddons(ctx, addon.ListAddonsInput{
229
+ Namespaces: []string{namespace},
230
+ Currency: &filter.FilterString{
231
+ Eq: &currencyStr,
232
+ },
233
+ })
234
+ assert.NoErrorf(t, err, "getting add-on by currency filter must not fail")
235
+
236
+ require.NotEmpty(t, listAddonV1.Items, "add-ons must not be empty")
237
+ })
238
+
239
+ t.Run("ByKeyVersion", func(t *testing.T) {
240
+ listAddonV1, err := env.AddonRepository.ListAddons(ctx, addon.ListAddonsInput{
241
+ Namespaces: []string{namespace},
242
+ KeyVersions: map[string][]int{addonV1Input.Key: {1}},
243
+ })
244
+ assert.NoErrorf(t, err, "getting add-on by key and version must not fail")
245
+
246
+ require.Lenf(t, listAddonV1.Items, 1, "add-ons must not be empty")
247
+
248
+ addon.AssertAddonEqual(t, *addonV1, listAddonV1.Items[0])
249
+ })
250
+ })
251
+
252
+ t.Run("Update", func(t *testing.T) {
253
+ now := time.Now()
254
+
255
+ addonV1Update := addon.UpdateAddonInput{
256
+ NamespacedID: models.NamespacedID{
257
+ Namespace: namespace,
258
+ ID: addonV1.ID,
259
+ },
260
+ EffectivePeriod: productcatalog.EffectivePeriod{
261
+ EffectiveFrom: lo.ToPtr(now.UTC()),
262
+ EffectiveTo: lo.ToPtr(now.Add(30 * 24 * time.Hour).UTC()),
263
+ },
264
+ Name: lo.ToPtr("Addon v1 Published"),
265
+ Description: lo.ToPtr("Addon v1 Published"),
266
+ Metadata: &models.Metadata{
267
+ "name": "Addon v1 Published",
268
+ "description": "Addon v1 Published",
269
+ },
270
+ RateCards: &productcatalog.RateCards{
271
+ &productcatalog.FlatFeeRateCard{
272
+ RateCardMeta: productcatalog.RateCardMeta{
273
+ Key: "ratecard-2",
274
+ Name: "RateCard 2",
275
+ Description: lo.ToPtr("RateCard 2"),
276
+ Metadata: models.Metadata{"name": "ratecard-2"},
277
+ FeatureKey: nil,
278
+ FeatureID: nil,
279
+ EntitlementTemplate: nil,
280
+ TaxConfig: &productcatalog.TaxConfig{
281
+ Stripe: &productcatalog.StripeTaxConfig{
282
+ Code: "txcd_10000000",
283
+ },
284
+ },
285
+ Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
286
+ Amount: decimal.NewFromInt(0),
287
+ PaymentTerm: productcatalog.InArrearsPaymentTerm,
288
+ }),
289
+ },
290
+ BillingCadence: &MonthPeriod,
291
+ },
292
+ },
293
+ }
294
+
295
+ addonV1, err = env.AddonRepository.UpdateAddon(ctx, addonV1Update)
296
+ require.NoErrorf(t, err, "updating add-on must not fail")
297
+
298
+ require.NotNilf(t, addonV1, "add-on must not be nil")
299
+
300
+ addon.AssertAddonUpdateInputEqual(t, addonV1Update, *addonV1)
301
+ })
302
+
303
+ t.Run("Delete", func(t *testing.T) {
304
+ err = env.AddonRepository.DeleteAddon(ctx, addon.DeleteAddonInput{
305
+ NamespacedID: models.NamespacedID{
306
+ Namespace: addonV1.Namespace,
307
+ ID: addonV1.ID,
308
+ },
309
+ })
310
+ require.NoErrorf(t, err, "deleting ad-on must not fail")
311
+
312
+ getAddonV1, err := env.AddonRepository.GetAddon(ctx, addon.GetAddonInput{
313
+ NamespacedID: models.NamespacedID{
314
+ Namespace: namespace,
315
+ ID: addonV1.ID,
316
+ },
317
+ })
318
+ require.NoErrorf(t, err, "getting add-on by id must not fail")
319
+
320
+ require.NotNilf(t, getAddonV1, "add-on must not be nil")
321
+
322
+ addon.AssertAddonEqual(t, *addonV1, *getAddonV1)
323
+ })
324
+ })
325
+
326
+ t.Run("ListAddonStatusFilter", func(t *testing.T) {
327
+ // Get new namespace ID
328
+ namespace := pctestutils.NewTestNamespace(t)
329
+
330
+ addonV1Input := pctestutils.NewTestAddon(t, namespace, productcatalog.RateCards{
331
+ &productcatalog.FlatFeeRateCard{
332
+ RateCardMeta: productcatalog.RateCardMeta{
333
+ Key: "ratecard",
334
+ Name: "ratecard",
335
+ },
336
+ },
337
+ }...)
338
+
339
+ inputs := []struct {
340
+ Version int
341
+ EffectivePeriod productcatalog.EffectivePeriod
342
+ }{
343
+ {
344
+ Version: 1,
345
+ EffectivePeriod: productcatalog.EffectivePeriod{
346
+ EffectiveFrom: lo.ToPtr(testutils.GetRFC3339Time(t, "2025-03-15T00:00:00Z")),
347
+ EffectiveTo: lo.ToPtr(testutils.GetRFC3339Time(t, "2025-03-15T12:00:00Z")),
348
+ },
349
+ },
350
+ {
351
+ Version: 2,
352
+ EffectivePeriod: productcatalog.EffectivePeriod{
353
+ EffectiveFrom: lo.ToPtr(testutils.GetRFC3339Time(t, "2025-03-15T12:00:00Z")),
354
+ },
355
+ },
356
+ {
357
+ Version: 3,
358
+ EffectivePeriod: productcatalog.EffectivePeriod{},
359
+ },
360
+ }
361
+
362
+ for _, in := range inputs {
363
+ addonV1Input.Addon.AddonMeta.Version = in.Version
364
+
365
+ addonVersion, err := env.AddonRepository.CreateAddon(ctx, addonV1Input)
366
+ require.NoErrorf(t, err, "creating new add-on must not fail")
367
+
368
+ _, err = env.AddonRepository.UpdateAddon(ctx, addon.UpdateAddonInput{
369
+ NamespacedID: models.NamespacedID{
370
+ Namespace: namespace,
371
+ ID: addonVersion.ID,
372
+ },
373
+ EffectivePeriod: in.EffectivePeriod,
374
+ })
375
+ require.NoErrorf(t, err, "updating new add-on must not fail")
376
+ }
377
+
378
+ tests := []struct {
379
+ name string
380
+ at time.Time
381
+ filter []productcatalog.AddonStatus
382
+ expectVersion []int
383
+ }{
384
+ {
385
+ name: "Active",
386
+ at: testutils.GetRFC3339Time(t, "2025-03-16T00:00:00Z"),
387
+ filter: []productcatalog.AddonStatus{
388
+ productcatalog.AddonStatusActive,
389
+ },
390
+ expectVersion: []int{2},
391
+ },
392
+ {
393
+ name: "Draft",
394
+ at: testutils.GetRFC3339Time(t, "2025-03-16T00:00:00Z"),
395
+ filter: []productcatalog.AddonStatus{
396
+ productcatalog.AddonStatusDraft,
397
+ },
398
+ expectVersion: []int{3},
399
+ },
400
+ {
401
+ name: "Archived",
402
+ at: testutils.GetRFC3339Time(t, "2025-03-16T00:00:00Z"),
403
+ filter: []productcatalog.AddonStatus{
404
+ productcatalog.AddonStatusArchived,
405
+ },
406
+ expectVersion: []int{1},
407
+ },
408
+ {
409
+ name: "All",
410
+ at: testutils.GetRFC3339Time(t, "2025-03-16T00:00:00Z"),
411
+ filter: []productcatalog.AddonStatus{
412
+ productcatalog.AddonStatusActive,
413
+ productcatalog.AddonStatusDraft,
414
+ productcatalog.AddonStatusArchived,
415
+ },
416
+ expectVersion: []int{1, 2, 3},
417
+ },
418
+ {
419
+ name: "Scheduled",
420
+ at: testutils.GetRFC3339Time(t, "2025-03-15T01:00:00Z"),
421
+ filter: []productcatalog.AddonStatus{
422
+ productcatalog.AddonStatusInvalid,
423
+ },
424
+ expectVersion: []int{},
425
+ },
426
+ }
427
+
428
+ defer clock.ResetTime()
429
+
430
+ for _, test := range tests {
431
+ t.Run(test.name, func(t *testing.T) {
432
+ clock.SetTime(test.at)
433
+
434
+ list, err := env.AddonRepository.ListAddons(ctx, addon.ListAddonsInput{
435
+ Namespaces: []string{namespace},
436
+ Status: test.filter,
437
+ })
438
+ require.NoError(t, err, "listing add-ons must not fail")
439
+
440
+ versions := lo.Map(list.Items, func(item addon.Addon, _ int) int {
441
+ return item.Version
442
+ })
443
+
444
+ require.ElementsMatch(t, test.expectVersion, versions)
445
+ })
446
+ }
447
+ })
448
+ })
449
+ }
450
+
451
+ func TestListAddonsExcludeUnitConfig(t *testing.T) {
452
+ ctx := context.Background()
453
+
454
+ env := pctestutils.NewTestEnv(t)
455
+ t.Cleanup(func() { env.Close(t) })
456
+
457
+ namespace := pctestutils.NewTestNamespace(t)
458
+
459
+ require.NoError(t, env.Meter.ReplaceMeters(ctx, pctestutils.NewTestMeters(t, namespace)),
460
+ "replacing meters must not fail")
461
+ meters, err := env.Meter.ListMeters(ctx, meter.ListMetersParams{
462
+ Page: pagination.Page{PageSize: 1000, PageNumber: 1},
463
+ Namespace: namespace,
464
+ })
465
+ require.NoError(t, err, "listing meters must not fail")
466
+ require.NotEmpty(t, meters.Items, "list of meters must not be empty")
467
+
468
+ feat, err := env.Feature.CreateFeature(ctx, pctestutils.NewTestFeatureFromMeter(t, &meters.Items[0]))
469
+ require.NoError(t, err, "creating feature must not fail")
470
+
471
+ // Plain add-on: flat rate card, no unit_config → v1-representable.
472
+ plainInput := pctestutils.NewTestAddon(t, namespace, &productcatalog.FlatFeeRateCard{
473
+ RateCardMeta: productcatalog.RateCardMeta{Key: "flat", Name: "Flat"},
474
+ })
475
+ plainInput.Addon.Key = "plain"
476
+ _, err = env.AddonRepository.CreateAddon(ctx, plainInput)
477
+ require.NoError(t, err, "creating plain add-on must not fail")
478
+
479
+ // unit_config add-on: usage-based rate card carrying a unit_config → not v1-representable.
480
+ ucInput := pctestutils.NewTestAddon(t, namespace, &productcatalog.UsageBasedRateCard{
481
+ RateCardMeta: productcatalog.RateCardMeta{
482
+ Key: feat.Key,
483
+ Name: "UC RateCard",
484
+ FeatureKey: lo.ToPtr(feat.Key),
485
+ FeatureID: lo.ToPtr(feat.ID),
486
+ Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: decimal.NewFromInt(1)}),
487
+ UnitConfig: &productcatalog.UnitConfig{
488
+ Operation: productcatalog.UnitConfigOperationDivide,
489
+ ConversionFactor: decimal.NewFromInt(1000),
490
+ },
491
+ },
492
+ BillingCadence: MonthPeriod,
493
+ })
494
+ ucInput.Addon.Key = "with-uc"
495
+ _, err = env.AddonRepository.CreateAddon(ctx, ucInput)
496
+ require.NoError(t, err, "creating unit_config add-on must not fail")
497
+
498
+ t.Run("included when ExcludeUnitConfig is false", func(t *testing.T) {
499
+ list, err := env.AddonRepository.ListAddons(ctx, addon.ListAddonsInput{
500
+ Namespaces: []string{namespace},
501
+ })
502
+ require.NoError(t, err, "listing add-ons must not fail")
503
+
504
+ keys := lo.Map(list.Items, func(a addon.Addon, _ int) string { return a.Key })
505
+ require.ElementsMatch(t, []string{"plain", "with-uc"}, keys)
506
+ require.Equal(t, 2, list.TotalCount, "TotalCount must count both add-ons")
507
+ })
508
+
509
+ t.Run("excluded when ExcludeUnitConfig is true, TotalCount stays consistent", func(t *testing.T) {
510
+ list, err := env.AddonRepository.ListAddons(ctx, addon.ListAddonsInput{
511
+ Namespaces: []string{namespace},
512
+ ExcludeUnitConfig: true,
513
+ })
514
+ require.NoError(t, err, "listing add-ons must not fail")
515
+
516
+ keys := lo.Map(list.Items, func(a addon.Addon, _ int) string { return a.Key })
517
+ require.ElementsMatch(t, []string{"plain"}, keys)
518
+ require.Equal(t, 1, list.TotalCount, "TotalCount must exclude the unit_config add-on, not just the page slice")
519
+ })
520
+ }
521
+
522
+ // TestFromPlanRateCardRowMapsUnitConfig guards the cross-package mapper used when an
523
+ // add-on is loaded with expanded linked plans
524
+ // (FromAddonRow → FromPlanAddonRow → FromPlanRow → FromPlanPhaseRow → FromPlanRateCardRow).
525
+ // This mapper is separate from the own-type add-on rate-card mapper, so a RateCardMeta field
526
+ // added to one is not automatically carried by the other; UnitConfig dropping here would
527
+ // surface a stored config as nil and rate raw usage instead of converted units.
528
+ func TestFromPlanRateCardRowMapsUnitConfig(t *testing.T) {
529
+ unitConfig := &productcatalog.UnitConfig{
530
+ Operation: productcatalog.UnitConfigOperationDivide,
531
+ ConversionFactor: decimal.NewFromInt(1000),
532
+ Rounding: productcatalog.UnitConfigRoundingModeCeiling,
533
+ Precision: 0,
534
+ DisplayUnit: lo.ToPtr("K"),
535
+ }
536
+
537
+ rc, err := adapter.FromPlanRateCardRow(entdb.PlanRateCard{
538
+ Key: "rc",
539
+ Name: "RC",
540
+ Type: productcatalog.UsageBasedRateCardType,
541
+ Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: decimal.NewFromInt(1)}),
542
+ UnitConfig: unitConfig,
543
+ })
544
+ require.NoError(t, err, "mapping plan rate card row must not fail")
545
+
546
+ require.Equal(t, unitConfig, rc.AsMeta().UnitConfig,
547
+ "UnitConfig must survive the add-on adapter's linked plan mapper")
548
+ }
openmeter/productcatalog/addon/adapter/addon.go ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package adapter
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "slices"
7
+ "time"
8
+
9
+ "entgo.io/ent/dialect/sql"
10
+
11
+ entdb "github.com/openmeterio/openmeter/openmeter/ent/db"
12
+ addondb "github.com/openmeterio/openmeter/openmeter/ent/db/addon"
13
+ addonratecarddb "github.com/openmeterio/openmeter/openmeter/ent/db/addonratecard"
14
+ planaddondb "github.com/openmeterio/openmeter/openmeter/ent/db/planaddon"
15
+ phasedb "github.com/openmeterio/openmeter/openmeter/ent/db/planphase"
16
+ ratecarddb "github.com/openmeterio/openmeter/openmeter/ent/db/planratecard"
17
+ "github.com/openmeterio/openmeter/openmeter/ent/db/predicate"
18
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
19
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/addon"
20
+ "github.com/openmeterio/openmeter/pkg/clock"
21
+ "github.com/openmeterio/openmeter/pkg/filter"
22
+ "github.com/openmeterio/openmeter/pkg/framework/entutils"
23
+ "github.com/openmeterio/openmeter/pkg/models"
24
+ "github.com/openmeterio/openmeter/pkg/pagination"
25
+ "github.com/openmeterio/openmeter/pkg/sortx"
26
+ )
27
+
28
+ func (a *adapter) ListAddons(ctx context.Context, params addon.ListAddonsInput) (pagination.Result[addon.Addon], error) {
29
+ fn := func(ctx context.Context, a *adapter) (pagination.Result[addon.Addon], error) {
30
+ if err := params.Validate(); err != nil {
31
+ return pagination.Result[addon.Addon]{}, fmt.Errorf("invalid list add-on parameters: %w", err)
32
+ }
33
+
34
+ query := a.db.Addon.Query()
35
+
36
+ if len(params.Namespaces) > 0 {
37
+ query = query.Where(addondb.NamespaceIn(params.Namespaces...))
38
+ }
39
+
40
+ if len(params.KeyVersions) > 0 {
41
+ var kvFilters []predicate.Addon
42
+ for key, version := range params.KeyVersions {
43
+ kvFilters = append(kvFilters, addondb.And(addondb.Key(key), addondb.VersionIn(version...)))
44
+ }
45
+ query = query.Where(addondb.Or(kvFilters...))
46
+ }
47
+
48
+ query = filter.ApplyToQuery(query, params.ID, addondb.FieldID)
49
+ query = filter.ApplyToQuery(query, params.Key, addondb.FieldKey)
50
+ query = filter.ApplyToQuery(query, params.Name, addondb.FieldName)
51
+ query = filter.ApplyToQuery(query, params.Currency, addondb.FieldCurrency)
52
+
53
+ if params.ExcludeUnitConfig {
54
+ query = query.Where(addondb.Not(addondb.HasRatecardsWith(
55
+ addonratecarddb.UnitConfigNotNil(),
56
+ addonratecarddb.DeletedAtIsNil(),
57
+ )))
58
+ }
59
+
60
+ if !params.IncludeDeleted {
61
+ query = query.Where(addondb.DeletedAtIsNil())
62
+ }
63
+
64
+ if len(params.Status) > 0 {
65
+ var predicates []predicate.Addon
66
+
67
+ now := clock.Now().UTC()
68
+
69
+ if slices.Contains(params.Status, productcatalog.AddonStatusActive) {
70
+ predicates = append(predicates, addondb.And(
71
+ addondb.EffectiveFromLTE(now),
72
+ addondb.Or(
73
+ addondb.EffectiveToGTE(now),
74
+ addondb.EffectiveToIsNil(),
75
+ ),
76
+ ))
77
+ }
78
+
79
+ if slices.Contains(params.Status, productcatalog.AddonStatusDraft) {
80
+ predicates = append(predicates, addondb.And(
81
+ addondb.EffectiveFromIsNil(),
82
+ addondb.EffectiveToIsNil(),
83
+ ))
84
+ }
85
+
86
+ if slices.Contains(params.Status, productcatalog.AddonStatusArchived) {
87
+ predicates = append(predicates, addondb.EffectiveToLT(now))
88
+ }
89
+
90
+ if slices.Contains(params.Status, productcatalog.AddonStatusInvalid) {
91
+ predicates = append(predicates, func(s *sql.Selector) {
92
+ s.Where(sql.ColumnsLT(addondb.FieldEffectiveTo, addondb.FieldEffectiveFrom))
93
+ })
94
+ }
95
+
96
+ if len(predicates) > 0 {
97
+ query = query.Where(addondb.Or(predicates...))
98
+ }
99
+ }
100
+
101
+ // Eager load ratecards
102
+ query = query.WithRatecards(
103
+ AddonEagerLoadRateCardsFn,
104
+ )
105
+
106
+ order := entutils.GetOrdering(sortx.OrderDefault)
107
+ if !params.Order.IsDefaultValue() {
108
+ order = entutils.GetOrdering(params.Order)
109
+ }
110
+
111
+ switch params.OrderBy {
112
+ case addon.OrderByCreatedAt:
113
+ query = query.Order(addondb.ByCreatedAt(order...))
114
+ case addon.OrderByUpdatedAt:
115
+ query = query.Order(addondb.ByUpdatedAt(order...))
116
+ case addon.OrderByVersion:
117
+ query = query.Order(addondb.ByVersion(order...))
118
+ case addon.OrderByKey:
119
+ query = query.Order(addondb.ByKey(order...))
120
+ case addon.OrderByName:
121
+ query = query.Order(addondb.ByName(order...))
122
+ case addon.OrderByID:
123
+ fallthrough
124
+ default:
125
+ query = query.Order(addondb.ByID(order...))
126
+ }
127
+
128
+ response := pagination.Result[addon.Addon]{
129
+ Page: params.Page,
130
+ }
131
+
132
+ paged, err := query.Paginate(ctx, params.Page)
133
+ if err != nil {
134
+ return response, fmt.Errorf("failed to list add-ons: %w", err)
135
+ }
136
+
137
+ result := make([]addon.Addon, 0, len(paged.Items))
138
+ for _, item := range paged.Items {
139
+ if item == nil {
140
+ a.logger.WarnContext(ctx, "invalid query result: nil add-on received")
141
+ continue
142
+ }
143
+
144
+ p, err := FromAddonRow(*item)
145
+ if err != nil {
146
+ return response, fmt.Errorf("failed to cast add-on: %w", err)
147
+ }
148
+
149
+ result = append(result, *p)
150
+ }
151
+
152
+ response.TotalCount = paged.TotalCount
153
+ response.Items = result
154
+
155
+ return response, nil
156
+ }
157
+
158
+ return entutils.TransactingRepo[pagination.Result[addon.Addon], *adapter](ctx, a, fn)
159
+ }
160
+
161
+ func (a *adapter) CreateAddon(ctx context.Context, params addon.CreateAddonInput) (*addon.Addon, error) {
162
+ fn := func(ctx context.Context, a *adapter) (*addon.Addon, error) {
163
+ if err := params.Validate(); err != nil {
164
+ return nil, fmt.Errorf("invalid create add-on parameters: %w", err)
165
+ }
166
+
167
+ if params.Version == 0 {
168
+ params.Version = 1
169
+ }
170
+
171
+ addonRow, err := a.db.Addon.Create().
172
+ SetKey(params.Key).
173
+ SetNamespace(params.Namespace).
174
+ SetName(params.Name).
175
+ SetNillableDescription(params.Description).
176
+ SetCurrency(params.Currency.String()).
177
+ SetMetadata(params.Metadata).
178
+ SetVersion(params.Version).
179
+ SetAnnotations(params.Annotations).
180
+ SetInstanceType(params.InstanceType).
181
+ Save(ctx)
182
+ if err != nil {
183
+ return nil, fmt.Errorf("failed to create add-on [namespace=%s]: %w", params.Namespace, err)
184
+ }
185
+
186
+ if addonRow == nil {
187
+ return nil, fmt.Errorf("invalid query result: nil add-on received [namespace=%s]", params.Namespace)
188
+ }
189
+
190
+ if len(params.RateCards) > 0 {
191
+ bulk, err := rateCardBulkCreate(a.db.AddonRateCard, params.RateCards, addonRow.ID, params.Namespace)
192
+ if err != nil {
193
+ return nil, fmt.Errorf("failed to bulk create ratecards [namespace=%s id:%s]: %w", params.Namespace, addonRow.ID, err)
194
+ }
195
+
196
+ if err = a.db.AddonRateCard.CreateBulk(bulk...).Exec(ctx); err != nil {
197
+ return nil, fmt.Errorf("failed to bulk create ratecards [namespace=%s id:%s]: %w", params.Namespace, addonRow.ID, err)
198
+ }
199
+ }
200
+
201
+ // Refetch newly created addon
202
+ addonRow, err = a.db.Addon.Query().
203
+ Where(addondb.And(
204
+ addondb.Namespace(params.Namespace),
205
+ addondb.ID(addonRow.ID)),
206
+ ).
207
+ WithRatecards(
208
+ AddonEagerLoadRateCardsFn,
209
+ ).
210
+ First(ctx)
211
+ if err != nil {
212
+ return nil, fmt.Errorf("failed to create add-on [namespace=%s]: %w", params.Namespace, err)
213
+ }
214
+
215
+ add, err := FromAddonRow(*addonRow)
216
+ if err != nil {
217
+ return nil, fmt.Errorf("failed to cast add-on [namespace=%s id:%s]: %w", params.Namespace, addonRow.ID, err)
218
+ }
219
+
220
+ return add, nil
221
+ }
222
+
223
+ return entutils.TransactingRepo[*addon.Addon, *adapter](ctx, a, fn)
224
+ }
225
+
226
+ func rateCardBulkCreate(c *entdb.AddonRateCardClient, rateCards productcatalog.RateCards, addonID string, ns string) ([]*entdb.AddonRateCardCreate, error) {
227
+ bulk := make([]*entdb.AddonRateCardCreate, 0, len(rateCards))
228
+
229
+ for _, rateCard := range rateCards {
230
+ rateCardEntity, err := asAddonRateCardRow(rateCard)
231
+ if err != nil {
232
+ return nil, fmt.Errorf("failed to cast ratecard to db entity: %w", err)
233
+ }
234
+
235
+ q := c.Create().
236
+ SetAddonID(addonID).
237
+ SetNamespace(ns).
238
+ SetKey(rateCardEntity.Key).
239
+ SetType(rateCardEntity.Type).
240
+ SetName(rateCardEntity.Name).
241
+ SetNillableDescription(rateCardEntity.Description).
242
+ SetMetadata(rateCardEntity.Metadata).
243
+ SetNillableFeatureKey(rateCardEntity.FeatureKey).
244
+ SetNillableFeaturesID(rateCardEntity.FeatureID).
245
+ SetEntitlementTemplate(rateCardEntity.EntitlementTemplate).
246
+ SetNillableBillingCadence(rateCardEntity.BillingCadence).
247
+ SetDiscounts(rateCardEntity.Discounts)
248
+
249
+ if rateCardEntity.TaxConfig != nil {
250
+ q.SetTaxConfig(rateCardEntity.TaxConfig)
251
+ }
252
+
253
+ q.SetNillableTaxCodeID(rateCardEntity.TaxCodeID)
254
+ q.SetNillableTaxBehavior(rateCardEntity.TaxBehavior)
255
+
256
+ if rateCardEntity.Price != nil {
257
+ q.SetPrice(rateCardEntity.Price)
258
+ }
259
+
260
+ if rateCardEntity.UnitConfig != nil {
261
+ q.SetUnitConfig(rateCardEntity.UnitConfig)
262
+ }
263
+
264
+ bulk = append(bulk, q)
265
+ }
266
+
267
+ return bulk, nil
268
+ }
269
+
270
+ func (a *adapter) DeleteAddon(ctx context.Context, params addon.DeleteAddonInput) error {
271
+ fn := func(ctx context.Context, a *adapter) (interface{}, error) {
272
+ if err := params.Validate(); err != nil {
273
+ return nil, fmt.Errorf("invalid delete add-on parameters: %w", err)
274
+ }
275
+
276
+ add, err := a.GetAddon(ctx, addon.GetAddonInput{
277
+ NamespacedID: models.NamespacedID{
278
+ Namespace: params.Namespace,
279
+ ID: params.ID,
280
+ },
281
+ })
282
+ if err != nil {
283
+ if entdb.IsNotFound(err) {
284
+ return nil, addon.NewNotFoundError(addon.NotFoundErrorParams{
285
+ Namespace: params.Namespace,
286
+ ID: params.ID,
287
+ })
288
+ }
289
+
290
+ return nil, fmt.Errorf("failed to get add-on: %w", err)
291
+ }
292
+
293
+ deletedAt := time.Now().UTC()
294
+ err = a.db.Addon.UpdateOneID(add.ID).
295
+ Where(addondb.Namespace(add.Namespace)).
296
+ SetDeletedAt(deletedAt).
297
+ Exec(ctx)
298
+ if err != nil {
299
+ if entdb.IsNotFound(err) {
300
+ return nil, addon.NewNotFoundError(addon.NotFoundErrorParams{
301
+ Namespace: params.Namespace,
302
+ ID: params.ID,
303
+ })
304
+ }
305
+
306
+ return nil, fmt.Errorf("failed to delete add-on: %w", err)
307
+ }
308
+
309
+ return nil, nil
310
+ }
311
+
312
+ _, resp := entutils.TransactingRepo[interface{}, *adapter](ctx, a, fn)
313
+
314
+ return resp
315
+ }
316
+
317
+ func (a *adapter) GetAddon(ctx context.Context, params addon.GetAddonInput) (*addon.Addon, error) {
318
+ fn := func(ctx context.Context, a *adapter) (*addon.Addon, error) {
319
+ if err := params.Validate(); err != nil {
320
+ return nil, fmt.Errorf("invalid get add-on parameters: %w", err)
321
+ }
322
+
323
+ query := a.db.Addon.Query()
324
+
325
+ if params.ID != "" { // get Addon by ID
326
+ query = query.Where(addondb.And(
327
+ addondb.Namespace(params.Namespace),
328
+ addondb.ID(params.ID)),
329
+ )
330
+ } else if params.Key != "" {
331
+ if params.Version == 0 {
332
+ if params.IncludeLatest { // get add-ons latest version by Key
333
+ //
334
+ // SELECT *
335
+ // FROM Addons WHERE (namespace, key, version) IN
336
+ // (SELECT namespace, key, MAX(version)
337
+ // FROM addons
338
+ // WHERE namespace = '$1' and key = '$2'
339
+ // GROUP BY (namespace, key)
340
+ // )
341
+ query = query.Where(func(s *sql.Selector) {
342
+ t := sql.Table(addondb.Table)
343
+ s.Where(
344
+ sql.In(
345
+ t.Wrap(func(b *sql.Builder) {
346
+ b.IdentComma(addondb.FieldNamespace, addondb.FieldKey, addondb.FieldVersion)
347
+ }).String(),
348
+ sql.Select(
349
+ addondb.FieldNamespace, addondb.FieldKey, sql.Max(addondb.FieldVersion),
350
+ ).
351
+ From(t).
352
+ Where(sql.And(
353
+ sql.EQ(addondb.FieldNamespace, params.Namespace),
354
+ sql.EQ(addondb.FieldKey, params.Key),
355
+ )).
356
+ GroupBy(addondb.FieldNamespace, addondb.FieldKey),
357
+ ),
358
+ )
359
+ })
360
+ } else { // get add-on in active with active status by Key
361
+ now := time.Now().UTC()
362
+ query = query.Where(addondb.And(
363
+ addondb.Namespace(params.Namespace),
364
+ addondb.Key(params.Key),
365
+ addondb.EffectiveFromLTE(now),
366
+ addondb.Or(
367
+ addondb.EffectiveToGT(now),
368
+ addondb.EffectiveToIsNil(),
369
+ ),
370
+ addondb.DeletedAtIsNil(),
371
+ ))
372
+ }
373
+ } else { // get add-on by Key and Version
374
+ query = query.Where(addondb.And(
375
+ addondb.Namespace(params.Namespace),
376
+ addondb.Key(params.Key),
377
+ addondb.Version(params.Version),
378
+ ))
379
+ }
380
+ }
381
+
382
+ // Eager load RateCards
383
+ query = query.WithRatecards(
384
+ AddonEagerLoadRateCardsFn,
385
+ )
386
+
387
+ if params.Expand.PlanAddons {
388
+ query = query.WithPlans(
389
+ addonEagerLoadActivePlans,
390
+ )
391
+ }
392
+
393
+ addonRow, err := query.First(ctx)
394
+ if err != nil {
395
+ if entdb.IsNotFound(err) {
396
+ return nil, addon.NewNotFoundError(addon.NotFoundErrorParams{
397
+ Namespace: params.Namespace,
398
+ ID: params.ID,
399
+ Key: params.Key,
400
+ Version: params.Version,
401
+ })
402
+ }
403
+
404
+ return nil, fmt.Errorf("failed to get add-on: %w", err)
405
+ }
406
+
407
+ if addonRow == nil {
408
+ return nil, fmt.Errorf("invalid query result: nil add-on received")
409
+ }
410
+
411
+ add, err := FromAddonRow(*addonRow)
412
+ if err != nil {
413
+ return nil, fmt.Errorf("failed to cast add-on: %w", err)
414
+ }
415
+
416
+ return add, nil
417
+ }
418
+
419
+ return entutils.TransactingRepo[*addon.Addon, *adapter](ctx, a, fn)
420
+ }
421
+
422
+ var addonEagerLoadActivePlans = func(paq *entdb.PlanAddonQuery) {
423
+ paq.Where(
424
+ planaddondb.Or(
425
+ planaddondb.DeletedAtIsNil(),
426
+ planaddondb.DeletedAtGT(clock.Now().UTC()),
427
+ ),
428
+ ).WithPlan(func(pq *entdb.PlanQuery) {
429
+ pq.WithPhases(func(ppq *entdb.PlanPhaseQuery) {
430
+ ppq.Where(
431
+ phasedb.Or(
432
+ phasedb.DeletedAtIsNil(),
433
+ phasedb.DeletedAtGT(clock.Now().UTC()),
434
+ ),
435
+ ).WithRatecards(func(prcq *entdb.PlanRateCardQuery) {
436
+ prcq.Where(
437
+ ratecarddb.Or(
438
+ ratecarddb.DeletedAtIsNil(),
439
+ ratecarddb.DeletedAtGT(clock.Now().UTC()),
440
+ ),
441
+ ).WithFeatures().WithTaxCode()
442
+ })
443
+ })
444
+ })
445
+ }
446
+
447
+ func (a *adapter) UpdateAddon(ctx context.Context, params addon.UpdateAddonInput) (*addon.Addon, error) {
448
+ fn := func(ctx context.Context, a *adapter) (*addon.Addon, error) {
449
+ if err := params.Validate(); err != nil {
450
+ return nil, fmt.Errorf("invalid update add-on parameters: %w", err)
451
+ }
452
+
453
+ add, err := a.GetAddon(ctx, addon.GetAddonInput{
454
+ NamespacedID: models.NamespacedID{
455
+ Namespace: params.Namespace,
456
+ ID: params.ID,
457
+ },
458
+ })
459
+ if err != nil {
460
+ return nil, fmt.Errorf("failed to get add-on: %w", err)
461
+ }
462
+
463
+ if !params.Equal(*add) {
464
+ query := a.db.Addon.UpdateOneID(add.ID).
465
+ Where(addondb.Namespace(params.Namespace)).
466
+ SetNillableName(params.Name).
467
+ SetNillableDescription(params.Description).
468
+ SetNillableEffectiveFrom(params.EffectiveFrom).
469
+ SetNillableEffectiveTo(params.EffectiveTo)
470
+
471
+ if params.Metadata != nil {
472
+ query = query.SetMetadata(*params.Metadata)
473
+ }
474
+
475
+ if params.Annotations != nil {
476
+ query = query.SetAnnotations(*params.Annotations)
477
+ }
478
+
479
+ if params.InstanceType != nil {
480
+ query = query.SetInstanceType(*params.InstanceType)
481
+ }
482
+
483
+ err = query.Exec(ctx)
484
+ if err != nil {
485
+ return nil, fmt.Errorf("failed to update add-on: %w", err)
486
+ }
487
+
488
+ // Addon needs to be refetched after updated in order to populate all subresources
489
+ add, err = a.GetAddon(ctx, addon.GetAddonInput{
490
+ NamespacedID: models.NamespacedID{
491
+ Namespace: params.Namespace,
492
+ ID: params.ID,
493
+ },
494
+ })
495
+ if err != nil {
496
+ if entdb.IsNotFound(err) {
497
+ return nil, addon.NewNotFoundError(addon.NotFoundErrorParams{
498
+ Namespace: params.Namespace,
499
+ ID: params.ID,
500
+ })
501
+ }
502
+
503
+ return nil, fmt.Errorf("failed to get updated add-on: %w", err)
504
+ }
505
+ }
506
+
507
+ // Return early if there are no updates for AddonPhases
508
+ if params.RateCards == nil {
509
+ return add, nil
510
+ }
511
+
512
+ // Delete all existing ratecards
513
+ _, err = a.db.AddonRateCard.Delete().
514
+ Where(addonratecarddb.AddonID(add.ID)).
515
+ Exec(ctx)
516
+ if err != nil {
517
+ return nil, fmt.Errorf("failed to delete add-on ratecards: %w", err)
518
+ }
519
+
520
+ if len(*params.RateCards) > 0 {
521
+ bulk, err := rateCardBulkCreate(a.db.AddonRateCard, *params.RateCards, add.ID, params.Namespace)
522
+ if err != nil {
523
+ return nil, fmt.Errorf("failed to bulk create ratecards [namespace=%s id:%s]: %w", params.Namespace, add.ID, err)
524
+ }
525
+
526
+ if err = a.db.AddonRateCard.CreateBulk(bulk...).Exec(ctx); err != nil {
527
+ return nil, fmt.Errorf("failed to bulk create ratecards [namespace=%s id:%s]: %w", params.Namespace, add.ID, err)
528
+ }
529
+ }
530
+
531
+ // Refetch updated add-on
532
+ addonRow, err := a.db.Addon.Query().
533
+ Where(addondb.And(
534
+ addondb.Namespace(params.Namespace),
535
+ addondb.ID(add.ID)),
536
+ ).
537
+ WithRatecards(
538
+ AddonEagerLoadRateCardsFn,
539
+ ).
540
+ First(ctx)
541
+ if err != nil {
542
+ return nil, fmt.Errorf("failed to update add-on [namespace=%s]: %w", params.Namespace, err)
543
+ }
544
+
545
+ add, err = FromAddonRow(*addonRow)
546
+ if err != nil {
547
+ return nil, fmt.Errorf("failed to cast updated add-on [namespace=%s id:%s]: %w", params.Namespace, addonRow.ID, err)
548
+ }
549
+
550
+ return add, nil
551
+ }
552
+
553
+ return entutils.TransactingRepo[*addon.Addon, *adapter](ctx, a, fn)
554
+ }
555
+
556
+ var AddonEagerLoadRateCardsFn = func(q *entdb.AddonRateCardQuery) {
557
+ q.Where(
558
+ addonratecarddb.Or(
559
+ addonratecarddb.DeletedAtIsNil(),
560
+ addonratecarddb.DeletedAtGT(clock.Now().UTC()),
561
+ ))
562
+ rateCardEagerLoadFeaturesFn(q)
563
+ rateCardEagerLoadTaxCodesFn(q)
564
+ }
565
+
566
+ var rateCardEagerLoadFeaturesFn = func(q *entdb.AddonRateCardQuery) {
567
+ q.WithFeatures()
568
+ }
569
+
570
+ var rateCardEagerLoadTaxCodesFn = func(q *entdb.AddonRateCardQuery) {
571
+ q.WithTaxCode()
572
+ }
openmeter/productcatalog/addon/adapter/mapping.go ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package adapter
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+
7
+ "github.com/invopop/gobl/currency"
8
+ "github.com/samber/lo"
9
+
10
+ entdb "github.com/openmeterio/openmeter/openmeter/ent/db"
11
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
12
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/addon"
13
+ taxcodeadapter "github.com/openmeterio/openmeter/openmeter/taxcode/adapter"
14
+ "github.com/openmeterio/openmeter/pkg/models"
15
+ )
16
+
17
+ func FromAddonRow(a entdb.Addon) (*addon.Addon, error) {
18
+ aa := &addon.Addon{
19
+ NamespacedID: models.NamespacedID{
20
+ Namespace: a.Namespace,
21
+ ID: a.ID,
22
+ },
23
+ ManagedModel: models.ManagedModel{
24
+ CreatedAt: a.CreatedAt,
25
+ UpdatedAt: a.UpdatedAt,
26
+ DeletedAt: a.DeletedAt,
27
+ },
28
+ AddonMeta: productcatalog.AddonMeta{
29
+ Key: a.Key,
30
+ Name: a.Name,
31
+ Description: a.Description,
32
+ Metadata: a.Metadata,
33
+ Annotations: a.Annotations,
34
+ Version: a.Version,
35
+ Currency: currency.Code(a.Currency),
36
+ InstanceType: a.InstanceType,
37
+ EffectivePeriod: productcatalog.EffectivePeriod{
38
+ EffectiveFrom: a.EffectiveFrom,
39
+ EffectiveTo: a.EffectiveTo,
40
+ },
41
+ },
42
+ }
43
+
44
+ // Set Rate Cards
45
+
46
+ if len(a.Edges.Ratecards) > 0 {
47
+ aa.RateCards = make(addon.RateCards, 0, len(a.Edges.Ratecards))
48
+ for _, edge := range a.Edges.Ratecards {
49
+ if edge == nil {
50
+ continue
51
+ }
52
+
53
+ ratecard, err := FromAddonRateCardRow(*edge)
54
+ if err != nil {
55
+ return nil, fmt.Errorf("invalid ratecard [namespace=%s key=%s]: %w", aa.Namespace, edge.Key, err)
56
+ }
57
+
58
+ aa.RateCards = append(aa.RateCards, *ratecard)
59
+ }
60
+ }
61
+
62
+ plans, err := a.Edges.PlansOrErr()
63
+ if err != nil {
64
+ aa.Plans = nil
65
+ } else {
66
+ planAddons := make([]addon.Plan, 0, len(plans))
67
+
68
+ for _, plan := range plans {
69
+ if plan == nil {
70
+ continue
71
+ }
72
+
73
+ planAddon, err := FromPlanAddonRow(*plan)
74
+ if err != nil {
75
+ return nil, fmt.Errorf("invalid plan add-on assignment %s: %w", plan.ID, err)
76
+ }
77
+
78
+ planAddons = append(planAddons, *planAddon)
79
+ }
80
+
81
+ aa.Plans = &planAddons
82
+ }
83
+
84
+ return aa, nil
85
+ }
86
+
87
+ func FromAddonRateCardRow(r entdb.AddonRateCard) (*addon.RateCard, error) {
88
+ meta := productcatalog.RateCardMeta{
89
+ Key: r.Key,
90
+ Name: r.Name,
91
+ Description: r.Description,
92
+ Metadata: r.Metadata,
93
+ EntitlementTemplate: r.EntitlementTemplate,
94
+ FeatureKey: r.FeatureKey,
95
+ FeatureID: r.FeatureID,
96
+ TaxConfig: r.TaxConfig,
97
+ Price: r.Price,
98
+ Discounts: lo.FromPtr(r.Discounts),
99
+ UnitConfig: r.UnitConfig,
100
+ }
101
+
102
+ if r.FeatureID != nil || r.FeatureKey != nil {
103
+ ratecardFeature, err := r.Edges.FeaturesOrErr()
104
+ //if err != nil {
105
+ // return nil, errors.New("feature is not loaded for ratecard")
106
+ //}
107
+ //
108
+ //meta.SetFeature(&ratecardFeature.ID, &ratecardFeature.Key)
109
+
110
+ // FIXME(chrisgacsal): temporary fix until data is migrated
111
+ if err == nil && ratecardFeature != nil {
112
+ meta.SetFeature(&ratecardFeature.ID, &ratecardFeature.Key)
113
+ }
114
+ }
115
+
116
+ // Map TaxCode if eagerly loaded.
117
+ taxCodeRow, err := r.Edges.TaxCodeOrErr()
118
+ if err == nil {
119
+ tc, err := taxcodeadapter.MapTaxCodeFromEntity(taxCodeRow)
120
+ if err != nil {
121
+ return nil, fmt.Errorf("invalid tax code for rate card %s: %w", r.ID, err)
122
+ }
123
+
124
+ meta.TaxCode = &tc
125
+ }
126
+
127
+ // Backfill legacy TaxConfig fields from new columns and TaxCode entity.
128
+ meta.TaxConfig = productcatalog.BackfillTaxConfig(meta.TaxConfig, r.TaxBehavior, meta.TaxCode)
129
+
130
+ // Get billing cadence
131
+
132
+ billingCadence, err := r.BillingCadence.ParsePtrOrNil()
133
+ if err != nil {
134
+ return nil, fmt.Errorf("invalid ratecard [namespace=%s key=%s]: billing cadence: %w", r.Namespace, r.Key, err)
135
+ }
136
+
137
+ // Managed fields
138
+
139
+ managed := addon.RateCardManagedFields{
140
+ ManagedModel: models.ManagedModel{
141
+ CreatedAt: r.CreatedAt,
142
+ UpdatedAt: r.UpdatedAt,
143
+ DeletedAt: r.DeletedAt,
144
+ },
145
+ NamespacedID: models.NamespacedID{
146
+ Namespace: r.Namespace,
147
+ ID: r.ID,
148
+ },
149
+ AddonID: r.AddonID,
150
+ }
151
+
152
+ var ratecard *addon.RateCard
153
+
154
+ switch r.Type {
155
+ case productcatalog.FlatFeeRateCardType:
156
+ ratecard = &addon.RateCard{
157
+ RateCardManagedFields: managed,
158
+ RateCard: &productcatalog.FlatFeeRateCard{
159
+ RateCardMeta: meta,
160
+ BillingCadence: billingCadence,
161
+ },
162
+ }
163
+ case productcatalog.UsageBasedRateCardType:
164
+ ratecard = &addon.RateCard{
165
+ RateCardManagedFields: managed,
166
+ RateCard: &productcatalog.UsageBasedRateCard{
167
+ RateCardMeta: meta,
168
+ BillingCadence: lo.FromPtr(billingCadence),
169
+ },
170
+ }
171
+ default:
172
+ return nil, fmt.Errorf("invalid ratecard [namespace=%s key=%s]: invalid type %s: %w", r.Namespace, r.Key, r.Type, err)
173
+ }
174
+
175
+ return ratecard, nil
176
+ }
177
+
178
+ func FromPlanAddonRow(a entdb.PlanAddon) (*addon.Plan, error) {
179
+ planAddon := &addon.Plan{
180
+ NamespacedID: models.NamespacedID{
181
+ Namespace: a.Namespace,
182
+ ID: a.ID,
183
+ },
184
+ ManagedModel: models.ManagedModel{
185
+ CreatedAt: a.CreatedAt,
186
+ UpdatedAt: a.UpdatedAt,
187
+ DeletedAt: a.DeletedAt,
188
+ },
189
+ PlanAddonMeta: productcatalog.PlanAddonMeta{
190
+ Metadata: a.Metadata,
191
+ Annotations: a.Annotations,
192
+ PlanAddonConfig: productcatalog.PlanAddonConfig{
193
+ FromPlanPhase: a.FromPlanPhase,
194
+ MaxQuantity: a.MaxQuantity,
195
+ },
196
+ },
197
+ }
198
+
199
+ // Set Plan
200
+
201
+ plan, err := a.Edges.PlanOrErr()
202
+ if err != nil {
203
+ return nil, errors.New("failed to cast plan: plan is not loaded")
204
+ }
205
+
206
+ pp, err := FromPlanRow(*plan)
207
+ if err != nil {
208
+ return nil, fmt.Errorf("failed to cast add-on: %w", err)
209
+ }
210
+
211
+ planAddon.Plan = *pp
212
+
213
+ return planAddon, nil
214
+ }
215
+
216
+ func FromPlanRow(p entdb.Plan) (*productcatalog.Plan, error) {
217
+ billingCadence, err := p.BillingCadence.Parse()
218
+ if err != nil {
219
+ return nil, fmt.Errorf("invalid billing cadence %s: %w", p.BillingCadence, err)
220
+ }
221
+
222
+ pp := &productcatalog.Plan{
223
+ PlanMeta: productcatalog.PlanMeta{
224
+ Key: p.Key,
225
+ Name: p.Name,
226
+ Description: p.Description,
227
+ Metadata: p.Metadata,
228
+ Version: p.Version,
229
+ Currency: currency.Code(p.Currency),
230
+ EffectivePeriod: productcatalog.EffectivePeriod{
231
+ EffectiveFrom: p.EffectiveFrom,
232
+ EffectiveTo: p.EffectiveTo,
233
+ },
234
+ BillingCadence: billingCadence,
235
+ ProRatingConfig: p.ProRatingConfig,
236
+ SettlementMode: p.SettlementMode,
237
+ },
238
+ }
239
+
240
+ if len(p.Edges.Phases) > 0 {
241
+ phases := make([]productcatalog.Phase, len(p.Edges.Phases))
242
+ for _, edge := range p.Edges.Phases {
243
+ if edge == nil {
244
+ continue
245
+ }
246
+
247
+ phase, err := FromPlanPhaseRow(*edge)
248
+ if err != nil {
249
+ return nil, fmt.Errorf("invalid phase %s: %w", edge.ID, err)
250
+ }
251
+
252
+ phases[edge.Index] = *phase
253
+ }
254
+
255
+ if len(phases) > 0 {
256
+ pp.Phases = phases
257
+ }
258
+ }
259
+
260
+ return pp, nil
261
+ }
262
+
263
+ func FromPlanPhaseRow(p entdb.PlanPhase) (*productcatalog.Phase, error) {
264
+ pp := &productcatalog.Phase{
265
+ PhaseMeta: productcatalog.PhaseMeta{
266
+ Key: p.Key,
267
+ Name: p.Name,
268
+ Description: p.Description,
269
+ Metadata: p.Metadata,
270
+ },
271
+ }
272
+
273
+ // Set Interval
274
+
275
+ duration, err := p.Duration.ParsePtrOrNil()
276
+ if err != nil {
277
+ return nil, fmt.Errorf("invalid duration %v: %w", p.Duration, err)
278
+ }
279
+
280
+ pp.Duration = duration
281
+
282
+ // Set Rate Cards
283
+
284
+ if len(p.Edges.Ratecards) > 0 {
285
+ pp.RateCards = make([]productcatalog.RateCard, 0, len(p.Edges.Ratecards))
286
+ for _, edge := range p.Edges.Ratecards {
287
+ if edge == nil {
288
+ continue
289
+ }
290
+
291
+ ratecard, err := FromPlanRateCardRow(*edge)
292
+ if err != nil {
293
+ return nil, fmt.Errorf("invalid rate card %s: %w", edge.ID, err)
294
+ }
295
+
296
+ pp.RateCards = append(pp.RateCards, ratecard)
297
+ }
298
+ }
299
+
300
+ return pp, nil
301
+ }
302
+
303
+ func FromPlanRateCardRow(r entdb.PlanRateCard) (productcatalog.RateCard, error) {
304
+ meta := productcatalog.RateCardMeta{
305
+ Key: r.Key,
306
+ Name: r.Name,
307
+ Description: r.Description,
308
+ Metadata: r.Metadata,
309
+ FeatureID: r.FeatureID,
310
+ FeatureKey: r.FeatureKey,
311
+ EntitlementTemplate: r.EntitlementTemplate,
312
+ TaxConfig: r.TaxConfig,
313
+ Price: r.Price,
314
+ Discounts: lo.FromPtr(r.Discounts),
315
+ UnitConfig: r.UnitConfig,
316
+ }
317
+
318
+ // Map TaxCode if eagerly loaded.
319
+ taxCodeRow, err := r.Edges.TaxCodeOrErr()
320
+ if err == nil {
321
+ tc, err := taxcodeadapter.MapTaxCodeFromEntity(taxCodeRow)
322
+ if err != nil {
323
+ return nil, fmt.Errorf("invalid tax code for rate card %s: %w", r.ID, err)
324
+ }
325
+
326
+ meta.TaxCode = &tc
327
+ }
328
+
329
+ // Backfill legacy TaxConfig fields from new columns and TaxCode entity.
330
+ meta.TaxConfig = productcatalog.BackfillTaxConfig(meta.TaxConfig, r.TaxBehavior, meta.TaxCode)
331
+
332
+ // Get billing cadence
333
+
334
+ billingCadence, err := r.BillingCadence.ParsePtrOrNil()
335
+ if err != nil {
336
+ return nil, fmt.Errorf("invalid rate card billing cadence %s: %w", r.ID, err)
337
+ }
338
+
339
+ var ratecard productcatalog.RateCard
340
+
341
+ switch r.Type {
342
+ case productcatalog.FlatFeeRateCardType:
343
+ ratecard = &productcatalog.FlatFeeRateCard{
344
+ RateCardMeta: meta,
345
+ BillingCadence: billingCadence,
346
+ }
347
+ case productcatalog.UsageBasedRateCardType:
348
+ ratecard = &productcatalog.UsageBasedRateCard{
349
+ RateCardMeta: meta,
350
+ BillingCadence: lo.FromPtr(billingCadence),
351
+ }
352
+ default:
353
+ return nil, fmt.Errorf("invalid RateCard type %s", r.Type)
354
+ }
355
+
356
+ return ratecard, nil
357
+ }
358
+
359
+ func asAddonRateCardRow(r productcatalog.RateCard) (entdb.AddonRateCard, error) {
360
+ meta := r.AsMeta()
361
+
362
+ ratecard := entdb.AddonRateCard{
363
+ Key: meta.Key,
364
+ Metadata: meta.Metadata,
365
+ Name: meta.Name,
366
+ Description: meta.Description,
367
+ EntitlementTemplate: meta.EntitlementTemplate,
368
+ TaxConfig: meta.TaxConfig,
369
+ FeatureKey: meta.FeatureKey,
370
+ FeatureID: meta.FeatureID,
371
+ Price: meta.Price,
372
+ Type: r.Type(),
373
+ Discounts: lo.EmptyableToPtr(meta.Discounts),
374
+ UnitConfig: meta.UnitConfig,
375
+ }
376
+
377
+ if managed, ok := r.(addon.ManagedRateCard); ok {
378
+ managedFields := managed.ManagedFields()
379
+ ratecard.Namespace = managedFields.Namespace
380
+ ratecard.ID = managedFields.ID
381
+ }
382
+
383
+ switch v := r.(type) {
384
+ case *productcatalog.FlatFeeRateCard:
385
+ ratecard.BillingCadence = v.BillingCadence.ISOStringPtrOrNil()
386
+ case *productcatalog.UsageBasedRateCard:
387
+ ratecard.BillingCadence = v.BillingCadence.ISOStringPtrOrNil()
388
+ default:
389
+ return ratecard, fmt.Errorf("invalid ratecard [key=%s]: invalid type: %T", r.Key(), r)
390
+ }
391
+
392
+ if meta.TaxConfig != nil {
393
+ ratecard.TaxCodeID = meta.TaxConfig.TaxCodeID
394
+ ratecard.TaxBehavior = meta.TaxConfig.Behavior
395
+ }
396
+
397
+ return ratecard, nil
398
+ }
openmeter/productcatalog/addon/addon.go ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package addon
2
+
3
+ import (
4
+ "errors"
5
+
6
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
7
+ "github.com/openmeterio/openmeter/pkg/models"
8
+ )
9
+
10
+ var (
11
+ _ models.Validator = (*Addon)(nil)
12
+ _ models.CustomValidator[Addon] = (*Addon)(nil)
13
+ )
14
+
15
+ type Addon struct {
16
+ models.NamespacedID
17
+ models.ManagedModel
18
+
19
+ productcatalog.AddonMeta
20
+
21
+ // RateCards
22
+ RateCards RateCards `json:"rateCards"`
23
+
24
+ // Plans contains the list of Plans assigned to this Addon. It is only provided if the Addon was fetched
25
+ // with Plans being expanded.
26
+ Plans *[]Plan `json:"plans,omitempty"`
27
+ }
28
+
29
+ func (a Addon) ValidateWith(validators ...models.ValidatorFunc[Addon]) error {
30
+ return models.Validate(a, validators...)
31
+ }
32
+
33
+ func (a Addon) Validate() error {
34
+ var errs []error
35
+
36
+ if err := a.NamespacedID.Validate(); err != nil {
37
+ errs = append(errs, err)
38
+ }
39
+
40
+ if err := a.ManagedModel.Validate(); err != nil {
41
+ errs = append(errs, err)
42
+ }
43
+
44
+ if err := a.AddonMeta.Validate(); err != nil {
45
+ errs = append(errs, err)
46
+ }
47
+
48
+ for _, rateCard := range a.RateCards {
49
+ if err := rateCard.Validate(); err != nil {
50
+ errs = append(errs, err)
51
+ }
52
+ }
53
+
54
+ return models.NewNillableGenericValidationError(errors.Join(errs...))
55
+ }
56
+
57
+ func (a Addon) AsProductCatalogAddon() productcatalog.Addon {
58
+ return productcatalog.Addon{
59
+ AddonMeta: a.AddonMeta,
60
+ RateCards: a.RateCards.AsProductCatalogRateCards(),
61
+ }
62
+ }
openmeter/productcatalog/addon/assert.go ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package addon
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/samber/lo"
7
+ "github.com/stretchr/testify/assert"
8
+ "github.com/stretchr/testify/require"
9
+
10
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
11
+ )
12
+
13
+ func AssertAddonCreateInputEqual(t *testing.T, i CreateAddonInput, a Addon) {
14
+ t.Helper()
15
+
16
+ assert.Equalf(t, i.Namespace, a.Namespace, "create input: namespace mismatch")
17
+ assert.Equalf(t, i.Key, a.Key, "create input: key mismatch")
18
+ assert.Equalf(t, i.Name, a.Name, "create input: name mismatch")
19
+ assert.Equalf(t, i.Description, a.Description, "create input: description mismatch")
20
+ assert.Equalf(t, i.Currency, a.Currency, "create input: currency mismatch")
21
+ assert.Equalf(t, i.Metadata, a.Metadata, "metadata mismatch")
22
+ assert.Equalf(t, i.Annotations, a.Annotations, "annotations mismatch")
23
+
24
+ AssertAddonRateCardsEqual(t, i.RateCards, a.RateCards.AsProductCatalogRateCards())
25
+ }
26
+
27
+ func AssertAddonUpdateInputEqual(t *testing.T, i UpdateAddonInput, a Addon) {
28
+ t.Helper()
29
+
30
+ assert.Equalf(t, i.Namespace, a.Namespace, "update input: namespace mismatch")
31
+
32
+ if i.Name != nil {
33
+ assert.Equalf(t, *i.Name, a.Name, "update input: name mismatch")
34
+ }
35
+
36
+ if i.Description != nil {
37
+ assert.Equalf(t, lo.FromPtr(i.Description), lo.FromPtr(a.Description), "update input: description mismatch")
38
+ }
39
+
40
+ if i.Metadata != nil {
41
+ assert.Equalf(t, *i.Metadata, a.Metadata, "metadata mismatch")
42
+ }
43
+
44
+ if i.Annotations != nil {
45
+ assert.Equalf(t, *i.Annotations, a.Annotations, "annotations mismatch")
46
+ }
47
+
48
+ if i.RateCards != nil {
49
+ AssertAddonRateCardsEqual(t, *i.RateCards, a.RateCards.AsProductCatalogRateCards())
50
+ }
51
+ }
52
+
53
+ func AssertAddonEqual(t *testing.T, expected, actual Addon) {
54
+ t.Helper()
55
+
56
+ assert.Equalf(t, expected.Key, actual.Key, "key mismatch")
57
+ assert.Equalf(t, expected.Name, actual.Name, "name mismatch")
58
+ assert.Equalf(t, expected.Description, actual.Description, "description mismatch")
59
+ assert.Equalf(t, expected.Currency, actual.Currency, "currency mismatch")
60
+ assert.Equalf(t, expected.Metadata, actual.Metadata, "metadata mismatch")
61
+ assert.Equalf(t, expected.Annotations, actual.Annotations, "annotations mismatch")
62
+
63
+ AssertAddonRateCardsEqual(t, expected.RateCards.AsProductCatalogRateCards(), actual.RateCards.AsProductCatalogRateCards())
64
+ }
65
+
66
+ func AssertAddonRateCardsEqual(t *testing.T, r1, r2 productcatalog.RateCards) {
67
+ t.Helper()
68
+
69
+ assert.Equalf(t, len(r1), len(r2), "number of RateCards mismatch")
70
+
71
+ r1Map := func() map[string]productcatalog.RateCard {
72
+ m := make(map[string]productcatalog.RateCard, len(r1))
73
+ for _, v := range r1 {
74
+ m[v.Key()] = v
75
+ }
76
+
77
+ return m
78
+ }()
79
+
80
+ r2Map := func() map[string]productcatalog.RateCard {
81
+ m := make(map[string]productcatalog.RateCard, len(r2))
82
+ for _, v := range r2 {
83
+ m[v.Key()] = v
84
+ }
85
+
86
+ return m
87
+ }()
88
+
89
+ visited := make(map[string]struct{})
90
+ for phase1Key, rateCard1 := range r1Map {
91
+ rateCard2, ok := r2Map[phase1Key]
92
+ require.Truef(t, ok, "missing RateCard key")
93
+
94
+ AssertRateCardEqual(t, rateCard1, rateCard2)
95
+
96
+ visited[phase1Key] = struct{}{}
97
+ }
98
+
99
+ for phase2Key := range r2Map {
100
+ _, ok := visited[phase2Key]
101
+ require.Truef(t, ok, "missing RateCard key")
102
+ }
103
+ }
104
+
105
+ func AssertRateCardEqual(t *testing.T, r1, r2 productcatalog.RateCard) {
106
+ t.Helper()
107
+
108
+ assert.Equalf(t, r1.Type(), r2.Type(), "type mismatch")
109
+
110
+ m1 := r1.AsMeta()
111
+ m2 := r2.AsMeta()
112
+
113
+ assert.Equalf(t, m1.Key, m2.Key, "key mismatch")
114
+ assert.Equalf(t, m1.Name, m2.Name, "name mismatch")
115
+ assert.Equalf(t, lo.FromPtr(m1.Description), lo.FromPtr(m2.Description), "description mismatch")
116
+
117
+ assert.Truef(t, m1.Metadata.Equal(m2.Metadata), "metadata mismatch")
118
+
119
+ assert.Equalf(t, m1.FeatureKey, m2.FeatureKey, "feature key mismatch")
120
+ assert.Equalf(t, m1.FeatureID, m2.FeatureID, "feature id mismatch")
121
+
122
+ assert.Truef(t, m1.EntitlementTemplate.Equal(m2.EntitlementTemplate), "entitlement template mismatch")
123
+
124
+ assert.Truef(t, m1.TaxConfig.Equal(m2.TaxConfig), "tax config mismatch")
125
+
126
+ assert.Truef(t, m1.Price.Equal(m2.Price), "price mismatch")
127
+
128
+ assert.Truef(t, m1.UnitConfig.Equal(m2.UnitConfig), "unit config mismatch")
129
+
130
+ billingCadence1 := r1.GetBillingCadence().ISOStringPtrOrNil()
131
+ billingCadence2 := r2.GetBillingCadence().ISOStringPtrOrNil()
132
+
133
+ assert.Equal(t, billingCadence1, billingCadence2)
134
+ }
openmeter/productcatalog/addon/errors.go ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package addon
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+
7
+ "github.com/openmeterio/openmeter/pkg/models"
8
+ )
9
+
10
+ var _ error = (*NotFoundError)(nil)
11
+
12
+ type NotFoundErrorParams struct {
13
+ Namespace string
14
+ ID string
15
+ Key string
16
+ Version int
17
+ }
18
+
19
+ func NewNotFoundError(e NotFoundErrorParams) *NotFoundError {
20
+ var m string
21
+
22
+ if e.Namespace != "" {
23
+ m += fmt.Sprintf(" namespace=%s", e.Namespace)
24
+ }
25
+
26
+ if e.ID != "" {
27
+ m += fmt.Sprintf(" id=%s", e.ID)
28
+ }
29
+
30
+ if e.Key != "" {
31
+ m += fmt.Sprintf(" key=%s", e.Key)
32
+ }
33
+
34
+ if e.Version != 0 {
35
+ m += fmt.Sprintf(" version=%d", e.Version)
36
+ }
37
+
38
+ if len(m) > 0 {
39
+ m = fmt.Sprintf("add-on not found. [%s]", m[1:])
40
+ } else {
41
+ m = "add-on not found"
42
+ }
43
+
44
+ return &NotFoundError{
45
+ err: models.NewGenericNotFoundError(
46
+ errors.New(m),
47
+ ),
48
+ }
49
+ }
50
+
51
+ var _ models.GenericError = &NotFoundError{}
52
+
53
+ type NotFoundError struct {
54
+ err error
55
+ }
56
+
57
+ func (e *NotFoundError) Error() string {
58
+ return e.err.Error()
59
+ }
60
+
61
+ func (e *NotFoundError) Unwrap() error {
62
+ return e.err
63
+ }
64
+
65
+ func IsNotFound(err error) bool {
66
+ if err == nil {
67
+ return false
68
+ }
69
+ var e *NotFoundError
70
+
71
+ return errors.As(err, &e)
72
+ }
openmeter/productcatalog/addon/errors_test.go ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package addon
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "testing"
7
+
8
+ "github.com/stretchr/testify/assert"
9
+ )
10
+
11
+ func TestIsNotFoundError(t *testing.T) {
12
+ tests := []struct {
13
+ Name string
14
+ Error error
15
+ ExpectedError bool
16
+ }{
17
+ {
18
+ Name: "Valid",
19
+ Error: NewNotFoundError(NotFoundErrorParams{
20
+ Namespace: "test",
21
+ ID: "test",
22
+ }),
23
+ ExpectedError: true,
24
+ },
25
+ {
26
+ Name: "Wrapped",
27
+ Error: errors.Join(
28
+ fmt.Errorf("wrapped: %w", NewNotFoundError(NotFoundErrorParams{
29
+ Namespace: "test",
30
+ ID: "test",
31
+ })),
32
+ ),
33
+ ExpectedError: true,
34
+ },
35
+ {
36
+ Name: "Invalid",
37
+ Error: errors.New("test error"),
38
+ ExpectedError: false,
39
+ },
40
+ }
41
+
42
+ for _, test := range tests {
43
+ t.Run(test.Name, func(t *testing.T) {
44
+ assert.Equal(t, test.ExpectedError, IsNotFound(test.Error))
45
+ })
46
+ }
47
+ }
48
+
49
+ func TestIsNotFoundError_String(t *testing.T) {
50
+ tests := []struct {
51
+ Name string
52
+ Error error
53
+ ExpectedError string
54
+ }{
55
+ {
56
+ Name: "ID",
57
+ Error: NewNotFoundError(NotFoundErrorParams{
58
+ Namespace: "namespace",
59
+ ID: "id",
60
+ }),
61
+ ExpectedError: "not found error: add-on not found. [namespace=namespace id=id]",
62
+ },
63
+ {
64
+ Name: "Key",
65
+ Error: NewNotFoundError(NotFoundErrorParams{
66
+ Namespace: "namespace",
67
+ Key: "key",
68
+ }),
69
+ ExpectedError: "not found error: add-on not found. [namespace=namespace key=key]",
70
+ },
71
+ {
72
+ Name: "KeyVersion",
73
+ Error: NewNotFoundError(NotFoundErrorParams{
74
+ Namespace: "namespace",
75
+ Key: "key",
76
+ Version: 1,
77
+ }),
78
+ ExpectedError: "not found error: add-on not found. [namespace=namespace key=key version=1]",
79
+ },
80
+ {
81
+ Name: "Default",
82
+ Error: NewNotFoundError(NotFoundErrorParams{}),
83
+ ExpectedError: "not found error: add-on not found",
84
+ },
85
+ }
86
+
87
+ for _, test := range tests {
88
+ t.Run(test.Name, func(t *testing.T) {
89
+ t.Logf("%v", test.Error)
90
+
91
+ assert.Equal(t, test.ExpectedError, test.Error.Error())
92
+ })
93
+ }
94
+ }
openmeter/productcatalog/addon/event.go ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package addon
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+
7
+ "github.com/oklog/ulid/v2"
8
+ "github.com/samber/lo"
9
+
10
+ "github.com/openmeterio/openmeter/openmeter/event/metadata"
11
+ "github.com/openmeterio/openmeter/openmeter/session"
12
+ )
13
+
14
+ const (
15
+ AddonEventSubsystem metadata.EventSubsystem = "addon"
16
+ AddonCreateEventName metadata.EventName = "addon.created"
17
+ AddonUpdateEventName metadata.EventName = "addon.updated"
18
+ AddonDeleteEventName metadata.EventName = "addon.deleted"
19
+ AddonPublishEventName metadata.EventName = "addon.published"
20
+ AddonArchiveEventName metadata.EventName = "addon.archived"
21
+ )
22
+
23
+ // NewAddonCreateEvent creates a new Addon create event
24
+ func NewAddonCreateEvent(ctx context.Context, addon *Addon) AddonCreateEvent {
25
+ return AddonCreateEvent{
26
+ Addon: addon,
27
+ UserID: session.GetSessionUserID(ctx),
28
+ }
29
+ }
30
+
31
+ // AddonCreateEvent is an event that is emitted when an Addon is created
32
+ type AddonCreateEvent struct {
33
+ Addon *Addon `json:"addon"`
34
+ UserID *string `json:"userId,omitempty"`
35
+ }
36
+
37
+ func (e AddonCreateEvent) EventName() string {
38
+ return metadata.GetEventName(metadata.EventType{
39
+ Subsystem: AddonEventSubsystem,
40
+ Name: AddonCreateEventName,
41
+ Version: "v1",
42
+ })
43
+ }
44
+
45
+ func (e AddonCreateEvent) EventMetadata() metadata.EventMetadata {
46
+ resourcePath := metadata.ComposeResourcePath(e.Addon.Namespace, metadata.EntityAddon, e.Addon.ID)
47
+
48
+ return metadata.EventMetadata{
49
+ ID: ulid.Make().String(),
50
+ Source: resourcePath,
51
+ Subject: resourcePath,
52
+ Time: e.Addon.CreatedAt,
53
+ }
54
+ }
55
+
56
+ func (e AddonCreateEvent) Validate() error {
57
+ var errs []error
58
+
59
+ if e.Addon == nil {
60
+ errs = append(errs, errors.New("add-on is required"))
61
+ }
62
+
63
+ return errors.Join(errs...)
64
+ }
65
+
66
+ // NewAddonUpdateEvent creates a new Addon update event
67
+ func NewAddonUpdateEvent(ctx context.Context, addon *Addon) AddonUpdateEvent {
68
+ return AddonUpdateEvent{
69
+ Addon: addon,
70
+ UserID: session.GetSessionUserID(ctx),
71
+ }
72
+ }
73
+
74
+ // AddonUpdateEvent is an event that is emitted when an Addon is updated
75
+ type AddonUpdateEvent struct {
76
+ Addon *Addon `json:"addon"`
77
+ UserID *string `json:"userId,omitempty"`
78
+ }
79
+
80
+ func (e AddonUpdateEvent) EventName() string {
81
+ return metadata.GetEventName(metadata.EventType{
82
+ Subsystem: AddonEventSubsystem,
83
+ Name: AddonUpdateEventName,
84
+ Version: "v1",
85
+ })
86
+ }
87
+
88
+ func (e AddonUpdateEvent) EventMetadata() metadata.EventMetadata {
89
+ resourcePath := metadata.ComposeResourcePath(e.Addon.Namespace, metadata.EntityAddon, e.Addon.ID)
90
+
91
+ return metadata.EventMetadata{
92
+ ID: ulid.Make().String(),
93
+ Source: resourcePath,
94
+ Subject: resourcePath,
95
+ Time: e.Addon.UpdatedAt,
96
+ }
97
+ }
98
+
99
+ func (e AddonUpdateEvent) Validate() error {
100
+ var errs []error
101
+
102
+ if e.Addon == nil {
103
+ errs = append(errs, errors.New("add-on is required"))
104
+ }
105
+
106
+ return errors.Join(errs...)
107
+ }
108
+
109
+ // NewAddonDeleteEvent creates a new Addon delete event
110
+ func NewAddonDeleteEvent(ctx context.Context, addon *Addon) AddonDeleteEvent {
111
+ return AddonDeleteEvent{
112
+ Addon: addon,
113
+ UserID: session.GetSessionUserID(ctx),
114
+ }
115
+ }
116
+
117
+ // AddonDeleteEvent is an event that is emitted when an Addon is deleted
118
+ type AddonDeleteEvent struct {
119
+ Addon *Addon `json:"addon"`
120
+ UserID *string `json:"userId,omitempty"`
121
+ }
122
+
123
+ func (e AddonDeleteEvent) EventName() string {
124
+ return metadata.GetEventName(metadata.EventType{
125
+ Subsystem: AddonEventSubsystem,
126
+ Name: AddonDeleteEventName,
127
+ Version: "v1",
128
+ })
129
+ }
130
+
131
+ func (e AddonDeleteEvent) EventMetadata() metadata.EventMetadata {
132
+ resourcePath := metadata.ComposeResourcePath(e.Addon.Namespace, metadata.EntityAddon, e.Addon.ID)
133
+
134
+ return metadata.EventMetadata{
135
+ ID: ulid.Make().String(),
136
+ Source: resourcePath,
137
+ Subject: resourcePath,
138
+ Time: lo.FromPtr(e.Addon.DeletedAt),
139
+ }
140
+ }
141
+
142
+ func (e AddonDeleteEvent) Validate() error {
143
+ var errs []error
144
+
145
+ if e.Addon == nil {
146
+ errs = append(errs, errors.New("add-on is required"))
147
+ }
148
+
149
+ if e.Addon.DeletedAt == nil {
150
+ errs = append(errs, errors.New("add-on deleted at is required"))
151
+ }
152
+
153
+ return errors.Join(errs...)
154
+ }
155
+
156
+ // NewAddonPublishEvent creates a new Addon publish event
157
+ func NewAddonPublishEvent(ctx context.Context, Addon *Addon) AddonPublishEvent {
158
+ return AddonPublishEvent{
159
+ Addon: Addon,
160
+ UserID: session.GetSessionUserID(ctx),
161
+ }
162
+ }
163
+
164
+ // AddonPublishEvent is an event that is emitted when an Addon is published
165
+ type AddonPublishEvent struct {
166
+ Addon *Addon `json:"addon"`
167
+ UserID *string `json:"userId,omitempty"`
168
+ }
169
+
170
+ func (e AddonPublishEvent) EventName() string {
171
+ return metadata.GetEventName(metadata.EventType{
172
+ Subsystem: AddonEventSubsystem,
173
+ Name: AddonPublishEventName,
174
+ Version: "v1",
175
+ })
176
+ }
177
+
178
+ func (e AddonPublishEvent) EventMetadata() metadata.EventMetadata {
179
+ resourcePath := metadata.ComposeResourcePath(e.Addon.Namespace, metadata.EntityAddon, e.Addon.ID)
180
+
181
+ return metadata.EventMetadata{
182
+ ID: ulid.Make().String(),
183
+ Source: resourcePath,
184
+ Subject: resourcePath,
185
+ Time: e.Addon.UpdatedAt,
186
+ }
187
+ }
188
+
189
+ func (e AddonPublishEvent) Validate() error {
190
+ var errs []error
191
+
192
+ if e.Addon == nil {
193
+ errs = append(errs, errors.New("add-on is required"))
194
+ }
195
+
196
+ return errors.Join(errs...)
197
+ }
198
+
199
+ // NewAddonArchiveEvent creates a new Addon archive event
200
+ func NewAddonArchiveEvent(ctx context.Context, Addon *Addon) AddonArchiveEvent {
201
+ return AddonArchiveEvent{
202
+ Addon: Addon,
203
+ UserID: session.GetSessionUserID(ctx),
204
+ }
205
+ }
206
+
207
+ // AddonArchiveEvent is an event that is emitted when an Addon is archived
208
+ type AddonArchiveEvent struct {
209
+ Addon *Addon `json:"addon"`
210
+ UserID *string `json:"userId,omitempty"`
211
+ }
212
+
213
+ func (e AddonArchiveEvent) EventName() string {
214
+ return metadata.GetEventName(metadata.EventType{
215
+ Subsystem: AddonEventSubsystem,
216
+ Name: AddonArchiveEventName,
217
+ Version: "v1",
218
+ })
219
+ }
220
+
221
+ func (e AddonArchiveEvent) EventMetadata() metadata.EventMetadata {
222
+ resourcePath := metadata.ComposeResourcePath(e.Addon.Namespace, metadata.EntityAddon, e.Addon.ID)
223
+
224
+ return metadata.EventMetadata{
225
+ ID: ulid.Make().String(),
226
+ Source: resourcePath,
227
+ Subject: resourcePath,
228
+ Time: e.Addon.UpdatedAt,
229
+ }
230
+ }
231
+
232
+ func (e AddonArchiveEvent) Validate() error {
233
+ var errs []error
234
+
235
+ if e.Addon == nil {
236
+ errs = append(errs, errors.New("add-on is required"))
237
+ }
238
+
239
+ return errors.Join(errs...)
240
+ }
openmeter/productcatalog/addon/httpdriver/addon.go ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package httpdriver
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "net/http"
7
+
8
+ "github.com/samber/lo"
9
+
10
+ "github.com/openmeterio/openmeter/api"
11
+ "github.com/openmeterio/openmeter/openmeter/notification"
12
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
13
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/addon"
14
+ productcataloghttp "github.com/openmeterio/openmeter/openmeter/productcatalog/http"
15
+ "github.com/openmeterio/openmeter/pkg/clock"
16
+ "github.com/openmeterio/openmeter/pkg/defaultx"
17
+ "github.com/openmeterio/openmeter/pkg/filter"
18
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
19
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
20
+ "github.com/openmeterio/openmeter/pkg/models"
21
+ "github.com/openmeterio/openmeter/pkg/pagination"
22
+ "github.com/openmeterio/openmeter/pkg/ref"
23
+ "github.com/openmeterio/openmeter/pkg/sortx"
24
+ )
25
+
26
+ type (
27
+ ListAddonsRequest = addon.ListAddonsInput
28
+ ListAddonsResponse = api.AddonPaginatedResponse
29
+ ListAddonsParams = api.ListAddonsParams
30
+ ListAddonsHandler httptransport.HandlerWithArgs[ListAddonsRequest, ListAddonsResponse, ListAddonsParams]
31
+ )
32
+
33
+ func (h *handler) ListAddons() ListAddonsHandler {
34
+ return httptransport.NewHandlerWithArgs(
35
+ func(ctx context.Context, r *http.Request, params ListAddonsParams) (ListAddonsRequest, error) {
36
+ ns, err := h.resolveNamespace(ctx)
37
+ if err != nil {
38
+ return ListAddonsRequest{}, fmt.Errorf("failed to resolve namespace [namespace=%s]: %w", ns, err)
39
+ }
40
+
41
+ var statusFilter []productcatalog.AddonStatus
42
+ if params.Status != nil {
43
+ statusFilter = lo.Map(*params.Status, func(status api.AddonStatus, _ int) productcatalog.AddonStatus {
44
+ return productcatalog.AddonStatus(status)
45
+ })
46
+ }
47
+
48
+ req := ListAddonsRequest{
49
+ OrderBy: addon.OrderBy(lo.FromPtrOr(params.OrderBy, api.AddonOrderById)),
50
+ Order: sortx.Order(defaultx.WithDefault(params.Order, api.SortOrderDESC)),
51
+ Page: pagination.Page{
52
+ PageSize: defaultx.WithDefault(params.PageSize, notification.DefaultPageSize),
53
+ PageNumber: defaultx.WithDefault(params.Page, notification.DefaultPageNumber),
54
+ },
55
+ Namespaces: []string{ns},
56
+ KeyVersions: lo.FromPtr(params.KeyVersion),
57
+ IncludeDeleted: lo.FromPtr(params.IncludeDeleted),
58
+ 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,
61
+ }
62
+
63
+ if params.Id != nil {
64
+ req.ID = &filter.FilterULID{FilterString: filter.FilterString{In: params.Id}}
65
+ }
66
+ if params.Key != nil {
67
+ req.Key = &filter.FilterString{In: params.Key}
68
+ }
69
+ if params.Currency != nil {
70
+ req.Currency = &filter.FilterString{In: params.Currency}
71
+ }
72
+
73
+ return req, nil
74
+ },
75
+ func(ctx context.Context, request ListAddonsRequest) (ListAddonsResponse, error) {
76
+ resp, err := h.service.ListAddons(ctx, request)
77
+ if err != nil {
78
+ return ListAddonsResponse{}, fmt.Errorf("failed to list add-ons: %w", err)
79
+ }
80
+
81
+ items := make([]api.Addon, 0, len(resp.Items))
82
+
83
+ for _, a := range resp.Items {
84
+ var item api.Addon
85
+
86
+ item, err = FromAddon(a)
87
+ if err != nil {
88
+ return ListAddonsResponse{}, fmt.Errorf("failed to cast add-on [namespace=%s key=%s]: %w", a.Namespace, a.Key, err)
89
+ }
90
+
91
+ items = append(items, item)
92
+ }
93
+
94
+ return ListAddonsResponse{
95
+ Items: items,
96
+ Page: resp.Page.PageNumber,
97
+ PageSize: resp.Page.PageSize,
98
+ TotalCount: resp.TotalCount,
99
+ }, nil
100
+ },
101
+ commonhttp.JSONResponseEncoderWithStatus[ListAddonsResponse](http.StatusOK),
102
+ httptransport.AppendOptions(
103
+ h.options,
104
+ httptransport.WithOperationName("listAddons"),
105
+ httptransport.WithErrorEncoder(productcataloghttp.ValidationErrorEncoder(productcataloghttp.ResourceKindAddon)),
106
+ )...,
107
+ )
108
+ }
109
+
110
+ type (
111
+ CreateAddonRequest = addon.CreateAddonInput
112
+ CreateAddonResponse = api.Addon
113
+ CreateAddonHandler httptransport.Handler[CreateAddonRequest, CreateAddonResponse]
114
+ )
115
+
116
+ func (h *handler) CreateAddon() CreateAddonHandler {
117
+ return httptransport.NewHandler(
118
+ func(ctx context.Context, r *http.Request) (CreateAddonRequest, error) {
119
+ body := api.AddonCreate{}
120
+ if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil {
121
+ return CreateAddonRequest{}, fmt.Errorf("failed to decode create add-on request: %w", err)
122
+ }
123
+
124
+ ns, err := h.resolveNamespace(ctx)
125
+ if err != nil {
126
+ return CreateAddonRequest{}, fmt.Errorf("failed to resolve namespace [namespace=%s]: %w", ns, err)
127
+ }
128
+
129
+ req, err := AsCreateAddonRequest(body, ns)
130
+ if err != nil {
131
+ return CreateAddonRequest{}, fmt.Errorf("failed to parse add-on request [namespace=%s key=%s]: %w", ns, body.Key, err)
132
+ }
133
+
134
+ req.NamespacedModel = models.NamespacedModel{
135
+ Namespace: ns,
136
+ }
137
+
138
+ req.IgnoreNonCriticalIssues = true
139
+
140
+ return req, nil
141
+ },
142
+ func(ctx context.Context, request CreateAddonRequest) (CreateAddonResponse, error) {
143
+ a, err := h.service.CreateAddon(ctx, request)
144
+ if err != nil {
145
+ return CreateAddonResponse{}, fmt.Errorf("failed to create add-on [namespace=%s key=%s]: %w", request.Namespace, request.Key, err)
146
+ }
147
+
148
+ return FromAddon(*a)
149
+ },
150
+ commonhttp.JSONResponseEncoderWithStatus[CreateAddonResponse](http.StatusCreated),
151
+ httptransport.AppendOptions(
152
+ h.options,
153
+ httptransport.WithOperationName("createAddon"),
154
+ httptransport.WithErrorEncoder(productcataloghttp.ValidationErrorEncoder(productcataloghttp.ResourceKindAddon)),
155
+ )...,
156
+ )
157
+ }
158
+
159
+ type (
160
+ UpdateAddonRequest = addon.UpdateAddonInput
161
+ UpdateAddonResponse = api.Addon
162
+ UpdateAddonHandler httptransport.HandlerWithArgs[UpdateAddonRequest, UpdateAddonResponse, string]
163
+ )
164
+
165
+ func (h *handler) UpdateAddon() UpdateAddonHandler {
166
+ return httptransport.NewHandlerWithArgs(
167
+ func(ctx context.Context, r *http.Request, addonID string) (UpdateAddonRequest, error) {
168
+ body := api.AddonReplaceUpdate{}
169
+ if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil {
170
+ return UpdateAddonRequest{}, fmt.Errorf("failed to decode update add-on request: %w", err)
171
+ }
172
+
173
+ ns, err := h.resolveNamespace(ctx)
174
+ if err != nil {
175
+ return UpdateAddonRequest{}, fmt.Errorf("failed to resolve namespace [namespace=%s]: %w", ns, err)
176
+ }
177
+
178
+ req, err := AsUpdateAddonRequest(body, ns, addonID)
179
+ if err != nil {
180
+ return UpdateAddonRequest{}, fmt.Errorf("failed to parse update add-on request [namespace=%s id=%s]: %w", ns, addonID, err)
181
+ }
182
+
183
+ req.NamespacedID = models.NamespacedID{
184
+ Namespace: ns,
185
+ ID: addonID,
186
+ }
187
+
188
+ req.IgnoreNonCriticalIssues = true
189
+
190
+ req.RejectUnitConfig = true
191
+
192
+ return req, nil
193
+ },
194
+ func(ctx context.Context, request UpdateAddonRequest) (UpdateAddonResponse, error) {
195
+ a, err := h.service.UpdateAddon(ctx, request)
196
+ if err != nil {
197
+ return UpdateAddonResponse{}, fmt.Errorf("failed to update add-on [namespace=%s id=%s]: %w", request.Namespace, request.ID, err)
198
+ }
199
+
200
+ return FromAddon(*a)
201
+ },
202
+ commonhttp.JSONResponseEncoderWithStatus[UpdateAddonResponse](http.StatusOK),
203
+ httptransport.AppendOptions(
204
+ h.options,
205
+ httptransport.WithOperationName("updateAddon"),
206
+ httptransport.WithErrorEncoder(productcataloghttp.ValidationErrorEncoder(productcataloghttp.ResourceKindAddon)),
207
+ )...,
208
+ )
209
+ }
210
+
211
+ type (
212
+ DeleteAddonRequest = addon.DeleteAddonInput
213
+ DeleteAddonResponse = interface{}
214
+ DeleteAddonHandler httptransport.HandlerWithArgs[DeleteAddonRequest, DeleteAddonResponse, string]
215
+ )
216
+
217
+ func (h *handler) DeleteAddon() DeleteAddonHandler {
218
+ return httptransport.NewHandlerWithArgs(
219
+ func(ctx context.Context, r *http.Request, addonID string) (DeleteAddonRequest, error) {
220
+ ns, err := h.resolveNamespace(ctx)
221
+ if err != nil {
222
+ return DeleteAddonRequest{}, fmt.Errorf("failed to resolve namespace [namespace=%s]: %w", ns, err)
223
+ }
224
+
225
+ return DeleteAddonRequest{
226
+ NamespacedID: models.NamespacedID{
227
+ Namespace: ns,
228
+ ID: addonID,
229
+ },
230
+ }, nil
231
+ },
232
+ func(ctx context.Context, request DeleteAddonRequest) (DeleteAddonResponse, error) {
233
+ err := h.service.DeleteAddon(ctx, request)
234
+ if err != nil {
235
+ return nil, fmt.Errorf("failed to delete add-on [namespace=%s id=%s]: %w", request.Namespace, request.ID, err)
236
+ }
237
+
238
+ return nil, nil
239
+ },
240
+ commonhttp.EmptyResponseEncoder[DeleteAddonResponse](http.StatusNoContent),
241
+ httptransport.AppendOptions(
242
+ h.options,
243
+ httptransport.WithOperationName("deleteAddon"),
244
+ httptransport.WithErrorEncoder(productcataloghttp.ValidationErrorEncoder(productcataloghttp.ResourceKindAddon)),
245
+ )...,
246
+ )
247
+ }
248
+
249
+ type (
250
+ GetAddonRequest = addon.GetAddonInput
251
+ GetAddonRequestParams struct {
252
+ // AddonID or Key.
253
+ IDOrKey string
254
+
255
+ // Version is the version of the add-on.
256
+ // If not set the latest version is assumed.
257
+ Version int
258
+
259
+ // AllowLatest defines whether return the latest version regardless of its AddonStatus or with ActiveStatus only if
260
+ // Version is not set.
261
+ IncludeLatest bool
262
+ }
263
+ GetAddonResponse = api.Addon
264
+ GetAddonHandler httptransport.HandlerWithArgs[GetAddonRequest, GetAddonResponse, GetAddonRequestParams]
265
+ )
266
+
267
+ func (h *handler) GetAddon() GetAddonHandler {
268
+ return httptransport.NewHandlerWithArgs(
269
+ func(ctx context.Context, r *http.Request, params GetAddonRequestParams) (GetAddonRequest, error) {
270
+ ns, err := h.resolveNamespace(ctx)
271
+ if err != nil {
272
+ return GetAddonRequest{}, fmt.Errorf("failed to resolve namespace [namespace=%s]: %w", ns, err)
273
+ }
274
+
275
+ // Try to detect whether the IdOrKey is an ID in ULID format or Key.
276
+ idOrKey := ref.ParseIDOrKey(params.IDOrKey)
277
+
278
+ return GetAddonRequest{
279
+ NamespacedID: models.NamespacedID{
280
+ Namespace: ns,
281
+ ID: idOrKey.ID,
282
+ },
283
+ Key: idOrKey.Key,
284
+ Version: params.Version,
285
+ IncludeLatest: params.IncludeLatest,
286
+ }, nil
287
+ },
288
+ func(ctx context.Context, request GetAddonRequest) (GetAddonResponse, error) {
289
+ a, err := h.service.GetAddon(ctx, request)
290
+ if err != nil {
291
+ return GetAddonResponse{}, fmt.Errorf("failed to get add-on [namespace=%s key=%s id=%s]: %w", request.Namespace, request.Key, request.ID, err)
292
+ }
293
+
294
+ if a.AsProductCatalogAddon().HasUnitConfig() {
295
+ return GetAddonResponse{}, productcatalog.ErrUnitConfigNotRepresentable
296
+ }
297
+
298
+ return FromAddon(*a)
299
+ },
300
+ commonhttp.JSONResponseEncoderWithStatus[GetAddonResponse](http.StatusOK),
301
+ httptransport.AppendOptions(
302
+ h.options,
303
+ httptransport.WithOperationName("getAddon"),
304
+ httptransport.WithErrorEncoder(productcataloghttp.ValidationErrorEncoder(productcataloghttp.ResourceKindAddon)),
305
+ )...,
306
+ )
307
+ }
308
+
309
+ type (
310
+ PublishAddonRequest = addon.PublishAddonInput
311
+ PublishAddonResponse = api.Addon
312
+ PublishAddonHandler httptransport.HandlerWithArgs[PublishAddonRequest, PublishAddonResponse, string]
313
+ )
314
+
315
+ func (h *handler) PublishAddon() PublishAddonHandler {
316
+ return httptransport.NewHandlerWithArgs(
317
+ func(ctx context.Context, r *http.Request, addonID string) (PublishAddonRequest, error) {
318
+ ns, err := h.resolveNamespace(ctx)
319
+ if err != nil {
320
+ return PublishAddonRequest{}, fmt.Errorf("failed to resolve namespace [namespace=%s]: %w", ns, err)
321
+ }
322
+
323
+ // TODO(chrisgacsal): update api.Request in TypeSpec definition to allow setting EffectivePeriod
324
+
325
+ req := PublishAddonRequest{
326
+ NamespacedID: models.NamespacedID{
327
+ Namespace: ns,
328
+ ID: addonID,
329
+ },
330
+ EffectivePeriod: productcatalog.EffectivePeriod{
331
+ EffectiveFrom: lo.ToPtr(clock.Now()),
332
+ },
333
+ RejectUnitConfig: true,
334
+ }
335
+
336
+ return req, nil
337
+ },
338
+ func(ctx context.Context, request PublishAddonRequest) (PublishAddonResponse, error) {
339
+ a, err := h.service.PublishAddon(ctx, request)
340
+ if err != nil {
341
+ return PublishAddonResponse{}, fmt.Errorf("failed to punlish add-on [namespace=%s id=%s]: %w", request.Namespace, request.ID, err)
342
+ }
343
+
344
+ return FromAddon(*a)
345
+ },
346
+ commonhttp.JSONResponseEncoderWithStatus[PublishAddonResponse](http.StatusOK),
347
+ httptransport.AppendOptions(
348
+ h.options,
349
+ httptransport.WithOperationName("publishAddon"),
350
+ httptransport.WithErrorEncoder(productcataloghttp.ValidationErrorEncoder(productcataloghttp.ResourceKindAddon)),
351
+ )...,
352
+ )
353
+ }
354
+
355
+ type (
356
+ ArchiveAddonRequest = addon.ArchiveAddonInput
357
+ ArchiveAddonResponse = api.Addon
358
+ ArchiveAddonHandler httptransport.HandlerWithArgs[ArchiveAddonRequest, ArchiveAddonResponse, string]
359
+ )
360
+
361
+ func (h *handler) ArchiveAddon() ArchiveAddonHandler {
362
+ return httptransport.NewHandlerWithArgs(
363
+ func(ctx context.Context, r *http.Request, addonID string) (ArchiveAddonRequest, error) {
364
+ ns, err := h.resolveNamespace(ctx)
365
+ if err != nil {
366
+ return ArchiveAddonRequest{}, fmt.Errorf("failed to resolve namespace [namespace=%s]: %w", ns, err)
367
+ }
368
+
369
+ // TODO(chrisgacsal): update api.Request in TypeSpec definition to allow setting EffectivePeriod.To
370
+
371
+ req := ArchiveAddonRequest{
372
+ NamespacedID: models.NamespacedID{
373
+ Namespace: ns,
374
+ ID: addonID,
375
+ },
376
+ EffectiveTo: clock.Now(),
377
+ // The v1 API cannot represent unit_config; reject archiving such an add-on.
378
+ RejectUnitConfig: true,
379
+ }
380
+
381
+ return req, nil
382
+ },
383
+ func(ctx context.Context, request ArchiveAddonRequest) (ArchiveAddonResponse, error) {
384
+ p, err := h.service.ArchiveAddon(ctx, request)
385
+ if err != nil {
386
+ return ArchiveAddonResponse{}, fmt.Errorf("failed to archive add-on [namespace=%s id=%s]: %w", request.Namespace, request.ID, err)
387
+ }
388
+
389
+ return FromAddon(*p)
390
+ },
391
+ commonhttp.JSONResponseEncoderWithStatus[ArchiveAddonResponse](http.StatusOK),
392
+ httptransport.AppendOptions(
393
+ h.options,
394
+ httptransport.WithOperationName("archiveAddon"),
395
+ httptransport.WithErrorEncoder(productcataloghttp.ValidationErrorEncoder(productcataloghttp.ResourceKindAddon)),
396
+ )...,
397
+ )
398
+ }
openmeter/productcatalog/addon/httpdriver/driver.go ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package httpdriver
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "net/http"
7
+
8
+ "github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver"
9
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/addon"
10
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
11
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
12
+ )
13
+
14
+ type Handler interface {
15
+ AddonHandler
16
+ }
17
+
18
+ type AddonHandler interface {
19
+ ListAddons() ListAddonsHandler
20
+ CreateAddon() CreateAddonHandler
21
+ DeleteAddon() DeleteAddonHandler
22
+ GetAddon() GetAddonHandler
23
+ UpdateAddon() UpdateAddonHandler
24
+ PublishAddon() PublishAddonHandler
25
+ ArchiveAddon() ArchiveAddonHandler
26
+ }
27
+
28
+ var _ Handler = (*handler)(nil)
29
+
30
+ type handler struct {
31
+ service addon.Service
32
+ namespaceDecoder namespacedriver.NamespaceDecoder
33
+ options []httptransport.HandlerOption
34
+ }
35
+
36
+ func (h *handler) resolveNamespace(ctx context.Context) (string, error) {
37
+ ns, ok := h.namespaceDecoder.GetNamespace(ctx)
38
+ if !ok {
39
+ return "", commonhttp.NewHTTPError(http.StatusInternalServerError, errors.New("internal server error"))
40
+ }
41
+
42
+ return ns, nil
43
+ }
44
+
45
+ func New(
46
+ namespaceDecoder namespacedriver.NamespaceDecoder,
47
+ service addon.Service,
48
+ options ...httptransport.HandlerOption,
49
+ ) Handler {
50
+ return &handler{
51
+ service: service,
52
+ namespaceDecoder: namespaceDecoder,
53
+ options: options,
54
+ }
55
+ }
openmeter/productcatalog/addon/httpdriver/mapping.go ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package httpdriver
2
+
3
+ import (
4
+ "fmt"
5
+
6
+ "github.com/invopop/gobl/currency"
7
+ "github.com/samber/lo"
8
+
9
+ "github.com/openmeterio/openmeter/api"
10
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
11
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/addon"
12
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/http"
13
+ "github.com/openmeterio/openmeter/pkg/models"
14
+ )
15
+
16
+ func FromAddon(a addon.Addon) (api.Addon, error) {
17
+ validationIssues, _ := a.AsProductCatalogAddon().ValidationErrors()
18
+
19
+ resp := api.Addon{
20
+ CreatedAt: a.CreatedAt,
21
+ Currency: a.Currency.String(),
22
+ DeletedAt: a.DeletedAt,
23
+ Description: a.Description,
24
+ InstanceType: api.AddonInstanceType(a.InstanceType),
25
+ EffectiveFrom: a.EffectiveFrom,
26
+ EffectiveTo: a.EffectiveTo,
27
+ Id: a.ID,
28
+ Key: a.Key,
29
+ Metadata: http.FromMetadata(a.Metadata),
30
+ Annotations: http.FromAnnotations(a.Annotations),
31
+ Name: a.Name,
32
+ UpdatedAt: a.UpdatedAt,
33
+ Version: a.Version,
34
+ ValidationErrors: http.FromValidationErrors(validationIssues),
35
+ }
36
+
37
+ resp.RateCards = make([]api.RateCard, 0, len(a.RateCards))
38
+ for _, rateCard := range a.RateCards.AsProductCatalogRateCards() {
39
+ rc, err := http.FromRateCard(rateCard)
40
+ if err != nil {
41
+ return resp, fmt.Errorf("failed to cast ratecard: %w", err)
42
+ }
43
+
44
+ resp.RateCards = append(resp.RateCards, rc)
45
+ }
46
+
47
+ switch a.Status() {
48
+ case productcatalog.AddonStatusDraft:
49
+ resp.Status = api.AddonStatusDraft
50
+ case productcatalog.AddonStatusActive:
51
+ resp.Status = api.AddonStatusActive
52
+ case productcatalog.AddonStatusArchived:
53
+ resp.Status = api.AddonStatusArchived
54
+ default:
55
+ return resp, fmt.Errorf("invalid add-on status: %s", a.Status())
56
+ }
57
+
58
+ return resp, nil
59
+ }
60
+
61
+ func AsCreateAddonRequest(a api.AddonCreate, namespace string) (CreateAddonRequest, error) {
62
+ var err error
63
+
64
+ req := CreateAddonRequest{
65
+ NamespacedModel: models.NamespacedModel{
66
+ Namespace: namespace,
67
+ },
68
+ Addon: productcatalog.Addon{
69
+ AddonMeta: productcatalog.AddonMeta{
70
+ Key: a.Key,
71
+ Name: a.Name,
72
+ Description: a.Description,
73
+ InstanceType: productcatalog.AddonInstanceType(a.InstanceType),
74
+ Metadata: lo.FromPtrOr(a.Metadata, nil),
75
+ },
76
+ RateCards: nil,
77
+ },
78
+ }
79
+
80
+ req.Currency = currency.Code(a.Currency)
81
+ if err = req.Currency.Validate(); err != nil {
82
+ return req, fmt.Errorf("invalid CurrencyCode: %w", err)
83
+ }
84
+
85
+ req.RateCards, err = http.AsRateCards(a.RateCards)
86
+ if err != nil {
87
+ return req, err
88
+ }
89
+
90
+ return req, nil
91
+ }
92
+
93
+ func AsUpdateAddonRequest(a api.AddonReplaceUpdate, namespace string, addonID string) (UpdateAddonRequest, error) {
94
+ req := UpdateAddonRequest{
95
+ NamespacedID: models.NamespacedID{
96
+ Namespace: namespace,
97
+ ID: addonID,
98
+ },
99
+ Name: lo.ToPtr(a.Name),
100
+ Description: a.Description,
101
+ InstanceType: lo.ToPtr(productcatalog.AddonInstanceType(a.InstanceType)),
102
+ Metadata: (*models.Metadata)(a.Metadata),
103
+ }
104
+
105
+ rateCards, err := http.AsRateCards(a.RateCards)
106
+ if err != nil {
107
+ return req, err
108
+ }
109
+
110
+ req.RateCards = &rateCards
111
+
112
+ return req, nil
113
+ }
openmeter/productcatalog/addon/plan.go ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package addon
2
+
3
+ import (
4
+ "errors"
5
+
6
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
7
+ "github.com/openmeterio/openmeter/pkg/models"
8
+ )
9
+
10
+ var _ models.Validator = (*Addon)(nil)
11
+
12
+ // Plan stores the Addon specific representation of planaddon.PlanAddon.
13
+ type Plan struct {
14
+ models.NamespacedID
15
+ models.ManagedModel
16
+
17
+ productcatalog.PlanAddonMeta
18
+ productcatalog.Plan
19
+ }
20
+
21
+ func (p Plan) Validate() error {
22
+ var errs []error
23
+
24
+ if err := p.NamespacedID.Validate(); err != nil {
25
+ errs = append(errs, err)
26
+ }
27
+
28
+ if err := p.Plan.Validate(); err != nil {
29
+ errs = append(errs, err)
30
+ }
31
+
32
+ return models.NewNillableGenericValidationError(errors.Join(errs...))
33
+ }
openmeter/productcatalog/addon/ratecard.go ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package addon
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "fmt"
7
+
8
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
9
+ "github.com/openmeterio/openmeter/pkg/models"
10
+ )
11
+
12
+ var (
13
+ _ models.Validator = (*RateCardManagedFields)(nil)
14
+ _ models.Equaler[RateCardManagedFields] = (*RateCardManagedFields)(nil)
15
+ )
16
+
17
+ type RateCardManagedFields struct {
18
+ models.ManagedModel
19
+ models.NamespacedID
20
+
21
+ // AddonID defines the Addon the RateCard assigned to.
22
+ AddonID string `json:"addonId"`
23
+ }
24
+
25
+ func (m RateCardManagedFields) Equal(v RateCardManagedFields) bool {
26
+ if m.Namespace != v.Namespace {
27
+ return false
28
+ }
29
+
30
+ if m.ID != v.ID {
31
+ return false
32
+ }
33
+
34
+ return m.AddonID == v.AddonID
35
+ }
36
+
37
+ func (m RateCardManagedFields) Validate() error {
38
+ var errs []error
39
+
40
+ if m.Namespace == "" {
41
+ errs = append(errs, errors.New("namespace must not be empty"))
42
+ }
43
+
44
+ if m.ID == "" {
45
+ errs = append(errs, errors.New("id must not be empty"))
46
+ }
47
+
48
+ return models.NewNillableGenericValidationError(errors.Join(errs...))
49
+ }
50
+
51
+ type ManagedRateCard interface {
52
+ ManagedFields() RateCardManagedFields
53
+ }
54
+
55
+ var (
56
+ _ ManagedRateCard = (*RateCard)(nil)
57
+ _ productcatalog.RateCard = (*RateCard)(nil)
58
+ )
59
+
60
+ type RateCard struct {
61
+ productcatalog.RateCard
62
+ RateCardManagedFields
63
+ }
64
+
65
+ func (r *RateCard) ManagedFields() RateCardManagedFields {
66
+ return r.RateCardManagedFields
67
+ }
68
+
69
+ func (r *RateCard) Equal(v productcatalog.RateCard) bool {
70
+ if managed, ok := (v).(ManagedRateCard); ok {
71
+ if !r.RateCardManagedFields.Equal(managed.ManagedFields()) {
72
+ return false
73
+ }
74
+ }
75
+
76
+ if !r.RateCard.Equal(v) {
77
+ return false
78
+ }
79
+
80
+ return true
81
+ }
82
+
83
+ func (r *RateCard) Validate() error {
84
+ var errs []error
85
+
86
+ if err := r.RateCard.Validate(); err != nil {
87
+ errs = append(errs, err)
88
+ }
89
+
90
+ if err := r.RateCardManagedFields.Validate(); err != nil {
91
+ errs = append(errs, err)
92
+ }
93
+
94
+ if r.AddonID == "" {
95
+ errs = append(errs, errors.New("addonId must not be empty"))
96
+ }
97
+
98
+ return models.NewNillableGenericValidationError(errors.Join(errs...))
99
+ }
100
+
101
+ func (r *RateCard) MarshalJSON() ([]byte, error) {
102
+ serde := struct {
103
+ productcatalog.RateCardSerde
104
+ productcatalog.RateCard
105
+ RateCardManagedFields
106
+ }{
107
+ RateCardSerde: productcatalog.RateCardSerde{
108
+ Type: r.Type(),
109
+ },
110
+ RateCard: r.RateCard,
111
+ RateCardManagedFields: r.RateCardManagedFields,
112
+ }
113
+
114
+ return json.Marshal(serde)
115
+ }
116
+
117
+ func (r *RateCard) UnmarshalJSON(b []byte) error {
118
+ var s productcatalog.RateCardSerde
119
+ err := json.Unmarshal(b, &s)
120
+ if err != nil {
121
+ return fmt.Errorf("failed to JSON deserialize RateCard type: %w", err)
122
+ }
123
+
124
+ serde := struct {
125
+ productcatalog.RateCard
126
+ RateCardManagedFields
127
+ }{
128
+ RateCardManagedFields: r.RateCardManagedFields,
129
+ RateCard: r.RateCard,
130
+ }
131
+
132
+ switch s.Type {
133
+ case productcatalog.FlatFeeRateCardType:
134
+ serde.RateCard = &productcatalog.FlatFeeRateCard{}
135
+ case productcatalog.UsageBasedRateCardType:
136
+ serde.RateCard = &productcatalog.UsageBasedRateCard{}
137
+ default:
138
+ return fmt.Errorf("invalid RateCard type: %s", s.Type)
139
+ }
140
+
141
+ err = json.Unmarshal(b, &serde)
142
+ if err != nil {
143
+ return fmt.Errorf("failed to JSON deserialize UsageBasedRateCard: %w", err)
144
+ }
145
+
146
+ r.RateCardManagedFields = serde.RateCardManagedFields
147
+ r.RateCard = serde.RateCard
148
+
149
+ return nil
150
+ }
151
+
152
+ type RateCards []RateCard
153
+
154
+ func (c RateCards) At(idx int) RateCard {
155
+ return c[idx]
156
+ }
157
+
158
+ func (c RateCards) AsProductCatalogRateCards() productcatalog.RateCards {
159
+ var rcs productcatalog.RateCards
160
+
161
+ for _, rc := range c {
162
+ rcs = append(rcs, rc.RateCard)
163
+ }
164
+
165
+ return rcs
166
+ }
167
+
168
+ func (c RateCards) SingleBillingCadence() bool {
169
+ return c.AsProductCatalogRateCards().SingleBillingCadence()
170
+ }
171
+
172
+ func (c RateCards) Equal(v RateCards) bool {
173
+ if len(c) != len(v) {
174
+ return false
175
+ }
176
+
177
+ leftSet := make(map[string]RateCard)
178
+ for _, rc := range c {
179
+ leftSet[rc.Key()] = rc
180
+ }
181
+
182
+ rightSet := make(map[string]RateCard)
183
+ for _, rc := range v {
184
+ rightSet[rc.Key()] = rc
185
+ }
186
+
187
+ if len(leftSet) != len(rightSet) {
188
+ return false
189
+ }
190
+
191
+ var visited int
192
+ for key, left := range leftSet {
193
+ right, ok := rightSet[key]
194
+ if !ok {
195
+ return false
196
+ }
197
+
198
+ if !left.Equal(&right) {
199
+ return false
200
+ }
201
+
202
+ visited++
203
+ }
204
+
205
+ return visited == len(rightSet)
206
+ }
207
+
208
+ func (c RateCards) Validate() error {
209
+ var errs []error
210
+
211
+ for _, rc := range c {
212
+ if err := rc.Validate(); err != nil {
213
+ errs = append(errs, err)
214
+ }
215
+ }
216
+
217
+ return models.NewNillableGenericValidationError(errors.Join(errs...))
218
+ }
openmeter/productcatalog/addon/ratecard_test.go ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package addon
2
+
3
+ import (
4
+ "encoding/json"
5
+ "testing"
6
+ "time"
7
+
8
+ decimal "github.com/alpacahq/alpacadecimal"
9
+ "github.com/samber/lo"
10
+ "github.com/stretchr/testify/assert"
11
+ "github.com/stretchr/testify/require"
12
+
13
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
14
+ "github.com/openmeterio/openmeter/pkg/datetime"
15
+ "github.com/openmeterio/openmeter/pkg/models"
16
+ )
17
+
18
+ func TestRateCard_JSON(t *testing.T) {
19
+ tests := []struct {
20
+ Name string
21
+ RateCard productcatalog.RateCard
22
+ ExpectedError bool
23
+ }{
24
+ {
25
+ Name: "FlatFee",
26
+ RateCard: &RateCard{
27
+ RateCardManagedFields: RateCardManagedFields{
28
+ ManagedModel: models.ManagedModel{
29
+ CreatedAt: time.Now().Add(-2 * time.Hour).UTC(),
30
+ UpdatedAt: time.Now().Add(-1 * time.Hour).UTC(),
31
+ DeletedAt: lo.ToPtr(time.Now().UTC()),
32
+ },
33
+ NamespacedID: models.NamespacedID{
34
+ Namespace: "namespace-1",
35
+ ID: "01JDPHJMKJ8SNYTK0GK88VD0E9",
36
+ },
37
+ AddonID: "01JDPHJMKKT1S3XF47V2AGMA6J",
38
+ },
39
+ RateCard: &productcatalog.FlatFeeRateCard{
40
+ RateCardMeta: productcatalog.RateCardMeta{
41
+ Key: "feature-1",
42
+ Name: "RateCard 1",
43
+ Description: lo.ToPtr("RateCard 1"),
44
+ Metadata: map[string]string{
45
+ "key": "value",
46
+ },
47
+ FeatureKey: lo.ToPtr("feature-1"),
48
+ FeatureID: lo.ToPtr("01JBP3SGZ20Y7VRVC351TDFXYZ"),
49
+ EntitlementTemplate: productcatalog.NewEntitlementTemplateFrom(
50
+ productcatalog.StaticEntitlementTemplate{
51
+ Metadata: map[string]string{
52
+ "key": "value",
53
+ },
54
+ Config: []byte(`{"key":"value"}`),
55
+ }),
56
+ TaxConfig: &productcatalog.TaxConfig{
57
+ Stripe: &productcatalog.StripeTaxConfig{
58
+ Code: "txcd_99999999",
59
+ },
60
+ },
61
+ Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
62
+ Amount: decimal.NewFromInt(1000),
63
+ PaymentTerm: productcatalog.InAdvancePaymentTerm,
64
+ }),
65
+ },
66
+ BillingCadence: lo.ToPtr(datetime.MustParseDuration(t, "P1M")),
67
+ },
68
+ },
69
+ },
70
+ {
71
+ Name: "UsageBased",
72
+ RateCard: &RateCard{
73
+ RateCardManagedFields: RateCardManagedFields{
74
+ ManagedModel: models.ManagedModel{
75
+ CreatedAt: time.Now().Add(-2 * time.Hour).UTC(),
76
+ UpdatedAt: time.Now().Add(-1 * time.Hour).UTC(),
77
+ DeletedAt: lo.ToPtr(time.Now().UTC()),
78
+ },
79
+ NamespacedID: models.NamespacedID{
80
+ Namespace: "namespace-2",
81
+ ID: "01JDPHJMKKHSBYD60YR9D26EST",
82
+ },
83
+ AddonID: "01JDPHJMKKH4YDJTQY5F3EAHCF",
84
+ },
85
+ RateCard: &productcatalog.UsageBasedRateCard{
86
+ RateCardMeta: productcatalog.RateCardMeta{
87
+ Key: "feature-2",
88
+ Name: "RateCard 2",
89
+ Description: lo.ToPtr("RateCard 2"),
90
+ Metadata: map[string]string{
91
+ "key": "value",
92
+ },
93
+ FeatureKey: lo.ToPtr("feature-2"),
94
+ FeatureID: lo.ToPtr("01JBP3SGZ20Y7VRVC351TDFXYZ"),
95
+ EntitlementTemplate: productcatalog.NewEntitlementTemplateFrom(
96
+ productcatalog.MeteredEntitlementTemplate{
97
+ Metadata: map[string]string{
98
+ "key": "value",
99
+ },
100
+ IsSoftLimit: true,
101
+ IssueAfterReset: lo.ToPtr(500.0),
102
+ IssueAfterResetPriority: lo.ToPtr[uint8](1),
103
+ PreserveOverageAtReset: lo.ToPtr(true),
104
+ UsagePeriod: datetime.MustParseDuration(t, "P1M"),
105
+ }),
106
+ TaxConfig: &productcatalog.TaxConfig{
107
+ Stripe: &productcatalog.StripeTaxConfig{
108
+ Code: "txcd_99999999",
109
+ },
110
+ },
111
+ Price: productcatalog.NewPriceFrom(
112
+ productcatalog.UnitPrice{
113
+ Amount: decimal.NewFromInt(1000),
114
+ Commitments: productcatalog.Commitments{
115
+ MinimumAmount: lo.ToPtr(decimal.NewFromInt(10)),
116
+ MaximumAmount: lo.ToPtr(decimal.NewFromInt(1000)),
117
+ },
118
+ }),
119
+ Discounts: productcatalog.Discounts{
120
+ Percentage: &productcatalog.PercentageDiscount{
121
+ Percentage: models.NewPercentage(10),
122
+ },
123
+ Usage: &productcatalog.UsageDiscount{
124
+ Quantity: decimal.NewFromInt(100),
125
+ },
126
+ },
127
+ },
128
+ BillingCadence: datetime.MustParseDuration(t, "P1M"),
129
+ },
130
+ },
131
+ },
132
+ }
133
+
134
+ for _, test := range tests {
135
+ t.Run(test.Name, func(t *testing.T) {
136
+ b, err := json.Marshal(&test.RateCard)
137
+ require.NoErrorf(t, err, "serializing RateCard must not fail")
138
+
139
+ t.Logf("Serialized RateCard: %s", string(b))
140
+
141
+ var rc *RateCard
142
+ err = json.Unmarshal(b, &rc)
143
+ require.NoErrorf(t, err, "deserializing RateCard must not fail")
144
+
145
+ assert.Equal(t, test.RateCard, rc)
146
+ })
147
+ }
148
+ }
149
+
150
+ func TestFlatFeeRateCard(t *testing.T) {
151
+ t.Run("Validate", func(t *testing.T) {
152
+ tests := []struct {
153
+ Name string
154
+ RateCard RateCard
155
+ ExpectedError bool
156
+ }{
157
+ {
158
+ Name: "valid",
159
+ RateCard: RateCard{
160
+ RateCardManagedFields: RateCardManagedFields{
161
+ ManagedModel: models.ManagedModel{
162
+ CreatedAt: time.Now().Add(-2 * time.Hour).UTC(),
163
+ UpdatedAt: time.Now().Add(-1 * time.Hour).UTC(),
164
+ DeletedAt: lo.ToPtr(time.Now().UTC()),
165
+ },
166
+ NamespacedID: models.NamespacedID{
167
+ Namespace: "namespace-1",
168
+ ID: "01JDPHJMKKBARD45QV203H97CE",
169
+ },
170
+ AddonID: "01JDPHJMKK2WFF1D8AD5SYB2P1",
171
+ },
172
+ RateCard: &productcatalog.FlatFeeRateCard{
173
+ RateCardMeta: productcatalog.RateCardMeta{
174
+ Key: "feat-1",
175
+ Name: "Flat 1",
176
+ Description: lo.ToPtr("Flat 1"),
177
+ Metadata: map[string]string{
178
+ "name": "Flat 1",
179
+ },
180
+ FeatureKey: lo.ToPtr("feat-1"),
181
+ FeatureID: lo.ToPtr("01JBP3SGZ20Y7VRVC351TDFXYZ"),
182
+ EntitlementTemplate: productcatalog.NewEntitlementTemplateFrom(
183
+ productcatalog.StaticEntitlementTemplate{
184
+ Metadata: map[string]string{
185
+ "name": "static-1",
186
+ },
187
+ Config: []byte(`"test"`),
188
+ }),
189
+ TaxConfig: &productcatalog.TaxConfig{
190
+ Stripe: &productcatalog.StripeTaxConfig{
191
+ Code: "txcd_99999999",
192
+ },
193
+ },
194
+ Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
195
+ Amount: decimal.NewFromInt(1000),
196
+ PaymentTerm: productcatalog.InArrearsPaymentTerm,
197
+ }),
198
+ },
199
+ BillingCadence: lo.ToPtr(datetime.MustParseDuration(t, "P1M")),
200
+ },
201
+ },
202
+ ExpectedError: false,
203
+ },
204
+ {
205
+ Name: "invalid",
206
+ RateCard: RateCard{
207
+ RateCardManagedFields: RateCardManagedFields{
208
+ ManagedModel: models.ManagedModel{
209
+ CreatedAt: time.Now().Add(-2 * time.Hour).UTC(),
210
+ UpdatedAt: time.Now().Add(-1 * time.Hour).UTC(),
211
+ DeletedAt: lo.ToPtr(time.Now().UTC()),
212
+ },
213
+ NamespacedID: models.NamespacedID{
214
+ Namespace: "namespace-2",
215
+ ID: "01JDPHJMKK6T8QBKQQQWGCCXYT",
216
+ },
217
+ AddonID: "01JDPHJMKKZCTPZMD5SYDJENP3",
218
+ },
219
+ RateCard: &productcatalog.FlatFeeRateCard{
220
+ RateCardMeta: productcatalog.RateCardMeta{
221
+ Key: "feat-2",
222
+ Name: "Flat 2",
223
+ Description: lo.ToPtr("Flat 2"),
224
+ Metadata: map[string]string{
225
+ "name": "Flat 2",
226
+ },
227
+ FeatureKey: lo.ToPtr("feat-2"),
228
+ FeatureID: lo.ToPtr("01JBP3SGZ2YTM6DVH2W318TPNH"),
229
+ EntitlementTemplate: productcatalog.NewEntitlementTemplateFrom(
230
+ productcatalog.StaticEntitlementTemplate{
231
+ Metadata: map[string]string{
232
+ "name": "static-1",
233
+ },
234
+ Config: []byte("invalid JSON"),
235
+ }),
236
+ TaxConfig: &productcatalog.TaxConfig{
237
+ Stripe: &productcatalog.StripeTaxConfig{
238
+ Code: "invalid_code",
239
+ },
240
+ },
241
+ Price: productcatalog.NewPriceFrom(
242
+ productcatalog.FlatPrice{
243
+ Amount: decimal.NewFromInt(-1000),
244
+ PaymentTerm: "invalid",
245
+ }),
246
+ },
247
+ BillingCadence: lo.ToPtr(datetime.MustParseDuration(t, "P0M")),
248
+ },
249
+ },
250
+ ExpectedError: true,
251
+ },
252
+ }
253
+
254
+ for _, test := range tests {
255
+ t.Run(test.Name, func(t *testing.T) {
256
+ err := test.RateCard.Validate()
257
+
258
+ if test.ExpectedError {
259
+ assert.Error(t, err)
260
+ } else {
261
+ assert.NoError(t, err)
262
+ }
263
+ })
264
+ }
265
+ })
266
+ }
267
+
268
+ func TestUsageBasedRateCard(t *testing.T) {
269
+ t.Run("Validate", func(t *testing.T) {
270
+ tests := []struct {
271
+ Name string
272
+ RateCard RateCard
273
+ ExpectedError bool
274
+ }{
275
+ {
276
+ Name: "valid",
277
+ RateCard: RateCard{
278
+ RateCardManagedFields: RateCardManagedFields{
279
+ ManagedModel: models.ManagedModel{
280
+ CreatedAt: time.Now().Add(-2 * time.Hour).UTC(),
281
+ UpdatedAt: time.Now().Add(-1 * time.Hour).UTC(),
282
+ DeletedAt: lo.ToPtr(time.Now().UTC()),
283
+ },
284
+ NamespacedID: models.NamespacedID{
285
+ Namespace: "namespace-1",
286
+ ID: "01JDPHJMKKK8MN7DNTEPS7BJ65",
287
+ },
288
+ AddonID: "01JDPHJMKK9J7Z45XRM4J3DS72",
289
+ },
290
+ RateCard: &productcatalog.UsageBasedRateCard{
291
+ RateCardMeta: productcatalog.RateCardMeta{
292
+ Key: "feat-1",
293
+ Name: "Usage 1",
294
+ Description: lo.ToPtr("Usage 1"),
295
+ Metadata: map[string]string{
296
+ "name": "usage-1",
297
+ },
298
+ FeatureKey: lo.ToPtr("feat-1"),
299
+ FeatureID: lo.ToPtr("01JBP3SGZ20Y7VRVC351TDFXYZ"),
300
+ EntitlementTemplate: productcatalog.NewEntitlementTemplateFrom(
301
+ productcatalog.MeteredEntitlementTemplate{
302
+ Metadata: map[string]string{
303
+ "name": "Entitlement 1",
304
+ },
305
+ IsSoftLimit: true,
306
+ IssueAfterReset: lo.ToPtr(500.0),
307
+ IssueAfterResetPriority: lo.ToPtr[uint8](1),
308
+ PreserveOverageAtReset: nil,
309
+ UsagePeriod: datetime.MustParseDuration(t, "P1M"),
310
+ }),
311
+ TaxConfig: &productcatalog.TaxConfig{
312
+ Stripe: &productcatalog.StripeTaxConfig{
313
+ Code: "txcd_99999999",
314
+ },
315
+ },
316
+ Price: productcatalog.NewPriceFrom(
317
+ productcatalog.UnitPrice{
318
+ Amount: decimal.NewFromInt(1000),
319
+ Commitments: productcatalog.Commitments{
320
+ MinimumAmount: lo.ToPtr(decimal.NewFromInt(500)),
321
+ MaximumAmount: lo.ToPtr(decimal.NewFromInt(1500)),
322
+ },
323
+ }),
324
+ },
325
+ BillingCadence: datetime.MustParseDuration(t, "P1M"),
326
+ },
327
+ },
328
+ ExpectedError: false,
329
+ },
330
+ {
331
+ Name: "invalid",
332
+ RateCard: RateCard{
333
+ RateCardManagedFields: RateCardManagedFields{
334
+ ManagedModel: models.ManagedModel{
335
+ CreatedAt: time.Now().Add(-2 * time.Hour).UTC(),
336
+ UpdatedAt: time.Now().Add(-1 * time.Hour).UTC(),
337
+ DeletedAt: lo.ToPtr(time.Now().UTC()),
338
+ },
339
+ NamespacedID: models.NamespacedID{
340
+ Namespace: "namespace-2",
341
+ ID: "01JDPHJMKK6RGN078EQEPHVJS2",
342
+ },
343
+ AddonID: "01JDPHJMKKBZFWS90VX5BFFKPE",
344
+ },
345
+ RateCard: &productcatalog.UsageBasedRateCard{
346
+ RateCardMeta: productcatalog.RateCardMeta{
347
+ Key: "feat-2",
348
+ Name: "Usage 2",
349
+ Description: lo.ToPtr("Usage 2"),
350
+ Metadata: map[string]string{
351
+ "name": "usage-2",
352
+ },
353
+ FeatureKey: lo.ToPtr("feat-2"),
354
+ FeatureID: lo.ToPtr("01JBWYR0G2PYB9DVADKQXF8E0P"),
355
+ EntitlementTemplate: productcatalog.NewEntitlementTemplateFrom(
356
+ productcatalog.MeteredEntitlementTemplate{
357
+ Metadata: map[string]string{
358
+ "name": "Entitlement 1",
359
+ },
360
+ IsSoftLimit: true,
361
+ IssueAfterReset: lo.ToPtr(500.0),
362
+ IssueAfterResetPriority: lo.ToPtr[uint8](1),
363
+ PreserveOverageAtReset: nil,
364
+ UsagePeriod: datetime.MustParseDuration(t, "P1M"),
365
+ }),
366
+ TaxConfig: &productcatalog.TaxConfig{
367
+ Stripe: &productcatalog.StripeTaxConfig{
368
+ Code: "invalid_code",
369
+ },
370
+ },
371
+ Price: productcatalog.NewPriceFrom(
372
+ productcatalog.UnitPrice{
373
+ Amount: decimal.NewFromInt(-1000),
374
+ Commitments: productcatalog.Commitments{
375
+ MinimumAmount: lo.ToPtr(decimal.NewFromInt(1500)),
376
+ MaximumAmount: lo.ToPtr(decimal.NewFromInt(500)),
377
+ },
378
+ }),
379
+ },
380
+ BillingCadence: datetime.MustParseDuration(t, "P0M"),
381
+ },
382
+ },
383
+ ExpectedError: true,
384
+ },
385
+ }
386
+
387
+ for _, test := range tests {
388
+ t.Run(test.Name, func(t *testing.T) {
389
+ err := test.RateCard.Validate()
390
+
391
+ if test.ExpectedError {
392
+ assert.Error(t, err)
393
+ } else {
394
+ assert.NoError(t, err)
395
+ }
396
+ })
397
+ }
398
+ })
399
+ }
openmeter/productcatalog/addon/repository.go ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package addon
2
+
3
+ import (
4
+ "context"
5
+
6
+ "github.com/openmeterio/openmeter/pkg/framework/entutils"
7
+ "github.com/openmeterio/openmeter/pkg/pagination"
8
+ )
9
+
10
+ // TODO: add bulk api
11
+
12
+ type Repository interface {
13
+ entutils.TxCreator
14
+
15
+ ListAddons(ctx context.Context, params ListAddonsInput) (pagination.Result[Addon], error)
16
+ CreateAddon(ctx context.Context, params CreateAddonInput) (*Addon, error)
17
+ DeleteAddon(ctx context.Context, params DeleteAddonInput) error
18
+ GetAddon(ctx context.Context, params GetAddonInput) (*Addon, error)
19
+ UpdateAddon(ctx context.Context, params UpdateAddonInput) (*Addon, error)
20
+ }
openmeter/productcatalog/addon/service.go ADDED
@@ -0,0 +1,468 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package addon
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "slices"
8
+ "time"
9
+
10
+ "github.com/samber/lo"
11
+
12
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
13
+ "github.com/openmeterio/openmeter/pkg/clock"
14
+ "github.com/openmeterio/openmeter/pkg/filter"
15
+ "github.com/openmeterio/openmeter/pkg/models"
16
+ "github.com/openmeterio/openmeter/pkg/pagination"
17
+ "github.com/openmeterio/openmeter/pkg/sortx"
18
+ )
19
+
20
+ const timeJitter = 30 * time.Second
21
+
22
+ const (
23
+ OrderAsc = sortx.OrderAsc
24
+ OrderDesc = sortx.OrderDesc
25
+ )
26
+
27
+ const (
28
+ OrderByID OrderBy = "id"
29
+ OrderByKey OrderBy = "key"
30
+ OrderByVersion OrderBy = "version"
31
+ OrderByCreatedAt OrderBy = "created_at"
32
+ OrderByUpdatedAt OrderBy = "updated_at"
33
+ OrderByName OrderBy = "name"
34
+ )
35
+
36
+ type OrderBy string
37
+
38
+ func (f OrderBy) Values() []OrderBy {
39
+ return []OrderBy{
40
+ OrderByID,
41
+ OrderByKey,
42
+ OrderByVersion,
43
+ OrderByCreatedAt,
44
+ OrderByUpdatedAt,
45
+ OrderByName,
46
+ }
47
+ }
48
+
49
+ func (f OrderBy) Validate() error {
50
+ if !slices.Contains(f.Values(), f) {
51
+ return models.NewGenericValidationError(fmt.Errorf("invalid add-on order by: %s", f))
52
+ }
53
+
54
+ return nil
55
+ }
56
+
57
+ type Service interface {
58
+ ListAddons(ctx context.Context, params ListAddonsInput) (pagination.Result[Addon], error)
59
+ CreateAddon(ctx context.Context, params CreateAddonInput) (*Addon, error)
60
+ DeleteAddon(ctx context.Context, params DeleteAddonInput) error
61
+ GetAddon(ctx context.Context, params GetAddonInput) (*Addon, error)
62
+ UpdateAddon(ctx context.Context, params UpdateAddonInput) (*Addon, error)
63
+ PublishAddon(ctx context.Context, params PublishAddonInput) (*Addon, error)
64
+ ArchiveAddon(ctx context.Context, params ArchiveAddonInput) (*Addon, error)
65
+ NextAddon(ctx context.Context, params NextAddonInput) (*Addon, error)
66
+ }
67
+
68
+ var _ models.Validator = (*ListAddonsInput)(nil)
69
+
70
+ type ListAddonsInput struct {
71
+ // Page is the pagination parameters.
72
+ // TODO: make it optional.
73
+ pagination.Page
74
+
75
+ // OrderBy is the field to order by.
76
+ OrderBy OrderBy
77
+
78
+ // Order is the order direction.
79
+ Order sortx.Order
80
+
81
+ // Namespaces is the list of namespaces to filter by.
82
+ Namespaces []string
83
+
84
+ // KeyVersions is the map of keys to versions to filter by.
85
+ KeyVersions map[string][]int
86
+
87
+ // IncludeDeleted defines whether to include deleted Addons.
88
+ IncludeDeleted bool
89
+
90
+ // Status filter
91
+ Status []productcatalog.AddonStatus
92
+
93
+ // Filters
94
+ ID *filter.FilterULID
95
+ Key *filter.FilterString
96
+ Name *filter.FilterString
97
+ Currency *filter.FilterString
98
+
99
+ // ExcludeUnitConfig omits add-ons carrying a unit_config conversion on any of their rate cards.
100
+ ExcludeUnitConfig bool
101
+ }
102
+
103
+ func (i ListAddonsInput) Validate() error {
104
+ var errs []error
105
+
106
+ if i.ID != nil {
107
+ if err := i.ID.Validate(); err != nil {
108
+ errs = append(errs, err)
109
+ }
110
+ }
111
+ if i.Key != nil {
112
+ if err := i.Key.Validate(); err != nil {
113
+ errs = append(errs, err)
114
+ }
115
+ }
116
+ if i.Name != nil {
117
+ if err := i.Name.Validate(); err != nil {
118
+ errs = append(errs, err)
119
+ }
120
+ }
121
+ if i.Currency != nil {
122
+ if err := i.Currency.Validate(); err != nil {
123
+ errs = append(errs, err)
124
+ }
125
+ }
126
+
127
+ if i.OrderBy != "" {
128
+ if err := i.OrderBy.Validate(); err != nil {
129
+ errs = append(errs, err)
130
+ }
131
+ }
132
+
133
+ return models.NewNillableGenericValidationError(errors.Join(errs...))
134
+ }
135
+
136
+ type ListAddonsStatusFilter struct {
137
+ // Active signals that the active Addons should be returned.
138
+ Active bool
139
+
140
+ // Draft signals that the draft Addons should be returned.
141
+ Draft bool
142
+
143
+ // Archived signals that the archived Addons should be returned.
144
+ Archived bool
145
+ }
146
+
147
+ type inputOptions struct {
148
+ // ignoreNonCriticalIssues makes Validate() return errors with critical severity or higher.
149
+ // This allows creating resource with expected validation issues.
150
+ IgnoreNonCriticalIssues bool
151
+ }
152
+
153
+ var _ models.Validator = (*CreateAddonInput)(nil)
154
+
155
+ type CreateAddonInput struct {
156
+ models.NamespacedModel
157
+ productcatalog.Addon
158
+
159
+ inputOptions
160
+ }
161
+
162
+ func (i CreateAddonInput) Validate() error {
163
+ var errs []error
164
+
165
+ if i.Namespace == "" {
166
+ errs = append(errs, productcatalog.ErrNamespaceEmpty)
167
+ }
168
+
169
+ if err := i.Addon.Validate(); err != nil {
170
+ errs = append(errs, fmt.Errorf("invalid add-on: %w", err))
171
+ }
172
+
173
+ issues, err := models.AsValidationIssues(errors.Join(errs...))
174
+ if err != nil {
175
+ return models.NewGenericValidationError(err)
176
+ }
177
+
178
+ if i.IgnoreNonCriticalIssues {
179
+ issues = issues.WithSeverityOrHigher(models.ErrorSeverityCritical)
180
+ }
181
+
182
+ return models.NewNillableGenericValidationError(issues.AsError())
183
+ }
184
+
185
+ var (
186
+ _ models.Validator = (*UpdateAddonInput)(nil)
187
+ _ models.Equaler[Addon] = (*UpdateAddonInput)(nil)
188
+ )
189
+
190
+ type UpdateAddonInput struct {
191
+ models.NamespacedID
192
+
193
+ // EffectivePeriod
194
+ productcatalog.EffectivePeriod
195
+
196
+ // Name
197
+ Name *string `json:"name"`
198
+
199
+ // Description
200
+ Description *string `json:"description,omitempty"`
201
+
202
+ // Metadata
203
+ Metadata *models.Metadata `json:"metadata,omitempty"`
204
+
205
+ // Metadata
206
+ Annotations *models.Annotations `json:"annotations,omitempty"`
207
+
208
+ // InstanceType
209
+ InstanceType *productcatalog.AddonInstanceType `json:"instanceType,omitempty"`
210
+
211
+ // RateCards
212
+ RateCards *productcatalog.RateCards `json:"rateCards,omitempty"`
213
+
214
+ // RejectUnitConfig makes mutation validation reject an add-on that carries a unit_config
215
+ // conversion on any rate card. The v1 API cannot represent unit_config, and v1 update
216
+ // rewrites rate cards from a body that has no such field, so proceeding would silently
217
+ // drop the conversion. v1 handlers set this; v3 leaves it false.
218
+ RejectUnitConfig bool
219
+
220
+ inputOptions
221
+ }
222
+
223
+ func (i UpdateAddonInput) Equal(p Addon) bool {
224
+ if i.Namespace != p.Namespace {
225
+ return false
226
+ }
227
+
228
+ if i.ID != p.ID {
229
+ return false
230
+ }
231
+
232
+ if !i.EffectivePeriod.Equal(p.EffectivePeriod) {
233
+ return false
234
+ }
235
+
236
+ if i.Name != nil && *i.Name != p.Name {
237
+ return false
238
+ }
239
+
240
+ if i.Description != nil && lo.FromPtr(i.Description) != lo.FromPtr(p.Description) {
241
+ return false
242
+ }
243
+
244
+ if i.Metadata != nil && !i.Metadata.Equal(p.Metadata) {
245
+ return false
246
+ }
247
+
248
+ if i.InstanceType != nil && *i.InstanceType != p.InstanceType {
249
+ return false
250
+ }
251
+
252
+ if i.RateCards != nil && !i.RateCards.Equal(p.RateCards.AsProductCatalogRateCards()) {
253
+ return false
254
+ }
255
+
256
+ return true
257
+ }
258
+
259
+ func (i UpdateAddonInput) Validate() error {
260
+ var errs []error
261
+
262
+ if i.Namespace == "" {
263
+ errs = append(errs, productcatalog.ErrNamespaceEmpty)
264
+ }
265
+
266
+ if i.ID == "" {
267
+ errs = append(errs, productcatalog.ErrIDEmpty)
268
+ }
269
+
270
+ if i.Name != nil && *i.Name == "" {
271
+ errs = append(errs, productcatalog.ErrResourceNameEmpty)
272
+ }
273
+
274
+ if i.EffectiveFrom != nil || i.EffectiveTo != nil {
275
+ if err := i.EffectivePeriod.Validate(); err != nil {
276
+ errs = append(errs, fmt.Errorf("invalid EffectivePeriod: %w", err))
277
+ }
278
+ }
279
+
280
+ if i.InstanceType != nil {
281
+ if err := i.InstanceType.Validate(); err != nil {
282
+ errs = append(errs, err)
283
+ }
284
+ }
285
+
286
+ if i.RateCards != nil {
287
+ if err := i.RateCards.Validate(); err != nil {
288
+ errs = append(errs, err)
289
+ }
290
+ }
291
+
292
+ issues, err := models.AsValidationIssues(errors.Join(errs...))
293
+ if err != nil {
294
+ return models.NewGenericValidationError(err)
295
+ }
296
+
297
+ if i.IgnoreNonCriticalIssues {
298
+ issues = issues.WithSeverityOrHigher(models.ErrorSeverityCritical)
299
+ }
300
+
301
+ return models.NewNillableGenericValidationError(issues.AsError())
302
+ }
303
+
304
+ type ExpandFields struct {
305
+ PlanAddons bool `json:"plans,omitempty"`
306
+ }
307
+
308
+ type GetAddonInput struct {
309
+ models.NamespacedID
310
+
311
+ // Key is the unique key for Addon.
312
+ Key string `json:"key,omitempty"`
313
+
314
+ // Version is the version of the Addon.
315
+ // If not set the latest version is assumed.
316
+ Version int `json:"version,omitempty"`
317
+
318
+ // IncludeLatest defines whether return the latest version regardless of its AddonStatus or with ActiveStatus only if
319
+ // Version is not set.
320
+ IncludeLatest bool `json:"includeLatest,omitempty"`
321
+
322
+ Expand ExpandFields `json:"expand,omitempty"`
323
+ }
324
+
325
+ func (i GetAddonInput) Validate() error {
326
+ var errs []error
327
+
328
+ if i.Namespace == "" {
329
+ errs = append(errs, productcatalog.ErrNamespaceEmpty)
330
+ }
331
+
332
+ if i.ID == "" && i.Key == "" {
333
+ errs = append(errs, errors.New("either add-on id or key must be provided"))
334
+ }
335
+
336
+ return models.NewNillableGenericValidationError(errors.Join(errs...))
337
+ }
338
+
339
+ type DeleteAddonInput struct {
340
+ models.NamespacedID
341
+ }
342
+
343
+ func (i DeleteAddonInput) Validate() error {
344
+ var errs []error
345
+
346
+ if i.Namespace == "" {
347
+ errs = append(errs, productcatalog.ErrNamespaceEmpty)
348
+ }
349
+
350
+ if i.ID == "" {
351
+ errs = append(errs, productcatalog.ErrIDEmpty)
352
+ }
353
+
354
+ return models.NewNillableGenericValidationError(errors.Join(errs...))
355
+ }
356
+
357
+ type PublishAddonInput struct {
358
+ models.NamespacedID
359
+
360
+ // AddonEffectivePeriod
361
+ productcatalog.EffectivePeriod
362
+
363
+ // RejectUnitConfig rejects the operation when the target add-on carries a unit_config
364
+ // conversion. The v1 API cannot represent it, so v1 handlers set this; v3 leaves it false.
365
+ RejectUnitConfig bool
366
+ }
367
+
368
+ func (i PublishAddonInput) Validate() error {
369
+ var errs []error
370
+
371
+ if i.Namespace == "" {
372
+ errs = append(errs, productcatalog.ErrNamespaceEmpty)
373
+ }
374
+
375
+ if i.ID == "" {
376
+ errs = append(errs, productcatalog.ErrIDEmpty)
377
+ }
378
+
379
+ now := clock.Now()
380
+
381
+ from := lo.FromPtr(i.EffectiveFrom)
382
+
383
+ if from.IsZero() {
384
+ errs = append(errs, errors.New("invalid EffectiveFrom: must not be empty"))
385
+ }
386
+
387
+ if !from.IsZero() && from.Before(now.Add(-timeJitter)) {
388
+ errs = append(errs, errors.New("invalid EffectiveFrom: period start must not be in the past"))
389
+ }
390
+
391
+ to := lo.FromPtr(i.EffectiveTo)
392
+
393
+ if !to.IsZero() && from.IsZero() {
394
+ errs = append(errs, errors.New("invalid EffectiveFrom: must not be empty if EffectiveTo is also set"))
395
+ }
396
+
397
+ if !to.IsZero() && to.Before(now.Add(timeJitter)) {
398
+ errs = append(errs, errors.New("invalid EffectiveTo: period end must not be in the past"))
399
+ }
400
+
401
+ if !from.IsZero() && !to.IsZero() && from.After(to) {
402
+ errs = append(errs, errors.New("invalid EffectivePeriod: period start must not be later than period end"))
403
+ }
404
+
405
+ return errors.Join(errs...)
406
+ }
407
+
408
+ type ArchiveAddonInput struct {
409
+ // NamespacedID
410
+ models.NamespacedID
411
+
412
+ // EffectiveFrom defines the time from the Addon is going to be unpublished.
413
+ EffectiveTo time.Time `json:"effectiveTo,omitempty"`
414
+
415
+ // RejectUnitConfig rejects the operation when the target add-on carries a unit_config
416
+ // conversion. The v1 API cannot represent it, so v1 handlers set this; v3 leaves it false.
417
+ RejectUnitConfig bool
418
+ }
419
+
420
+ func (i ArchiveAddonInput) Validate() error {
421
+ var errs []error
422
+
423
+ if i.Namespace == "" {
424
+ errs = append(errs, productcatalog.ErrNamespaceEmpty)
425
+ }
426
+
427
+ if i.ID == "" {
428
+ errs = append(errs, productcatalog.ErrIDEmpty)
429
+ }
430
+
431
+ if i.EffectiveTo.IsZero() {
432
+ errs = append(errs, errors.New("invalid EffectiveTo: must not be empty"))
433
+ }
434
+
435
+ now := clock.Now()
436
+
437
+ if i.EffectiveTo.Before(now.Add(-timeJitter)) {
438
+ errs = append(errs, errors.New("invalid EffectiveTo: period end must not be in the past"))
439
+ }
440
+
441
+ return errors.Join(errs...)
442
+ }
443
+
444
+ type NextAddonInput struct {
445
+ // NamespacedID
446
+ models.NamespacedID
447
+
448
+ // Key is the unique key for Addon.
449
+ Key string `json:"key,omitempty"`
450
+
451
+ // Version is the version of the Addon.
452
+ // If not set the latest version is assumed.
453
+ Version int `json:"version,omitempty"`
454
+ }
455
+
456
+ func (i NextAddonInput) Validate() error {
457
+ var errs []error
458
+
459
+ if i.Namespace == "" {
460
+ errs = append(errs, errors.New("invalid Namespace: must not be empty"))
461
+ }
462
+
463
+ if i.ID == "" && i.Key == "" {
464
+ errs = append(errs, errors.New("invalid: either ID or Key pair must be provided"))
465
+ }
466
+
467
+ return errors.Join(errs...)
468
+ }
openmeter/productcatalog/addon/service/addon.go ADDED
@@ -0,0 +1,685 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package service
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "sort"
8
+
9
+ "github.com/samber/lo"
10
+
11
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
12
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/addon"
13
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/featureresolver"
14
+ "github.com/openmeterio/openmeter/pkg/clock"
15
+ "github.com/openmeterio/openmeter/pkg/filter"
16
+ "github.com/openmeterio/openmeter/pkg/framework/transaction"
17
+ "github.com/openmeterio/openmeter/pkg/models"
18
+ "github.com/openmeterio/openmeter/pkg/pagination"
19
+ )
20
+
21
+ func (s service) ListAddons(ctx context.Context, params addon.ListAddonsInput) (pagination.Result[addon.Addon], error) {
22
+ fn := func(ctx context.Context) (pagination.Result[addon.Addon], error) {
23
+ if err := params.Validate(); err != nil {
24
+ return pagination.Result[addon.Addon]{}, fmt.Errorf("invalid list add-ons params: %w", err)
25
+ }
26
+
27
+ return s.adapter.ListAddons(ctx, params)
28
+ }
29
+
30
+ return fn(ctx)
31
+ }
32
+
33
+ // resolveTaxCodes ensures that each RateCard with a Stripe tax code in its TaxConfig
34
+ // has a corresponding TaxCode entity in the namespace. If no matching TaxCode exists,
35
+ // one is created. The RateCard's TaxConfig.TaxCodeID is then populated.
36
+ func (s service) resolveTaxCodes(ctx context.Context, namespace string, rateCards *productcatalog.RateCards) error {
37
+ if rateCards == nil || len(*rateCards) == 0 {
38
+ return nil
39
+ }
40
+
41
+ for _, rc := range *rateCards {
42
+ meta := rc.AsMeta()
43
+ if meta.TaxConfig == nil {
44
+ continue
45
+ }
46
+
47
+ if err := productcatalog.ResolveTaxConfig(ctx, s.taxCode, namespace, meta.TaxConfig); err != nil {
48
+ return err
49
+ }
50
+
51
+ var rcNew productcatalog.RateCard
52
+
53
+ switch rc.Type() {
54
+ case productcatalog.FlatFeeRateCardType:
55
+ rcNew = &productcatalog.FlatFeeRateCard{
56
+ RateCardMeta: meta,
57
+ BillingCadence: rc.GetBillingCadence(),
58
+ }
59
+ case productcatalog.UsageBasedRateCardType:
60
+ bc := rc.GetBillingCadence()
61
+ if bc == nil {
62
+ return fmt.Errorf("billing cadence is required for usage-based rate card")
63
+ }
64
+
65
+ rcNew = &productcatalog.UsageBasedRateCard{
66
+ RateCardMeta: meta,
67
+ BillingCadence: *bc,
68
+ }
69
+ default:
70
+ return fmt.Errorf("unsupported RateCard type: %s", rc.Type())
71
+ }
72
+
73
+ if err := rc.Merge(rcNew); err != nil {
74
+ return fmt.Errorf("failed to merge RateCard: %w", err)
75
+ }
76
+ }
77
+
78
+ return nil
79
+ }
80
+
81
+ // addonVersions is a collection of add-ons versions (all of them have the same namespace key pair).
82
+ type addonVersions []addon.Addon
83
+
84
+ func (a addonVersions) Len() int {
85
+ return len(a)
86
+ }
87
+
88
+ func (a addonVersions) Less(i, j int) bool {
89
+ return a[i].Version < a[j].Version
90
+ }
91
+
92
+ func (a addonVersions) Swap(i, j int) {
93
+ a[i], a[j] = a[j], a[i]
94
+ }
95
+
96
+ // Sort sorts the add-ons by their versions.
97
+ func (a addonVersions) Sort() {
98
+ sort.Sort(a)
99
+ }
100
+
101
+ // Latest returns add-on with the latest version regardless of its deleted status.
102
+ func (a addonVersions) Latest() *addon.Addon {
103
+ if len(a) == 0 {
104
+ return nil
105
+ }
106
+
107
+ // Ensure the collection is sorted
108
+ a.Sort()
109
+
110
+ return &a[len(a)-1]
111
+ }
112
+
113
+ // HasDraft returns true if there is an active (non-deleted) add-on with draft status.
114
+ func (a addonVersions) HasDraft() bool {
115
+ for _, aa := range a {
116
+ if aa.DeletedAt == nil && aa.Status() == productcatalog.AddonStatusDraft {
117
+ return true
118
+ }
119
+ }
120
+
121
+ return false
122
+ }
123
+
124
+ func (s service) getAddonVersions(ctx context.Context, namespace, key string) (addonVersions, error) {
125
+ versions, err := s.adapter.ListAddons(ctx, addon.ListAddonsInput{
126
+ OrderBy: addon.OrderByVersion,
127
+ Order: addon.OrderAsc,
128
+ Namespaces: []string{namespace},
129
+ Key: &filter.FilterString{In: &[]string{key}},
130
+ IncludeDeleted: true,
131
+ })
132
+ if err != nil {
133
+ return nil, fmt.Errorf("failed to list versions of the add-on: %w", err)
134
+ }
135
+
136
+ return versions.Items, nil
137
+ }
138
+
139
+ func (s service) CreateAddon(ctx context.Context, params addon.CreateAddonInput) (*addon.Addon, error) {
140
+ fn := func(ctx context.Context) (*addon.Addon, error) {
141
+ if err := params.Validate(); err != nil {
142
+ return nil, fmt.Errorf("invalid create add-on params: %w", err)
143
+ }
144
+
145
+ logger := s.logger.With(
146
+ "operation", "create",
147
+ "namespace", params.Namespace,
148
+ "addon.key", params.Key,
149
+ )
150
+
151
+ // Check if there is already an Add-on with the same Key
152
+ versions, err := s.getAddonVersions(ctx, params.Namespace, params.Key)
153
+ if err != nil {
154
+ return nil, fmt.Errorf("failed to get add-on generation: %w", err)
155
+ }
156
+
157
+ // Return error if the add-on generation already has an active (non-deleted) add-on with draft status
158
+ // as there can only be single draft add-on at a time.
159
+ if versions.HasDraft() {
160
+ return nil, models.NewGenericValidationError(
161
+ fmt.Errorf("only a single draft version is allowed for add-on"),
162
+ )
163
+ }
164
+
165
+ // Override the version parameter with the next version calculated from the last available version.
166
+ params.Version = lo.FromPtr(versions.Latest()).Version + 1
167
+
168
+ logger.Debug("creating add-on")
169
+
170
+ if len(params.RateCards) > 0 {
171
+ if err = featureresolver.ResolveFeaturesForRateCards(ctx, s.featureResolver, params.Namespace, &params.RateCards); err != nil {
172
+ return nil, fmt.Errorf("failed to resolve features for ratecards in add-on [addon.key=%s]: %w", params.Key, err)
173
+ }
174
+
175
+ if err = s.resolveTaxCodes(ctx, params.Namespace, &params.RateCards); err != nil {
176
+ return nil, fmt.Errorf("failed to resolve tax codes for ratecards in add-on: %w", err)
177
+ }
178
+ }
179
+
180
+ aa, err := s.adapter.CreateAddon(ctx, params)
181
+ if err != nil {
182
+ return nil, fmt.Errorf("failed to create add-on: %w", err)
183
+ }
184
+
185
+ logger.With("addon.id", aa.ID).Debug("add-on created")
186
+
187
+ // Emit add-on created event
188
+ event := addon.NewAddonCreateEvent(ctx, aa)
189
+ if err = s.publisher.Publish(ctx, event); err != nil {
190
+ return nil, fmt.Errorf("failed to publish add-on created event: %w", err)
191
+ }
192
+
193
+ return aa, nil
194
+ }
195
+
196
+ return transaction.Run(ctx, s.adapter, fn)
197
+ }
198
+
199
+ func (s service) DeleteAddon(ctx context.Context, params addon.DeleteAddonInput) error {
200
+ fn := func(ctx context.Context) (interface{}, error) {
201
+ if err := params.Validate(); err != nil {
202
+ return nil, fmt.Errorf("invalid delete add-on params: %w", err)
203
+ }
204
+
205
+ logger := s.logger.With(
206
+ "operation", "delete",
207
+ "namespace", params.Namespace,
208
+ "addon.id", params.ID,
209
+ )
210
+
211
+ logger.Debug("deleting add-on")
212
+
213
+ // Get the add-on to check if it can be deleted
214
+ add, err := s.adapter.GetAddon(ctx, addon.GetAddonInput{
215
+ NamespacedID: models.NamespacedID{
216
+ Namespace: params.Namespace,
217
+ ID: params.ID,
218
+ },
219
+ Expand: addon.ExpandFields{
220
+ PlanAddons: true,
221
+ },
222
+ })
223
+ if err != nil {
224
+ return nil, fmt.Errorf("failed to get add-on: %w", err)
225
+ }
226
+
227
+ if add.DeletedAt != nil && add.DeletedAt.Before(clock.Now()) {
228
+ return nil, nil
229
+ }
230
+
231
+ if add.Plans == nil {
232
+ return nil, fmt.Errorf("cannot check whether add-on has plans enabled as plans were not dfetched for add-on [namespace=%s id=%s key=%s]",
233
+ add.Namespace, add.ID, add.Key)
234
+ }
235
+
236
+ if len(*add.Plans) > 0 {
237
+ return nil, models.NewGenericValidationError(
238
+ fmt.Errorf("failed to delete add-on [namespace=%s id=%s key=%s]: add-on has active assignments", add.Namespace, add.ID, add.Key),
239
+ )
240
+ }
241
+
242
+ // Run validations prior deleting add-on.
243
+ if err = add.AsProductCatalogAddon().ValidateWith(
244
+ productcatalog.ValidateAddonWithStatus(productcatalog.AddonStatusDraft, productcatalog.AddonStatusArchived),
245
+ ); err != nil {
246
+ return nil, err
247
+ }
248
+
249
+ // Delete the add-on
250
+ err = s.adapter.DeleteAddon(ctx, params)
251
+ if err != nil {
252
+ return nil, fmt.Errorf("failed to delete add-on: %w", err)
253
+ }
254
+
255
+ logger.Debug("add-on deleted")
256
+
257
+ // Get the deleted add-on to emit the event
258
+ add, err = s.adapter.GetAddon(ctx, addon.GetAddonInput{
259
+ NamespacedID: models.NamespacedID{
260
+ Namespace: params.Namespace,
261
+ ID: params.ID,
262
+ },
263
+ })
264
+ if err != nil {
265
+ return nil, fmt.Errorf("failed to get deleted add-on: %w", err)
266
+ }
267
+
268
+ // Emit add-on deleted event
269
+ event := addon.NewAddonDeleteEvent(ctx, add)
270
+ if err = s.publisher.Publish(ctx, event); err != nil {
271
+ return nil, fmt.Errorf("failed to publish add-on deleted event: %w", err)
272
+ }
273
+
274
+ return nil, nil
275
+ }
276
+
277
+ _, err := transaction.Run(ctx, s.adapter, fn)
278
+
279
+ return err
280
+ }
281
+
282
+ func (s service) GetAddon(ctx context.Context, params addon.GetAddonInput) (*addon.Addon, error) {
283
+ fn := func(ctx context.Context) (*addon.Addon, error) {
284
+ if err := params.Validate(); err != nil {
285
+ return nil, fmt.Errorf("invalid get add-on params: %w", err)
286
+ }
287
+
288
+ logger := s.logger.With(
289
+ "operation", "get",
290
+ "namespace", params.Namespace,
291
+ "addon.id", params.ID,
292
+ "addon.key", params.Key,
293
+ "addon.version", params.Version,
294
+ )
295
+
296
+ logger.Debug("fetching add-on")
297
+
298
+ aa, err := s.adapter.GetAddon(ctx, params)
299
+ if err != nil {
300
+ // FIXME: not found error
301
+ return nil, fmt.Errorf("failed to get add-on: %w", err)
302
+ }
303
+
304
+ logger.Debug("add-on fetched")
305
+
306
+ return aa, nil
307
+ }
308
+
309
+ return fn(ctx)
310
+ }
311
+
312
+ func (s service) UpdateAddon(ctx context.Context, params addon.UpdateAddonInput) (*addon.Addon, error) {
313
+ fn := func(ctx context.Context) (*addon.Addon, error) {
314
+ if err := params.Validate(); err != nil {
315
+ return nil, fmt.Errorf("invalid update add-on params: %w", err)
316
+ }
317
+
318
+ logger := s.logger.With(
319
+ "operation", "update",
320
+ "namespace", params.Namespace,
321
+ "addon.id", params.ID,
322
+ )
323
+ logger.Debug("updating add-on")
324
+
325
+ if params.RateCards != nil && len(*params.RateCards) > 0 {
326
+ if err := featureresolver.ResolveFeaturesForRateCards(ctx, s.featureResolver, params.Namespace, params.RateCards); err != nil {
327
+ return nil, fmt.Errorf("failed to expand features for ratecards in add-on: %w", err)
328
+ }
329
+
330
+ if err := s.resolveTaxCodes(ctx, params.Namespace, params.RateCards); err != nil {
331
+ return nil, fmt.Errorf("failed to resolve tax codes for ratecards in add-on: %w", err)
332
+ }
333
+ }
334
+
335
+ add, err := s.adapter.GetAddon(ctx, addon.GetAddonInput{
336
+ NamespacedID: models.NamespacedID{
337
+ Namespace: params.Namespace,
338
+ ID: params.ID,
339
+ },
340
+ })
341
+ if err != nil {
342
+ return nil, fmt.Errorf("failed to get add-on: %w", err)
343
+ }
344
+
345
+ if params.RejectUnitConfig && add.AsProductCatalogAddon().HasUnitConfig() {
346
+ return nil, productcatalog.ErrUnitConfigNotRepresentable
347
+ }
348
+
349
+ // Run validations prior updating add-on.
350
+ if err = add.AsProductCatalogAddon().ValidateWith(
351
+ productcatalog.ValidateAddonWithStatus(productcatalog.AddonStatusDraft),
352
+ ); err != nil {
353
+ return nil, err
354
+ }
355
+
356
+ logger.Debug("updating add-on")
357
+
358
+ // NOTE(chrisgacsal): we only allow updating the state of the add-on via Publish/Archive,
359
+ // therefore the EffectivePeriod attribute must be zeroed before updating the add-on.
360
+ params.EffectivePeriod = productcatalog.EffectivePeriod{}
361
+
362
+ add, err = s.adapter.UpdateAddon(ctx, params)
363
+ if err != nil {
364
+ return nil, fmt.Errorf("failed to udpate add-on: %w", err)
365
+ }
366
+
367
+ logger.Debug("add-on updated")
368
+
369
+ // Emit add-on updated event
370
+ event := addon.NewAddonUpdateEvent(ctx, add)
371
+ if err = s.publisher.Publish(ctx, event); err != nil {
372
+ return nil, fmt.Errorf("failed to publish add-on updated event: %w", err)
373
+ }
374
+
375
+ return add, nil
376
+ }
377
+
378
+ return transaction.Run(ctx, s.adapter, fn)
379
+ }
380
+
381
+ func (s service) PublishAddon(ctx context.Context, params addon.PublishAddonInput) (*addon.Addon, error) {
382
+ fn := func(ctx context.Context) (*addon.Addon, error) {
383
+ if err := params.Validate(); err != nil {
384
+ return nil, fmt.Errorf("invalid publish add-on params: %w", err)
385
+ }
386
+
387
+ logger := s.logger.With(
388
+ "operation", "publish",
389
+ "namespace", params.Namespace,
390
+ "addon.id", params.ID,
391
+ )
392
+
393
+ logger.Debug("publishing add-on")
394
+
395
+ add, err := s.adapter.GetAddon(ctx, addon.GetAddonInput{
396
+ NamespacedID: models.NamespacedID{
397
+ Namespace: params.Namespace,
398
+ ID: params.ID,
399
+ },
400
+ })
401
+ if err != nil {
402
+ return nil, fmt.Errorf("failed to get add-on: %w", err)
403
+ }
404
+
405
+ if add.DeletedAt != nil {
406
+ return nil, models.NewGenericValidationError(
407
+ fmt.Errorf("cannot publish a deleted add-on"),
408
+ )
409
+ }
410
+
411
+ if params.RejectUnitConfig && add.AsProductCatalogAddon().HasUnitConfig() {
412
+ return nil, productcatalog.ErrUnitConfigNotRepresentable
413
+ }
414
+
415
+ pa := add.AsProductCatalogAddon()
416
+
417
+ // Run validations prior publishing add-on.
418
+
419
+ var errs []error
420
+
421
+ if err = pa.Publishable(); err != nil {
422
+ errs = append(errs, fmt.Errorf("invalid add-on [id=%s key=%s version=%d]: %w",
423
+ add.ID, add.Key, add.Version, err),
424
+ )
425
+ }
426
+
427
+ // Validate plan with features
428
+ err = pa.ValidateWith(
429
+ productcatalog.ValidateAddonWithFeatures(ctx, s.featureResolver.WithNamespace(params.Namespace)),
430
+ )
431
+ if err != nil {
432
+ errs = append(errs, fmt.Errorf("invalid add-on [id=%s key=%s version=%d]: %w",
433
+ add.ID, add.Key, add.Version, err),
434
+ )
435
+ }
436
+
437
+ if err = errors.Join(errs...); err != nil {
438
+ return nil, models.NewGenericValidationError(err)
439
+ }
440
+
441
+ // Find and archive add-on version with addon.AddonStatusActive if there is one. Only perform lookup if
442
+ // the add-on to be published has higher version then 1 meaning that it has previous versions,
443
+ // otherwise skip this step.
444
+ if add.Version > 1 {
445
+ activeAddon, err := s.adapter.GetAddon(ctx, addon.GetAddonInput{
446
+ NamespacedID: models.NamespacedID{
447
+ Namespace: params.Namespace,
448
+ },
449
+ Key: add.Key,
450
+ })
451
+ if err != nil {
452
+ if !addon.IsNotFound(err) {
453
+ return nil, fmt.Errorf("failed to get add-on with active status: %w", err)
454
+ }
455
+ }
456
+
457
+ if activeAddon != nil && params.EffectiveFrom != nil {
458
+ _, err = s.ArchiveAddon(ctx, addon.ArchiveAddonInput{
459
+ NamespacedID: models.NamespacedID{
460
+ Namespace: activeAddon.Namespace,
461
+ ID: activeAddon.ID,
462
+ },
463
+ EffectiveTo: lo.FromPtr(params.EffectiveFrom),
464
+ RejectUnitConfig: params.RejectUnitConfig,
465
+ })
466
+ if err != nil {
467
+ return nil, fmt.Errorf("failed to archive add-on with active status: %w", err)
468
+ }
469
+ }
470
+ }
471
+
472
+ // Publish new add-on version
473
+
474
+ input := addon.UpdateAddonInput{
475
+ NamespacedID: params.NamespacedID,
476
+ }
477
+
478
+ if params.EffectiveFrom != nil {
479
+ input.EffectiveFrom = lo.ToPtr(params.EffectiveFrom.UTC())
480
+ }
481
+
482
+ if params.EffectiveTo != nil {
483
+ input.EffectiveTo = lo.ToPtr(params.EffectiveTo.UTC())
484
+ }
485
+
486
+ add, err = s.adapter.UpdateAddon(ctx, input)
487
+ if err != nil {
488
+ return nil, fmt.Errorf("failed to publish add-on: %w", err)
489
+ }
490
+
491
+ logger.Debug("add-on published")
492
+
493
+ // Emit add-on published event
494
+ event := addon.NewAddonPublishEvent(ctx, add)
495
+ if err := s.publisher.Publish(ctx, event); err != nil {
496
+ return nil, fmt.Errorf("failed to publish add-on published event: %w", err)
497
+ }
498
+
499
+ return add, nil
500
+ }
501
+
502
+ return transaction.Run(ctx, s.adapter, fn)
503
+ }
504
+
505
+ func (s service) ArchiveAddon(ctx context.Context, params addon.ArchiveAddonInput) (*addon.Addon, error) {
506
+ fn := func(ctx context.Context) (*addon.Addon, error) {
507
+ if err := params.Validate(); err != nil {
508
+ return nil, fmt.Errorf("invalid archive add-on params: %w", err)
509
+ }
510
+
511
+ logger := s.logger.With(
512
+ "operation", "archive",
513
+ "namespace", params.Namespace,
514
+ "addon.id", params.ID,
515
+ )
516
+
517
+ logger.Debug("archiving add-on")
518
+
519
+ add, err := s.adapter.GetAddon(ctx, addon.GetAddonInput{
520
+ NamespacedID: models.NamespacedID{
521
+ Namespace: params.Namespace,
522
+ ID: params.ID,
523
+ },
524
+ })
525
+ if err != nil {
526
+ return nil, fmt.Errorf("failed to get add-on: %w", err)
527
+ }
528
+
529
+ if add.DeletedAt != nil {
530
+ return nil, models.NewGenericValidationError(
531
+ fmt.Errorf("cannot archive a deleted add-on"),
532
+ )
533
+ }
534
+
535
+ if params.RejectUnitConfig && add.AsProductCatalogAddon().HasUnitConfig() {
536
+ return nil, productcatalog.ErrUnitConfigNotRepresentable
537
+ }
538
+
539
+ // Run validations prior archiving add-on.
540
+ if err = add.AsProductCatalogAddon().ValidateWith(
541
+ productcatalog.ValidateAddonWithStatus(productcatalog.AddonStatusActive),
542
+ ); err != nil {
543
+ return nil, err
544
+ }
545
+
546
+ add, err = s.adapter.UpdateAddon(ctx, addon.UpdateAddonInput{
547
+ NamespacedID: models.NamespacedID{
548
+ Namespace: add.Namespace,
549
+ ID: add.ID,
550
+ },
551
+ EffectivePeriod: productcatalog.EffectivePeriod{
552
+ EffectiveFrom: add.EffectiveFrom,
553
+ EffectiveTo: lo.ToPtr(params.EffectiveTo.UTC()),
554
+ },
555
+ })
556
+ if err != nil {
557
+ return nil, fmt.Errorf("failed to archive add-on: %w", err)
558
+ }
559
+
560
+ logger.Debug("add-on archived")
561
+
562
+ // Emit add-on archived event
563
+ event := addon.NewAddonArchiveEvent(ctx, add)
564
+ if err := s.publisher.Publish(ctx, event); err != nil {
565
+ return nil, fmt.Errorf("failed to publish add-on archived event: %w", err)
566
+ }
567
+
568
+ return add, nil
569
+ }
570
+
571
+ return transaction.Run(ctx, s.adapter, fn)
572
+ }
573
+
574
+ func (s service) NextAddon(ctx context.Context, params addon.NextAddonInput) (*addon.Addon, error) {
575
+ fn := func(ctx context.Context) (*addon.Addon, error) {
576
+ if err := params.Validate(); err != nil {
577
+ return nil, fmt.Errorf("invalid next version add-on params: %w", err)
578
+ }
579
+
580
+ logger := s.logger.With(
581
+ "operation", "next",
582
+ "namespace", params.Namespace,
583
+ "addon.id", params.ID,
584
+ "addon.key", params.Key,
585
+ "addon.version", params.Version,
586
+ )
587
+
588
+ logger.Debug("creating new version of an add-on")
589
+
590
+ // Fetch all version of an add-on to find the one to be used as source and also to calculate the next version number.
591
+ versions, err := s.getAddonVersions(ctx, params.Namespace, params.Key)
592
+ if err != nil {
593
+ return nil, fmt.Errorf("failed to get add-on generation: %w", err)
594
+ }
595
+
596
+ if versions.Len() == 0 {
597
+ return nil, models.NewGenericValidationError(
598
+ fmt.Errorf("no versions available for this add-on"),
599
+ )
600
+ }
601
+
602
+ // Generate source add-on filter from input parameters
603
+
604
+ // addonFilterFunc is a filter function which returns tuple where the first boolean means that
605
+ // there is a match while the second tells the caller to stop further invocations as there is an exact match.
606
+ type addonFilterFunc func(addon addon.Addon) (match bool, stop bool)
607
+
608
+ sourceAddonFilterFunc := func() addonFilterFunc {
609
+ switch {
610
+ case params.ID != "":
611
+ return func(a addon.Addon) (match bool, stop bool) {
612
+ if a.Namespace == params.Namespace && a.ID == params.ID {
613
+ return true, true
614
+ }
615
+
616
+ return false, false
617
+ }
618
+ case params.Key != "" && params.Version == 0:
619
+ return func(a addon.Addon) (match bool, stop bool) {
620
+ return a.Namespace == params.Namespace && a.Key == params.Key, false
621
+ }
622
+ default:
623
+ return func(a addon.Addon) (match bool, stop bool) {
624
+ if a.Namespace == params.Namespace && a.Key == params.Key && a.Version == params.Version {
625
+ return true, true
626
+ }
627
+
628
+ return false, false
629
+ }
630
+ }
631
+ }()
632
+
633
+ var sourceAddon *addon.Addon
634
+
635
+ nextVersion := 1
636
+ var match, stop bool
637
+ for _, addonItem := range versions {
638
+ if addonItem.DeletedAt == nil && addonItem.Status() == productcatalog.AddonStatusDraft {
639
+ return nil, models.NewGenericValidationError(
640
+ fmt.Errorf("only a single draft version is allowed for add-on"),
641
+ )
642
+ }
643
+
644
+ if !stop {
645
+ match, stop = sourceAddonFilterFunc(addonItem)
646
+ if match {
647
+ sourceAddon = &addonItem
648
+ }
649
+ }
650
+
651
+ if addonItem.Version >= nextVersion {
652
+ nextVersion = addonItem.Version + 1
653
+ }
654
+ }
655
+
656
+ if sourceAddon == nil {
657
+ return nil, models.NewGenericValidationError(
658
+ fmt.Errorf("no versions available for add-on to use as source for next draft version"),
659
+ )
660
+ }
661
+
662
+ nextAddon, err := s.adapter.CreateAddon(ctx, addon.CreateAddonInput{
663
+ NamespacedModel: models.NamespacedModel{
664
+ Namespace: sourceAddon.Namespace,
665
+ },
666
+ Addon: productcatalog.Addon{
667
+ AddonMeta: productcatalog.AddonMeta{
668
+ Key: sourceAddon.Key,
669
+ Version: nextVersion,
670
+ Name: sourceAddon.Name,
671
+ Description: sourceAddon.Description,
672
+ Currency: sourceAddon.Currency,
673
+ },
674
+ RateCards: sourceAddon.RateCards.AsProductCatalogRateCards(),
675
+ },
676
+ })
677
+ if err != nil {
678
+ return nil, fmt.Errorf("failed to create new version of a add-on: %w", err)
679
+ }
680
+
681
+ return nextAddon, nil
682
+ }
683
+
684
+ return transaction.Run(ctx, s.adapter, fn)
685
+ }
openmeter/productcatalog/addon/service/service.go ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package service
2
+
3
+ import (
4
+ "errors"
5
+ "log/slog"
6
+
7
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
8
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/addon"
9
+ "github.com/openmeterio/openmeter/openmeter/taxcode"
10
+ "github.com/openmeterio/openmeter/openmeter/watermill/eventbus"
11
+ )
12
+
13
+ type Config struct {
14
+ Adapter addon.Repository
15
+ TaxCode taxcode.Service
16
+ Logger *slog.Logger
17
+ Publisher eventbus.Publisher
18
+
19
+ FeatureResolver productcatalog.FeatureResolver
20
+ }
21
+
22
+ func New(config Config) (addon.Service, error) {
23
+ if config.Adapter == nil {
24
+ return nil, errors.New("add-on adapter is required")
25
+ }
26
+
27
+ if config.FeatureResolver == nil {
28
+ return nil, errors.New("feature resolver is required")
29
+ }
30
+
31
+ if config.TaxCode == nil {
32
+ return nil, errors.New("tax code service is required")
33
+ }
34
+
35
+ if config.Logger == nil {
36
+ return nil, errors.New("logger is required")
37
+ }
38
+
39
+ if config.Publisher == nil {
40
+ return nil, errors.New("publisher is required")
41
+ }
42
+
43
+ return &service{
44
+ adapter: config.Adapter,
45
+ taxCode: config.TaxCode,
46
+ logger: config.Logger,
47
+ publisher: config.Publisher,
48
+
49
+ featureResolver: config.FeatureResolver,
50
+ }, nil
51
+ }
52
+
53
+ var _ addon.Service = (*service)(nil)
54
+
55
+ type service struct {
56
+ adapter addon.Repository
57
+ taxCode taxcode.Service
58
+ logger *slog.Logger
59
+ publisher eventbus.Publisher
60
+
61
+ featureResolver productcatalog.FeatureResolver
62
+ }
openmeter/productcatalog/addon/service/service_test.go ADDED
@@ -0,0 +1,610 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package service_test
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "testing"
7
+ "time"
8
+
9
+ decimal "github.com/alpacahq/alpacadecimal"
10
+ "github.com/samber/lo"
11
+ "github.com/stretchr/testify/assert"
12
+ "github.com/stretchr/testify/require"
13
+
14
+ "github.com/openmeterio/openmeter/openmeter/app"
15
+ "github.com/openmeterio/openmeter/openmeter/meter"
16
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
17
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/addon"
18
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
19
+ pctestutils "github.com/openmeterio/openmeter/openmeter/productcatalog/testutils"
20
+ "github.com/openmeterio/openmeter/openmeter/taxcode"
21
+ "github.com/openmeterio/openmeter/pkg/convert"
22
+ "github.com/openmeterio/openmeter/pkg/datetime"
23
+ "github.com/openmeterio/openmeter/pkg/filter"
24
+ "github.com/openmeterio/openmeter/pkg/models"
25
+ "github.com/openmeterio/openmeter/pkg/pagination"
26
+ )
27
+
28
+ var MonthPeriod = datetime.ISODurationFromDuration(30 * 24 * time.Hour)
29
+
30
+ func TestAddonService(t *testing.T) {
31
+ ctx, cancel := context.WithCancel(context.Background())
32
+ defer cancel()
33
+
34
+ env := pctestutils.NewTestEnv(t)
35
+ t.Cleanup(func() {
36
+ env.Close(t)
37
+ })
38
+
39
+ t.Run("Addon", func(t *testing.T) {
40
+ t.Run("Create", func(t *testing.T) {
41
+ // Get new namespace ID
42
+ namespace := pctestutils.NewTestNamespace(t)
43
+
44
+ // Setup meter repository
45
+ err := env.Meter.ReplaceMeters(ctx, pctestutils.NewTestMeters(t, namespace))
46
+ require.NoError(t, err, "replacing meters must not fail")
47
+
48
+ result, err := env.Meter.ListMeters(ctx, meter.ListMetersParams{
49
+ Page: pagination.Page{
50
+ PageSize: 1000,
51
+ PageNumber: 1,
52
+ },
53
+ Namespace: namespace,
54
+ })
55
+ require.NoErrorf(t, err, "listing meters must not fail")
56
+
57
+ meters := result.Items
58
+ require.NotEmptyf(t, meters, "list of Meters must not be empty")
59
+
60
+ // Set a feature for each meter
61
+ features := make([]feature.Feature, 0, len(meters))
62
+ for _, m := range meters {
63
+ input := pctestutils.NewTestFeatureFromMeter(t, &m)
64
+
65
+ feat, err := env.Feature.CreateFeature(ctx, input)
66
+ require.NoErrorf(t, err, "creating feature must not fail")
67
+ require.NotNil(t, feat, "feature must not be empty")
68
+
69
+ features = append(features, feat)
70
+ }
71
+
72
+ taxcode, err := env.TaxCode.CreateTaxCode(ctx, taxcode.CreateTaxCodeInput{
73
+ Namespace: namespace,
74
+ Key: "txcd_10000000",
75
+ Name: "Test Tax Code",
76
+ Description: lo.ToPtr("Test Tax Code"),
77
+ AppMappings: []taxcode.TaxCodeAppMapping{
78
+ {
79
+ AppType: app.AppTypeStripe,
80
+ TaxCode: "txcd_10000000",
81
+ },
82
+ },
83
+ Metadata: models.Metadata{"name": "Test Tax Code"},
84
+ })
85
+ require.NoErrorf(t, err, "creating tax code must not fail")
86
+ require.NotNil(t, taxcode, "tax code must not be empty")
87
+
88
+ addonV1Input := pctestutils.NewTestAddon(t, namespace, productcatalog.RateCards{
89
+ &productcatalog.UsageBasedRateCard{
90
+ RateCardMeta: productcatalog.RateCardMeta{
91
+ Key: features[0].Key,
92
+ Name: features[0].Name,
93
+ Description: lo.ToPtr(features[0].Name),
94
+ Metadata: models.Metadata{"name": features[0].Name},
95
+ FeatureKey: nil,
96
+ FeatureID: lo.ToPtr(features[0].ID),
97
+ EntitlementTemplate: productcatalog.NewEntitlementTemplateFrom(productcatalog.BooleanEntitlementTemplate{}),
98
+ TaxConfig: &productcatalog.TaxConfig{
99
+ Stripe: &productcatalog.StripeTaxConfig{
100
+ Code: "txcd_10000000",
101
+ },
102
+ TaxCodeID: lo.ToPtr(taxcode.ID),
103
+ },
104
+ Price: productcatalog.NewPriceFrom(productcatalog.TieredPrice{
105
+ Mode: productcatalog.VolumeTieredPrice,
106
+ Tiers: []productcatalog.PriceTier{
107
+ {
108
+ UpToAmount: lo.ToPtr(decimal.NewFromInt(1000)),
109
+ FlatPrice: &productcatalog.PriceTierFlatPrice{
110
+ Amount: decimal.NewFromInt(100),
111
+ },
112
+ UnitPrice: &productcatalog.PriceTierUnitPrice{
113
+ Amount: decimal.NewFromInt(50),
114
+ },
115
+ },
116
+ {
117
+ UpToAmount: nil,
118
+ FlatPrice: &productcatalog.PriceTierFlatPrice{
119
+ Amount: decimal.NewFromInt(5),
120
+ },
121
+ UnitPrice: &productcatalog.PriceTierUnitPrice{
122
+ Amount: decimal.NewFromInt(25),
123
+ },
124
+ },
125
+ },
126
+ Commitments: productcatalog.Commitments{
127
+ MinimumAmount: lo.ToPtr(decimal.NewFromInt(1000)),
128
+ MaximumAmount: nil,
129
+ },
130
+ }),
131
+ },
132
+ BillingCadence: MonthPeriod,
133
+ },
134
+ }...)
135
+
136
+ var addonV1 *addon.Addon
137
+
138
+ addonV1, err = env.Addon.CreateAddon(ctx, addonV1Input)
139
+ require.NoErrorf(t, err, "creating add-on must not fail")
140
+ require.NotNil(t, addonV1, "add-on must not be empty")
141
+
142
+ addon.AssertAddonCreateInputEqual(t, addonV1Input, *addonV1)
143
+
144
+ assert.Equalf(t, productcatalog.AddonStatusDraft, addonV1.Status(),
145
+ "add-on status mismatch: expected=%s, actual=%s", productcatalog.AddonStatusDraft, addonV1.Status())
146
+
147
+ t.Run("Get", func(t *testing.T) {
148
+ getAddon, err := env.Addon.GetAddon(ctx, addon.GetAddonInput{
149
+ NamespacedID: models.NamespacedID{
150
+ Namespace: addonV1Input.Namespace,
151
+ },
152
+ Key: addonV1Input.Key,
153
+ IncludeLatest: true,
154
+ })
155
+ require.NoErrorf(t, err, "getting draft add-on must not fail")
156
+ require.NotNil(t, getAddon, "draft add-on must not be empty")
157
+
158
+ assert.Equalf(t, addonV1.ID, getAddon.ID,
159
+ "Plan ID mismatch: %s = %s", addonV1.ID, getAddon.ID)
160
+
161
+ assert.Equalf(t, addonV1.Key, getAddon.Key,
162
+ "Plan Key mismatch: %s = %s", addonV1.Key, getAddon.Key)
163
+
164
+ assert.Equalf(t, addonV1.Version, getAddon.Version,
165
+ "Plan Version mismatch: %d = %d", addonV1.Version, getAddon.Version)
166
+
167
+ assert.Equalf(t, productcatalog.AddonStatusDraft, getAddon.Status(),
168
+ "Plan Status mismatch: expected=%s, actual=%s", productcatalog.AddonStatusDraft, getAddon.Status())
169
+ })
170
+
171
+ t.Run("Update", func(t *testing.T) {
172
+ updateInput := addon.UpdateAddonInput{
173
+ NamespacedID: addonV1.NamespacedID,
174
+ RateCards: &productcatalog.RateCards{
175
+ &productcatalog.FlatFeeRateCard{
176
+ RateCardMeta: productcatalog.RateCardMeta{
177
+ Key: features[0].Key,
178
+ Name: features[0].Name,
179
+ Description: lo.ToPtr("RateCard 1"),
180
+ Metadata: models.Metadata{"name": features[0].Name},
181
+ FeatureKey: lo.ToPtr(features[0].Key),
182
+ FeatureID: nil,
183
+ TaxConfig: &productcatalog.TaxConfig{
184
+ Stripe: &productcatalog.StripeTaxConfig{
185
+ Code: "txcd_10000000",
186
+ },
187
+ TaxCodeID: lo.ToPtr(taxcode.ID),
188
+ },
189
+ Price: productcatalog.NewPriceFrom(
190
+ productcatalog.FlatPrice{
191
+ Amount: decimal.NewFromInt(0),
192
+ PaymentTerm: productcatalog.InArrearsPaymentTerm,
193
+ }),
194
+ },
195
+ BillingCadence: &MonthPeriod,
196
+ },
197
+ &productcatalog.UsageBasedRateCard{
198
+ RateCardMeta: productcatalog.RateCardMeta{
199
+ Key: features[1].Key,
200
+ Name: features[1].Name,
201
+ Description: lo.ToPtr(features[1].Name),
202
+ Metadata: models.Metadata{"name": features[1].Name},
203
+ FeatureKey: nil,
204
+ FeatureID: lo.ToPtr(features[1].ID),
205
+ EntitlementTemplate: productcatalog.NewEntitlementTemplateFrom(productcatalog.BooleanEntitlementTemplate{}),
206
+ TaxConfig: &productcatalog.TaxConfig{
207
+ Stripe: &productcatalog.StripeTaxConfig{
208
+ Code: "txcd_10000000",
209
+ },
210
+ TaxCodeID: lo.ToPtr(taxcode.ID),
211
+ },
212
+ Price: productcatalog.NewPriceFrom(productcatalog.TieredPrice{
213
+ Mode: productcatalog.VolumeTieredPrice,
214
+ Tiers: []productcatalog.PriceTier{
215
+ {
216
+ UpToAmount: lo.ToPtr(decimal.NewFromInt(1000)),
217
+ FlatPrice: &productcatalog.PriceTierFlatPrice{
218
+ Amount: decimal.NewFromInt(100),
219
+ },
220
+ UnitPrice: &productcatalog.PriceTierUnitPrice{
221
+ Amount: decimal.NewFromInt(50),
222
+ },
223
+ },
224
+ {
225
+ UpToAmount: nil,
226
+ FlatPrice: &productcatalog.PriceTierFlatPrice{
227
+ Amount: decimal.NewFromInt(5),
228
+ },
229
+ UnitPrice: &productcatalog.PriceTierUnitPrice{
230
+ Amount: decimal.NewFromInt(25),
231
+ },
232
+ },
233
+ },
234
+ Commitments: productcatalog.Commitments{
235
+ MinimumAmount: lo.ToPtr(decimal.NewFromInt(1000)),
236
+ MaximumAmount: nil,
237
+ },
238
+ }),
239
+ },
240
+ BillingCadence: MonthPeriod,
241
+ },
242
+ },
243
+ }
244
+
245
+ updateInput.IgnoreNonCriticalIssues = true
246
+
247
+ updatedAddon, err := env.Addon.UpdateAddon(ctx, updateInput)
248
+ require.NoErrorf(t, err, "updating draft add-on must not fail")
249
+ require.NotNil(t, updatedAddon, "updated draft add-on must not be empty")
250
+
251
+ addon.AssertAddonUpdateInputEqual(t, updateInput, *updatedAddon)
252
+ })
253
+
254
+ var publishedAddonV1 *addon.Addon
255
+
256
+ t.Run("Publish", func(t *testing.T) {
257
+ publishAt := time.Now().Truncate(time.Microsecond)
258
+
259
+ publishInput := addon.PublishAddonInput{
260
+ NamespacedID: addonV1.NamespacedID,
261
+ EffectivePeriod: productcatalog.EffectivePeriod{
262
+ EffectiveFrom: &publishAt,
263
+ EffectiveTo: nil,
264
+ },
265
+ }
266
+
267
+ publishedAddonV1, err = env.Addon.PublishAddon(ctx, publishInput)
268
+ require.NoErrorf(t, err, "publishing draft add-on must not fail")
269
+ require.NotNil(t, publishedAddonV1, "published add-on must not be empty")
270
+ require.NotNil(t, publishedAddonV1.EffectiveFrom, "EffectiveFrom for published add-on must not be empty")
271
+
272
+ assert.Equalf(t, publishAt, *publishedAddonV1.EffectiveFrom,
273
+ "EffectiveFrom for published add-on mismatch: expected=%s, actual=%s", publishAt, *publishedAddonV1.EffectiveFrom)
274
+
275
+ assert.Equalf(t, productcatalog.AddonStatusActive, publishedAddonV1.Status(),
276
+ "add-on Status mismatch: expected=%s, actual=%s", productcatalog.AddonStatusActive, publishedAddonV1.Status())
277
+
278
+ t.Run("Update", func(t *testing.T) {
279
+ updateInput := addon.UpdateAddonInput{
280
+ NamespacedID: addonV1.NamespacedID,
281
+ Name: lo.ToPtr("Invalid Update"),
282
+ }
283
+
284
+ _, err = env.Addon.UpdateAddon(ctx, updateInput)
285
+ require.Errorf(t, err, "updating active add-on must fail")
286
+ })
287
+ })
288
+
289
+ var addonV2 *addon.Addon
290
+
291
+ t.Run("V2", func(t *testing.T) {
292
+ addonV2, err = env.Addon.CreateAddon(ctx, addonV1Input)
293
+ require.NoErrorf(t, err, "creating a new draft add-on from active must not fail")
294
+ require.NotNil(t, addonV2, "new draft add-on must not be empty")
295
+
296
+ assert.Equalf(t, publishedAddonV1.Version+1, addonV2.Version,
297
+ "new draft add-on must have higher version number")
298
+
299
+ assert.Equalf(t, productcatalog.AddonStatusDraft, addonV2.Status(),
300
+ "add-on Status mismatch: expected=%s, actual=%s", productcatalog.AddonStatusDraft, addonV2.Status())
301
+
302
+ t.Run("PublishUnaligned", func(t *testing.T) {
303
+ updateInput := addon.UpdateAddonInput{
304
+ NamespacedID: addonV2.NamespacedID,
305
+ RateCards: &productcatalog.RateCards{
306
+ &productcatalog.FlatFeeRateCard{
307
+ RateCardMeta: productcatalog.RateCardMeta{
308
+ Key: "misaligned1",
309
+ Name: "Misaligned 1",
310
+ Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
311
+ Amount: decimal.NewFromInt(100),
312
+ PaymentTerm: productcatalog.DefaultPaymentTerm,
313
+ }),
314
+ },
315
+ BillingCadence: lo.ToPtr(datetime.MustParseDuration(t, "P1W")),
316
+ },
317
+ &productcatalog.FlatFeeRateCard{
318
+ RateCardMeta: productcatalog.RateCardMeta{
319
+ Key: "misaligned2",
320
+ Name: "Misaligned 2",
321
+ Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
322
+ Amount: decimal.NewFromInt(10),
323
+ PaymentTerm: productcatalog.DefaultPaymentTerm,
324
+ }),
325
+ },
326
+ BillingCadence: lo.ToPtr(datetime.MustParseDuration(t, "P1M")),
327
+ },
328
+ },
329
+ }
330
+
331
+ _, err := env.Addon.UpdateAddon(ctx, updateInput)
332
+ require.NoError(t, err)
333
+
334
+ // Get the updated add-on
335
+ _, err = env.Addon.GetAddon(ctx, addon.GetAddonInput{
336
+ NamespacedID: addonV2.NamespacedID,
337
+ })
338
+ require.NoError(t, err)
339
+
340
+ // Let's try to publish the add-on
341
+ publishAt := time.Now().Truncate(time.Microsecond)
342
+
343
+ publishInput := addon.PublishAddonInput{
344
+ NamespacedID: addonV2.NamespacedID,
345
+ EffectivePeriod: productcatalog.EffectivePeriod{
346
+ EffectiveFrom: &publishAt,
347
+ EffectiveTo: nil,
348
+ },
349
+ }
350
+
351
+ _, err = env.Addon.PublishAddon(ctx, publishInput)
352
+ require.Error(t, err, "publishing draft add-on with alignment issues must fail")
353
+
354
+ // Let's update the plan to fix the alignment issue
355
+ _, err = env.Addon.UpdateAddon(ctx, addon.UpdateAddonInput{
356
+ NamespacedID: addonV2.NamespacedID,
357
+ RateCards: lo.ToPtr(publishedAddonV1.RateCards.AsProductCatalogRateCards()),
358
+ })
359
+ require.NoError(t, err)
360
+ })
361
+
362
+ t.Run("Publish", func(t *testing.T) {
363
+ publishAt := time.Now().Truncate(time.Microsecond)
364
+
365
+ publishInput := addon.PublishAddonInput{
366
+ NamespacedID: addonV2.NamespacedID,
367
+ EffectivePeriod: productcatalog.EffectivePeriod{
368
+ EffectiveFrom: &publishAt,
369
+ EffectiveTo: nil,
370
+ },
371
+ }
372
+
373
+ publishedAddonV2, err := env.Addon.PublishAddon(ctx, publishInput)
374
+ require.NoErrorf(t, err, "publishing draft add-on must not fail")
375
+ require.NotNil(t, publishedAddonV2, "published add-on must not be empty")
376
+ require.NotNil(t, publishedAddonV2.EffectiveFrom, "EffectiveFrom for published add-on must not be empty")
377
+
378
+ assert.Equalf(t, publishAt, *publishedAddonV2.EffectiveFrom,
379
+ "EffectiveFrom for published add-on mismatch: expected=%s, actual=%s", publishAt, *publishedAddonV2.EffectiveFrom)
380
+
381
+ assert.Equalf(t, productcatalog.AddonStatusActive, publishedAddonV2.Status(),
382
+ "add-on Status mismatch: expected=%s, actual=%s", productcatalog.AddonStatusActive, publishedAddonV2.Status())
383
+
384
+ getAddonV1, err := env.Addon.GetAddon(ctx, addon.GetAddonInput{
385
+ NamespacedID: publishedAddonV1.NamespacedID,
386
+ })
387
+ require.NoErrorf(t, err, "getting previous add-on version must not fail")
388
+ require.NotNil(t, getAddonV1, "previous add version must not be empty")
389
+
390
+ assert.Equalf(t, productcatalog.AddonStatusArchived, getAddonV1.Status(),
391
+ "add Status mismatch: expected=%s, actual=%s", productcatalog.AddonStatusArchived, getAddonV1.Status())
392
+
393
+ t.Run("Archive", func(t *testing.T) {
394
+ archiveAt := time.Now().Truncate(time.Microsecond)
395
+
396
+ archiveInput := addon.ArchiveAddonInput{
397
+ NamespacedID: addonV2.NamespacedID,
398
+ EffectiveTo: archiveAt,
399
+ }
400
+
401
+ archivedAddonV2, err := env.Addon.ArchiveAddon(ctx, archiveInput)
402
+ require.NoErrorf(t, err, "archiving add-on must not fail")
403
+ require.NotNil(t, archivedAddonV2, "archived add-on must not be empty")
404
+ require.NotNil(t, archivedAddonV2.EffectiveTo, "EffectiveFrom for archived add-on must not be empty")
405
+
406
+ assert.Equalf(t, archiveAt, *archivedAddonV2.EffectiveTo,
407
+ "EffectiveTo for published add-on mismatch: expected=%s, actual=%s", archiveAt, *archivedAddonV2.EffectiveTo)
408
+
409
+ assert.Equalf(t, productcatalog.AddonStatusArchived, archivedAddonV2.Status(),
410
+ "Status mismatch for archived add-on: expected=%s, actual=%s", productcatalog.AddonStatusArchived, archivedAddonV2.Status())
411
+ })
412
+ })
413
+
414
+ t.Run("Delete", func(t *testing.T) {
415
+ deleteInput := addon.DeleteAddonInput{
416
+ NamespacedID: addonV2.NamespacedID,
417
+ }
418
+
419
+ err = env.Addon.DeleteAddon(ctx, deleteInput)
420
+ require.NoErrorf(t, err, "deleting add-on must not fail")
421
+
422
+ deletedAddonV2, err := env.Addon.GetAddon(ctx, addon.GetAddonInput{
423
+ NamespacedID: addonV2.NamespacedID,
424
+ })
425
+ require.NoErrorf(t, err, "getting deleted add-on version must not fail")
426
+ require.NotNil(t, deletedAddonV2, "deleted add-on version must not be empty")
427
+
428
+ assert.NotNilf(t, deletedAddonV2.DeletedAt, "deletedAt must not be empty")
429
+
430
+ err = env.Addon.DeleteAddon(ctx, deleteInput)
431
+ require.NoErrorf(t, err, "deleting add-on must not fail")
432
+
433
+ deletedAddonV2Next, err := env.Addon.GetAddon(ctx, addon.GetAddonInput{
434
+ NamespacedID: addonV2.NamespacedID,
435
+ })
436
+ require.NoErrorf(t, err, "getting deleted add-on version must not fail")
437
+ require.NotNil(t, deletedAddonV2Next, "deleted add-on version must not be empty")
438
+
439
+ assert.Truef(t, deletedAddonV2.DeletedAt.Equal(*deletedAddonV2Next.DeletedAt), "deletedAt field must not be updated")
440
+ })
441
+ })
442
+ })
443
+ })
444
+ }
445
+
446
+ func TestAddonService_List(t *testing.T) {
447
+ ctx, cancel := context.WithCancel(context.Background())
448
+ defer cancel()
449
+
450
+ env := pctestutils.NewTestEnv(t)
451
+ t.Cleanup(func() {
452
+ env.Close(t)
453
+ })
454
+
455
+ namespace := pctestutils.NewTestNamespace(t)
456
+
457
+ // Create some addons for testing
458
+ addonCount := 5
459
+ addons := make([]*addon.Addon, 0, addonCount)
460
+ for i := range addonCount {
461
+ addonInput := pctestutils.NewTestAddon(t, namespace, &productcatalog.FlatFeeRateCard{
462
+ RateCardMeta: productcatalog.RateCardMeta{
463
+ Key: fmt.Sprintf("rc-%d", i),
464
+ Name: fmt.Sprintf("RateCard %d", i),
465
+ Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
466
+ Amount: decimal.NewFromInt(100),
467
+ PaymentTerm: productcatalog.InAdvancePaymentTerm,
468
+ }),
469
+ },
470
+ })
471
+ addonInput.Key = fmt.Sprintf("addon-%d", i)
472
+ addonInput.Name = fmt.Sprintf("Addon %d", i)
473
+ if i%2 == 0 {
474
+ addonInput.Currency = "USD"
475
+ } else {
476
+ addonInput.Currency = "EUR"
477
+ }
478
+
479
+ a, err := env.Addon.CreateAddon(ctx, addonInput)
480
+ require.NoError(t, err)
481
+ addons = append(addons, a)
482
+ }
483
+
484
+ testCases := []struct {
485
+ name string
486
+ input addon.ListAddonsInput
487
+ validate func(t *testing.T, res pagination.Result[addon.Addon])
488
+ }{
489
+ {
490
+ name: "ListAll",
491
+ input: addon.ListAddonsInput{
492
+ Namespaces: []string{namespace},
493
+ Page: pagination.Page{
494
+ PageSize: 10,
495
+ PageNumber: 1,
496
+ },
497
+ },
498
+ validate: func(t *testing.T, res pagination.Result[addon.Addon]) {
499
+ assert.Len(t, res.Items, addonCount)
500
+ },
501
+ },
502
+ {
503
+ name: "FilterByID",
504
+ input: addon.ListAddonsInput{
505
+ Namespaces: []string{namespace},
506
+ ID: &filter.FilterULID{
507
+ FilterString: filter.FilterString{
508
+ Eq: convert.ToPointer(addons[0].ID),
509
+ },
510
+ },
511
+ Page: pagination.Page{
512
+ PageSize: 10,
513
+ PageNumber: 1,
514
+ },
515
+ },
516
+ validate: func(t *testing.T, res pagination.Result[addon.Addon]) {
517
+ assert.Len(t, res.Items, 1)
518
+ assert.Equal(t, addons[0].ID, res.Items[0].ID)
519
+ },
520
+ },
521
+ {
522
+ name: "FilterByKey",
523
+ input: addon.ListAddonsInput{
524
+ Namespaces: []string{namespace},
525
+ Key: &filter.FilterString{
526
+ Eq: convert.ToPointer(addons[1].Key),
527
+ },
528
+ Page: pagination.Page{
529
+ PageSize: 10,
530
+ PageNumber: 1,
531
+ },
532
+ },
533
+ validate: func(t *testing.T, res pagination.Result[addon.Addon]) {
534
+ assert.Len(t, res.Items, 1)
535
+ assert.Equal(t, addons[1].Key, res.Items[0].Key)
536
+ },
537
+ },
538
+ {
539
+ name: "FilterByName",
540
+ input: addon.ListAddonsInput{
541
+ Namespaces: []string{namespace},
542
+ Name: &filter.FilterString{
543
+ Contains: convert.ToPointer("Addon 2"),
544
+ },
545
+ Page: pagination.Page{
546
+ PageSize: 10,
547
+ PageNumber: 1,
548
+ },
549
+ },
550
+ validate: func(t *testing.T, res pagination.Result[addon.Addon]) {
551
+ assert.Len(t, res.Items, 1)
552
+ assert.Equal(t, "Addon 2", res.Items[0].Name)
553
+ },
554
+ },
555
+ {
556
+ name: "FilterByCurrency",
557
+ input: addon.ListAddonsInput{
558
+ Namespaces: []string{namespace},
559
+ Currency: &filter.FilterString{
560
+ Eq: convert.ToPointer("EUR"),
561
+ },
562
+ Page: pagination.Page{
563
+ PageSize: 10,
564
+ PageNumber: 1,
565
+ },
566
+ },
567
+ validate: func(t *testing.T, res pagination.Result[addon.Addon]) {
568
+ assert.Len(t, res.Items, 2)
569
+ },
570
+ },
571
+ {
572
+ name: "FilterByStatus",
573
+ input: addon.ListAddonsInput{
574
+ Namespaces: []string{namespace},
575
+ Status: []productcatalog.AddonStatus{productcatalog.AddonStatusDraft},
576
+ Page: pagination.Page{
577
+ PageSize: 10,
578
+ PageNumber: 1,
579
+ },
580
+ },
581
+ validate: func(t *testing.T, res pagination.Result[addon.Addon]) {
582
+ assert.Len(t, res.Items, addonCount)
583
+ },
584
+ },
585
+ {
586
+ name: "SortByNameDesc",
587
+ input: addon.ListAddonsInput{
588
+ Namespaces: []string{namespace},
589
+ OrderBy: addon.OrderByName,
590
+ Order: addon.OrderDesc,
591
+ Page: pagination.Page{
592
+ PageSize: 10,
593
+ PageNumber: 1,
594
+ },
595
+ },
596
+ validate: func(t *testing.T, res pagination.Result[addon.Addon]) {
597
+ assert.Len(t, res.Items, addonCount)
598
+ assert.Equal(t, "Addon 4", res.Items[0].Name)
599
+ },
600
+ },
601
+ }
602
+
603
+ for _, tc := range testCases {
604
+ t.Run(tc.name, func(t *testing.T) {
605
+ res, err := env.Addon.ListAddons(ctx, tc.input)
606
+ require.NoError(t, err)
607
+ tc.validate(t, res)
608
+ })
609
+ }
610
+ }
openmeter/productcatalog/addon/service/taxcode_test.go ADDED
@@ -0,0 +1,899 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package service_test
2
+
3
+ import (
4
+ "context"
5
+ "testing"
6
+ "time"
7
+
8
+ decimal "github.com/alpacahq/alpacadecimal"
9
+ "github.com/samber/lo"
10
+ "github.com/stretchr/testify/assert"
11
+ "github.com/stretchr/testify/require"
12
+
13
+ "github.com/openmeterio/openmeter/openmeter/app"
14
+ "github.com/openmeterio/openmeter/openmeter/ent/db/addonratecard"
15
+ "github.com/openmeterio/openmeter/openmeter/meter"
16
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
17
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/addon"
18
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
19
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/planaddon"
20
+ pctestutils "github.com/openmeterio/openmeter/openmeter/productcatalog/testutils"
21
+ "github.com/openmeterio/openmeter/openmeter/taxcode"
22
+ taxcodetestutils "github.com/openmeterio/openmeter/openmeter/taxcode/testutils"
23
+ "github.com/openmeterio/openmeter/pkg/models"
24
+ "github.com/openmeterio/openmeter/pkg/pagination"
25
+ )
26
+
27
+ func newTestAddonFlatRateCard(feat feature.Feature, tc *productcatalog.TaxConfig) productcatalog.RateCard {
28
+ return &productcatalog.FlatFeeRateCard{
29
+ RateCardMeta: productcatalog.RateCardMeta{
30
+ Key: feat.Key,
31
+ Name: feat.Name,
32
+ FeatureKey: lo.ToPtr(feat.Key),
33
+ FeatureID: lo.ToPtr(feat.ID),
34
+ TaxConfig: tc,
35
+ Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
36
+ Amount: decimal.NewFromInt(100),
37
+ PaymentTerm: productcatalog.InArrearsPaymentTerm,
38
+ }),
39
+ },
40
+ BillingCadence: &MonthPeriod,
41
+ }
42
+ }
43
+
44
+ func newTestAddonInput(t *testing.T, namespace string, rcs ...productcatalog.RateCard) addon.CreateAddonInput {
45
+ t.Helper()
46
+ return pctestutils.NewTestAddon(t, namespace, rcs...)
47
+ }
48
+
49
+ func getFirstAddonRCTaxConfig(t *testing.T, a *addon.Addon) *productcatalog.TaxConfig {
50
+ t.Helper()
51
+ require.NotEmpty(t, a.RateCards)
52
+ return a.RateCards[0].AsMeta().TaxConfig
53
+ }
54
+
55
+ func findAddonTaxCodeByStripeCode(t *testing.T, ctx context.Context, svc taxcode.Service, namespace string, stripeCode string) (taxcode.TaxCode, error) {
56
+ t.Helper()
57
+ return svc.GetTaxCodeByAppMapping(ctx, taxcode.GetTaxCodeByAppMappingInput{
58
+ Namespace: namespace,
59
+ AppType: app.AppTypeStripe,
60
+ TaxCode: stripeCode,
61
+ })
62
+ }
63
+
64
+ // assertAddonRCDBCols queries the AddonRateCard row directly from the database and asserts the
65
+ // dedicated tax_code_id and tax_behavior columns match the expected values.
66
+ func assertAddonRCDBCols(t *testing.T, ctx context.Context, env *pctestutils.TestEnv, addonID string, rcKey string, wantTaxCodeID *string, wantBehavior *productcatalog.TaxBehavior) {
67
+ t.Helper()
68
+ row, err := env.Client.AddonRateCard.Query().
69
+ Where(
70
+ addonratecard.AddonID(addonID),
71
+ addonratecard.Key(rcKey),
72
+ addonratecard.DeletedAtIsNil(),
73
+ ).
74
+ Only(ctx)
75
+ require.NoError(t, err, "direct DB read of AddonRateCard must succeed")
76
+ assert.Equal(t, wantTaxCodeID, row.TaxCodeID, "tax_code_id column mismatch")
77
+ assert.Equal(t, wantBehavior, row.TaxBehavior, "tax_behavior column mismatch")
78
+ }
79
+
80
+ func TestAddonTaxCodeDualWrite(t *testing.T) {
81
+ ctx, cancel := context.WithCancel(context.Background())
82
+ defer cancel()
83
+
84
+ env := pctestutils.NewTestEnv(t)
85
+ t.Cleanup(func() {
86
+ env.Close(t)
87
+ })
88
+
89
+ namespace := pctestutils.NewTestNamespace(t)
90
+
91
+ // Setup meters and features
92
+ err := env.Meter.ReplaceMeters(ctx, pctestutils.NewTestMeters(t, namespace))
93
+ require.NoError(t, err)
94
+
95
+ result, err := env.Meter.ListMeters(ctx, meter.ListMetersParams{
96
+ Page: pagination.Page{
97
+ PageSize: 1000,
98
+ PageNumber: 1,
99
+ },
100
+ Namespace: namespace,
101
+ })
102
+ require.NoError(t, err)
103
+ require.NotEmpty(t, result.Items)
104
+
105
+ features := make([]feature.Feature, 0, len(result.Items))
106
+ for _, m := range result.Items {
107
+ feat, err := env.Feature.CreateFeature(ctx, pctestutils.NewTestFeatureFromMeter(t, &m))
108
+ require.NoError(t, err)
109
+ features = append(features, feat)
110
+ }
111
+
112
+ t.Run("Create", func(t *testing.T) {
113
+ t.Run("NoTaxConfig", func(t *testing.T) {
114
+ input := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], nil))
115
+ input.Key = "addon-no-tax-config"
116
+ input.Name = "No Tax Config"
117
+
118
+ a, err := env.Addon.CreateAddon(ctx, input)
119
+ require.NoError(t, err)
120
+
121
+ tc := getFirstAddonRCTaxConfig(t, a)
122
+ assert.Nil(t, tc, "TaxConfig should be nil")
123
+
124
+ assertAddonRCDBCols(t, ctx, env, a.ID, features[0].Key, nil, nil)
125
+ })
126
+
127
+ t.Run("StripeCodeOnly", func(t *testing.T) {
128
+ input := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], &productcatalog.TaxConfig{
129
+ Stripe: &productcatalog.StripeTaxConfig{
130
+ Code: "txcd_10000001",
131
+ },
132
+ }))
133
+ input.Key = "addon-stripe-only"
134
+ input.Name = "Stripe Only"
135
+
136
+ a, err := env.Addon.CreateAddon(ctx, input)
137
+ require.NoError(t, err)
138
+
139
+ tc := getFirstAddonRCTaxConfig(t, a)
140
+ require.NotNil(t, tc)
141
+
142
+ // Stripe code preserved
143
+ require.NotNil(t, tc.Stripe)
144
+ assert.Equal(t, "txcd_10000001", tc.Stripe.Code)
145
+
146
+ // TaxCodeID should be set
147
+ require.NotNil(t, tc.TaxCodeID, "TaxCodeID must be populated after resolution")
148
+
149
+ // Verify TaxCode entity exists
150
+ tcEntity, err := findAddonTaxCodeByStripeCode(t, ctx, env.TaxCode, namespace, "txcd_10000001")
151
+ require.NoError(t, err)
152
+ assert.Equal(t, *tc.TaxCodeID, tcEntity.ID)
153
+ assert.Equal(t, namespace, tcEntity.Namespace)
154
+
155
+ assertAddonRCDBCols(t, ctx, env, a.ID, features[0].Key, lo.ToPtr(tcEntity.ID), nil)
156
+ })
157
+
158
+ t.Run("StripeCodeAndBehavior", func(t *testing.T) {
159
+ input := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], &productcatalog.TaxConfig{
160
+ Behavior: lo.ToPtr(productcatalog.ExclusiveTaxBehavior),
161
+ Stripe: &productcatalog.StripeTaxConfig{
162
+ Code: "txcd_20000001",
163
+ },
164
+ }))
165
+ input.Key = "addon-stripe-and-behavior"
166
+ input.Name = "Stripe and Behavior"
167
+
168
+ a, err := env.Addon.CreateAddon(ctx, input)
169
+ require.NoError(t, err)
170
+
171
+ tc := getFirstAddonRCTaxConfig(t, a)
172
+ require.NotNil(t, tc)
173
+
174
+ require.NotNil(t, tc.Behavior)
175
+ assert.Equal(t, productcatalog.ExclusiveTaxBehavior, *tc.Behavior)
176
+
177
+ require.NotNil(t, tc.Stripe)
178
+ assert.Equal(t, "txcd_20000001", tc.Stripe.Code)
179
+
180
+ require.NotNil(t, tc.TaxCodeID)
181
+
182
+ tcEntity, err := findAddonTaxCodeByStripeCode(t, ctx, env.TaxCode, namespace, "txcd_20000001")
183
+ require.NoError(t, err)
184
+ assert.Equal(t, *tc.TaxCodeID, tcEntity.ID)
185
+
186
+ assertAddonRCDBCols(t, ctx, env, a.ID, features[0].Key, lo.ToPtr(tcEntity.ID), lo.ToPtr(productcatalog.ExclusiveTaxBehavior))
187
+ })
188
+
189
+ t.Run("BehaviorOnlyNoStripe", func(t *testing.T) {
190
+ input := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], &productcatalog.TaxConfig{
191
+ Behavior: lo.ToPtr(productcatalog.InclusiveTaxBehavior),
192
+ }))
193
+ input.Key = "addon-behavior-only"
194
+ input.Name = "Behavior Only"
195
+
196
+ a, err := env.Addon.CreateAddon(ctx, input)
197
+ require.NoError(t, err)
198
+
199
+ tc := getFirstAddonRCTaxConfig(t, a)
200
+ require.NotNil(t, tc)
201
+
202
+ require.NotNil(t, tc.Behavior)
203
+ assert.Equal(t, productcatalog.InclusiveTaxBehavior, *tc.Behavior)
204
+
205
+ assert.Nil(t, tc.Stripe, "Stripe should be nil when not provided")
206
+ assert.Nil(t, tc.TaxCodeID, "TaxCodeID should be nil when no Stripe code")
207
+
208
+ assertAddonRCDBCols(t, ctx, env, a.ID, features[0].Key, nil, lo.ToPtr(productcatalog.InclusiveTaxBehavior))
209
+ })
210
+
211
+ t.Run("ReuseExistingTaxCode", func(t *testing.T) {
212
+ // Create first addon with txcd_30000001
213
+ input1 := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], &productcatalog.TaxConfig{
214
+ Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_30000001"},
215
+ }))
216
+ input1.Key = "addon-reuse-1"
217
+ input1.Name = "Reuse 1"
218
+
219
+ a1, err := env.Addon.CreateAddon(ctx, input1)
220
+ require.NoError(t, err)
221
+
222
+ tc1 := getFirstAddonRCTaxConfig(t, a1)
223
+ require.NotNil(t, tc1)
224
+ require.NotNil(t, tc1.TaxCodeID)
225
+
226
+ // Create second addon with same stripe code
227
+ input2 := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], &productcatalog.TaxConfig{
228
+ Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_30000001"},
229
+ }))
230
+ input2.Key = "addon-reuse-2"
231
+ input2.Name = "Reuse 2"
232
+
233
+ a2, err := env.Addon.CreateAddon(ctx, input2)
234
+ require.NoError(t, err)
235
+
236
+ tc2 := getFirstAddonRCTaxConfig(t, a2)
237
+ require.NotNil(t, tc2)
238
+ require.NotNil(t, tc2.TaxCodeID)
239
+
240
+ // Both addons should reference the same TaxCode entity
241
+ assert.Equal(t, *tc1.TaxCodeID, *tc2.TaxCodeID, "both addons must reference the same TaxCode entity")
242
+
243
+ assertAddonRCDBCols(t, ctx, env, a1.ID, features[0].Key, lo.ToPtr(*tc1.TaxCodeID), nil)
244
+ assertAddonRCDBCols(t, ctx, env, a2.ID, features[0].Key, lo.ToPtr(*tc2.TaxCodeID), nil)
245
+ })
246
+
247
+ t.Run("MultipleDifferentStripeCodes", func(t *testing.T) {
248
+ rc1 := &productcatalog.FlatFeeRateCard{
249
+ RateCardMeta: productcatalog.RateCardMeta{
250
+ Key: "rc-a",
251
+ Name: "RC A",
252
+ TaxConfig: &productcatalog.TaxConfig{
253
+ Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_40000001"},
254
+ },
255
+ Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
256
+ Amount: decimal.NewFromInt(100),
257
+ PaymentTerm: productcatalog.InArrearsPaymentTerm,
258
+ }),
259
+ },
260
+ BillingCadence: &MonthPeriod,
261
+ }
262
+
263
+ rc2 := &productcatalog.FlatFeeRateCard{
264
+ RateCardMeta: productcatalog.RateCardMeta{
265
+ Key: "rc-b",
266
+ Name: "RC B",
267
+ TaxConfig: &productcatalog.TaxConfig{
268
+ Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_50000001"},
269
+ },
270
+ Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
271
+ Amount: decimal.NewFromInt(200),
272
+ PaymentTerm: productcatalog.InArrearsPaymentTerm,
273
+ }),
274
+ },
275
+ BillingCadence: &MonthPeriod,
276
+ }
277
+
278
+ input := pctestutils.NewTestAddon(t, namespace, rc1, rc2)
279
+ input.Key = "addon-multi-stripe"
280
+ input.Name = "Multi Stripe"
281
+
282
+ a, err := env.Addon.CreateAddon(ctx, input)
283
+ require.NoError(t, err)
284
+
285
+ require.Len(t, a.RateCards, 2)
286
+
287
+ rcMap := make(map[string]*productcatalog.TaxConfig)
288
+ for _, rc := range a.RateCards {
289
+ rcMap[rc.AsMeta().Key] = rc.AsMeta().TaxConfig
290
+ }
291
+
292
+ tcA := rcMap["rc-a"]
293
+ tcB := rcMap["rc-b"]
294
+
295
+ require.NotNil(t, tcA)
296
+ require.NotNil(t, tcA.TaxCodeID)
297
+ require.NotNil(t, tcB)
298
+ require.NotNil(t, tcB.TaxCodeID)
299
+
300
+ assert.NotEqual(t, *tcA.TaxCodeID, *tcB.TaxCodeID, "different stripe codes must create different TaxCode entities")
301
+
302
+ tcEntityA, err := findAddonTaxCodeByStripeCode(t, ctx, env.TaxCode, namespace, "txcd_40000001")
303
+ require.NoError(t, err)
304
+ tcEntityB, err := findAddonTaxCodeByStripeCode(t, ctx, env.TaxCode, namespace, "txcd_50000001")
305
+ require.NoError(t, err)
306
+ assertAddonRCDBCols(t, ctx, env, a.ID, "rc-a", lo.ToPtr(tcEntityA.ID), nil)
307
+ assertAddonRCDBCols(t, ctx, env, a.ID, "rc-b", lo.ToPtr(tcEntityB.ID), nil)
308
+ })
309
+
310
+ t.Run("TaxCodeIdOnly", func(t *testing.T) {
311
+ // Pre-create a TaxCode entity with a Stripe mapping.
312
+ tcEntity, err := env.TaxCode.GetOrCreateByAppMapping(ctx, taxcode.GetOrCreateByAppMappingInput{
313
+ Namespace: namespace,
314
+ AppType: app.AppTypeStripe,
315
+ TaxCode: "txcd_60000003",
316
+ })
317
+ require.NoError(t, err)
318
+
319
+ input := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], &productcatalog.TaxConfig{
320
+ TaxCodeID: lo.ToPtr(tcEntity.ID),
321
+ }))
322
+ input.Key = "addon-taxcodeid-only"
323
+ input.Name = "TaxCodeId Only"
324
+
325
+ a, err := env.Addon.CreateAddon(ctx, input)
326
+ require.NoError(t, err)
327
+
328
+ tc := getFirstAddonRCTaxConfig(t, a)
329
+ require.NotNil(t, tc)
330
+ require.NotNil(t, tc.TaxCodeID)
331
+ assert.Equal(t, tcEntity.ID, *tc.TaxCodeID)
332
+
333
+ // Stripe code should be backfilled from the TaxCode entity's app mapping.
334
+ require.NotNil(t, tc.Stripe, "Stripe must be backfilled from TaxCode app mapping")
335
+ assert.Equal(t, "txcd_60000003", tc.Stripe.Code)
336
+
337
+ assertAddonRCDBCols(t, ctx, env, a.ID, features[0].Key, lo.ToPtr(tcEntity.ID), nil)
338
+ })
339
+
340
+ t.Run("TaxCodeIdNotFound", func(t *testing.T) {
341
+ input := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], &productcatalog.TaxConfig{
342
+ TaxCodeID: lo.ToPtr("01JNON_EXISTENT_TAX_CODE_ID"),
343
+ }))
344
+ input.Key = "addon-taxcodeid-not-found"
345
+ input.Name = "TaxCodeId Not Found"
346
+
347
+ _, err := env.Addon.CreateAddon(ctx, input)
348
+ require.Error(t, err)
349
+ assert.True(t, models.IsGenericValidationError(err), "expected validation error for unknown taxCodeId, got: %v", err)
350
+ })
351
+ })
352
+
353
+ t.Run("Update", func(t *testing.T) {
354
+ t.Run("AddTaxConfig", func(t *testing.T) {
355
+ // Create addon without TaxConfig
356
+ input := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], nil))
357
+ input.Key = "addon-update-add-tax"
358
+ input.Name = "Update Add Tax"
359
+
360
+ a, err := env.Addon.CreateAddon(ctx, input)
361
+ require.NoError(t, err)
362
+
363
+ tc := getFirstAddonRCTaxConfig(t, a)
364
+ assert.Nil(t, tc)
365
+
366
+ // Update to add TaxConfig with Stripe code
367
+ updatedRateCards := productcatalog.RateCards{
368
+ newTestAddonFlatRateCard(features[0], &productcatalog.TaxConfig{
369
+ Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_70000001"},
370
+ }),
371
+ }
372
+
373
+ updated, err := env.Addon.UpdateAddon(ctx, addon.UpdateAddonInput{
374
+ NamespacedID: a.NamespacedID,
375
+ RateCards: &updatedRateCards,
376
+ })
377
+ require.NoError(t, err)
378
+
379
+ tc = getFirstAddonRCTaxConfig(t, updated)
380
+ require.NotNil(t, tc)
381
+ require.NotNil(t, tc.Stripe)
382
+ assert.Equal(t, "txcd_70000001", tc.Stripe.Code)
383
+ require.NotNil(t, tc.TaxCodeID, "TaxCodeID must be populated after update")
384
+
385
+ tcEntity, err := findAddonTaxCodeByStripeCode(t, ctx, env.TaxCode, namespace, "txcd_70000001")
386
+ require.NoError(t, err)
387
+ assert.Equal(t, *tc.TaxCodeID, tcEntity.ID)
388
+
389
+ assertAddonRCDBCols(t, ctx, env, updated.ID, features[0].Key, lo.ToPtr(tcEntity.ID), nil)
390
+ })
391
+
392
+ t.Run("ChangeStripeCode", func(t *testing.T) {
393
+ input := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], &productcatalog.TaxConfig{
394
+ Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_80000001"},
395
+ }))
396
+ input.Key = "addon-update-change-code"
397
+ input.Name = "Update Change Code"
398
+
399
+ a, err := env.Addon.CreateAddon(ctx, input)
400
+ require.NoError(t, err)
401
+
402
+ oldTC := getFirstAddonRCTaxConfig(t, a)
403
+ require.NotNil(t, oldTC)
404
+ require.NotNil(t, oldTC.TaxCodeID)
405
+ oldTaxCodeID := *oldTC.TaxCodeID
406
+
407
+ // Update to different stripe code
408
+ updatedRateCards := productcatalog.RateCards{
409
+ newTestAddonFlatRateCard(features[0], &productcatalog.TaxConfig{
410
+ Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_90000001"},
411
+ }),
412
+ }
413
+
414
+ updated, err := env.Addon.UpdateAddon(ctx, addon.UpdateAddonInput{
415
+ NamespacedID: a.NamespacedID,
416
+ RateCards: &updatedRateCards,
417
+ })
418
+ require.NoError(t, err)
419
+
420
+ newTC := getFirstAddonRCTaxConfig(t, updated)
421
+ require.NotNil(t, newTC)
422
+ require.NotNil(t, newTC.Stripe)
423
+ assert.Equal(t, "txcd_90000001", newTC.Stripe.Code)
424
+ require.NotNil(t, newTC.TaxCodeID)
425
+
426
+ assert.NotEqual(t, oldTaxCodeID, *newTC.TaxCodeID, "new stripe code must create a new TaxCode entity")
427
+
428
+ // Old TaxCode entity should still exist
429
+ _, err = findAddonTaxCodeByStripeCode(t, ctx, env.TaxCode, namespace, "txcd_80000001")
430
+ assert.NoError(t, err, "old TaxCode entity should still exist")
431
+
432
+ newTCEntity, err := findAddonTaxCodeByStripeCode(t, ctx, env.TaxCode, namespace, "txcd_90000001")
433
+ require.NoError(t, err)
434
+ assertAddonRCDBCols(t, ctx, env, updated.ID, features[0].Key, lo.ToPtr(newTCEntity.ID), nil)
435
+ })
436
+
437
+ t.Run("UpdateWithTaxCodeId", func(t *testing.T) {
438
+ input := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], nil))
439
+ input.Key = "addon-update-taxcodeid"
440
+ input.Name = "Update TaxCodeId"
441
+
442
+ a, err := env.Addon.CreateAddon(ctx, input)
443
+ require.NoError(t, err)
444
+
445
+ tcEntity, err := env.TaxCode.GetOrCreateByAppMapping(ctx, taxcode.GetOrCreateByAppMappingInput{
446
+ Namespace: namespace,
447
+ AppType: app.AppTypeStripe,
448
+ TaxCode: "txcd_60000004",
449
+ })
450
+ require.NoError(t, err)
451
+
452
+ updatedRateCards := productcatalog.RateCards{
453
+ newTestAddonFlatRateCard(features[0], &productcatalog.TaxConfig{
454
+ TaxCodeID: lo.ToPtr(tcEntity.ID),
455
+ }),
456
+ }
457
+
458
+ updated, err := env.Addon.UpdateAddon(ctx, addon.UpdateAddonInput{
459
+ NamespacedID: a.NamespacedID,
460
+ RateCards: &updatedRateCards,
461
+ })
462
+ require.NoError(t, err)
463
+
464
+ tc := getFirstAddonRCTaxConfig(t, updated)
465
+ require.NotNil(t, tc)
466
+ require.NotNil(t, tc.TaxCodeID)
467
+ assert.Equal(t, tcEntity.ID, *tc.TaxCodeID)
468
+ require.NotNil(t, tc.Stripe, "Stripe must be backfilled from TaxCode app mapping")
469
+ assert.Equal(t, "txcd_60000004", tc.Stripe.Code)
470
+
471
+ assertAddonRCDBCols(t, ctx, env, updated.ID, features[0].Key, lo.ToPtr(tcEntity.ID), nil)
472
+ })
473
+
474
+ t.Run("RemoveTaxConfig", func(t *testing.T) {
475
+ input := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], &productcatalog.TaxConfig{
476
+ Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_11000001"},
477
+ }))
478
+ input.Key = "addon-update-remove-tax"
479
+ input.Name = "Update Remove Tax"
480
+
481
+ a, err := env.Addon.CreateAddon(ctx, input)
482
+ require.NoError(t, err)
483
+
484
+ tc := getFirstAddonRCTaxConfig(t, a)
485
+ require.NotNil(t, tc)
486
+ require.NotNil(t, tc.TaxCodeID)
487
+
488
+ // Update to remove TaxConfig
489
+ updatedRateCards := productcatalog.RateCards{
490
+ newTestAddonFlatRateCard(features[0], nil),
491
+ }
492
+
493
+ updated, err := env.Addon.UpdateAddon(ctx, addon.UpdateAddonInput{
494
+ NamespacedID: a.NamespacedID,
495
+ RateCards: &updatedRateCards,
496
+ })
497
+ require.NoError(t, err)
498
+
499
+ tc = getFirstAddonRCTaxConfig(t, updated)
500
+ assert.Nil(t, tc, "TaxConfig should be nil after removal")
501
+
502
+ // TaxCode entity should still exist (orphaned, not deleted)
503
+ _, err = findAddonTaxCodeByStripeCode(t, ctx, env.TaxCode, namespace, "txcd_11000001")
504
+ assert.NoError(t, err, "TaxCode entity should not be deleted")
505
+
506
+ assertAddonRCDBCols(t, ctx, env, updated.ID, features[0].Key, nil, nil)
507
+ })
508
+
509
+ t.Run("MetadataOnlyUpdate", func(t *testing.T) {
510
+ input := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], &productcatalog.TaxConfig{
511
+ Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_12000001"},
512
+ }))
513
+ input.Key = "addon-update-metadata-only"
514
+ input.Name = "Update Metadata Only"
515
+
516
+ a, err := env.Addon.CreateAddon(ctx, input)
517
+ require.NoError(t, err)
518
+
519
+ tc := getFirstAddonRCTaxConfig(t, a)
520
+ require.NotNil(t, tc)
521
+ require.NotNil(t, tc.TaxCodeID)
522
+ originalTaxCodeID := *tc.TaxCodeID
523
+
524
+ // Update only addon name, no ratecards
525
+ updated, err := env.Addon.UpdateAddon(ctx, addon.UpdateAddonInput{
526
+ NamespacedID: a.NamespacedID,
527
+ Name: lo.ToPtr("Updated Name"),
528
+ })
529
+ require.NoError(t, err)
530
+
531
+ tc = getFirstAddonRCTaxConfig(t, updated)
532
+ require.NotNil(t, tc)
533
+ require.NotNil(t, tc.TaxCodeID)
534
+ assert.Equal(t, originalTaxCodeID, *tc.TaxCodeID, "TaxCodeID should be unchanged on metadata-only update")
535
+
536
+ assertAddonRCDBCols(t, ctx, env, updated.ID, features[0].Key, lo.ToPtr(originalTaxCodeID), nil)
537
+ })
538
+ })
539
+
540
+ t.Run("ReadBackVerification", func(t *testing.T) {
541
+ t.Run("BackfillFromNewColumns", func(t *testing.T) {
542
+ // Create addon with full TaxConfig (Stripe + Behavior)
543
+ input := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], &productcatalog.TaxConfig{
544
+ Behavior: lo.ToPtr(productcatalog.ExclusiveTaxBehavior),
545
+ Stripe: &productcatalog.StripeTaxConfig{Code: "txcd_13000001"},
546
+ }))
547
+ input.Key = "addon-readback-verify"
548
+ input.Name = "Readback Verify"
549
+
550
+ a, err := env.Addon.CreateAddon(ctx, input)
551
+ require.NoError(t, err)
552
+
553
+ // Re-read the addon
554
+ fetched, err := env.Addon.GetAddon(ctx, addon.GetAddonInput{
555
+ NamespacedID: a.NamespacedID,
556
+ })
557
+ require.NoError(t, err)
558
+
559
+ tc := getFirstAddonRCTaxConfig(t, fetched)
560
+ require.NotNil(t, tc)
561
+
562
+ // Behavior should be present
563
+ require.NotNil(t, tc.Behavior)
564
+ assert.Equal(t, productcatalog.ExclusiveTaxBehavior, *tc.Behavior)
565
+
566
+ // Stripe should be present
567
+ require.NotNil(t, tc.Stripe)
568
+ assert.Equal(t, "txcd_13000001", tc.Stripe.Code)
569
+
570
+ tcEntity13, err := findAddonTaxCodeByStripeCode(t, ctx, env.TaxCode, namespace, "txcd_13000001")
571
+ require.NoError(t, err)
572
+ assertAddonRCDBCols(t, ctx, env, fetched.ID, features[0].Key, lo.ToPtr(tcEntity13.ID), lo.ToPtr(productcatalog.ExclusiveTaxBehavior))
573
+ })
574
+ })
575
+ }
576
+
577
+ func TestAddonTaxCodeBackfill(t *testing.T) {
578
+ ctx, cancel := context.WithCancel(context.Background())
579
+ defer cancel()
580
+
581
+ env := pctestutils.NewTestEnv(t)
582
+ t.Cleanup(func() { env.Close(t) })
583
+ taxCodeEnv := taxcodetestutils.NewTestEnvFromClient(t, env.Client, nil)
584
+
585
+ namespace := pctestutils.NewTestNamespace(t)
586
+
587
+ // Setup meters and features
588
+ err := env.Meter.ReplaceMeters(ctx, pctestutils.NewTestMeters(t, namespace))
589
+ require.NoError(t, err)
590
+
591
+ result, err := env.Meter.ListMeters(ctx, meter.ListMetersParams{
592
+ Page: pagination.Page{PageSize: 1000, PageNumber: 1},
593
+ Namespace: namespace,
594
+ })
595
+ require.NoError(t, err)
596
+ require.NotEmpty(t, result.Items)
597
+
598
+ features := make([]feature.Feature, 0, len(result.Items))
599
+ for _, m := range result.Items {
600
+ feat, err := env.Feature.CreateFeature(ctx, pctestutils.NewTestFeatureFromMeter(t, &m))
601
+ require.NoError(t, err)
602
+ features = append(features, feat)
603
+ }
604
+
605
+ t.Run("BackfillFromDedicatedColumns", func(t *testing.T) {
606
+ // Create an addon via service to get an addon ID
607
+ input := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], nil))
608
+ input.Key = "backfill-addon-test"
609
+ input.Name = "Backfill Addon Test"
610
+
611
+ a, err := env.Addon.CreateAddon(ctx, input)
612
+ require.NoError(t, err)
613
+
614
+ tcEntity := taxCodeEnv.CreateTaxCode(t, namespace, taxcode.CreateTaxCodeInput{
615
+ Key: "stripe_txcd_99000002",
616
+ Name: "txcd_99000002",
617
+ AppMappings: taxcode.TaxCodeAppMappings{
618
+ {AppType: app.AppTypeStripe, TaxCode: "txcd_99000002"},
619
+ },
620
+ })
621
+
622
+ // Insert an AddonRateCard row directly — no tax_config JSONB, only dedicated columns
623
+ behavior := productcatalog.ExclusiveTaxBehavior
624
+ _, err = env.Client.AddonRateCard.Create().
625
+ SetAddonID(a.ID).
626
+ SetNamespace(namespace).
627
+ SetKey("backfill-rc").
628
+ SetType(productcatalog.FlatFeeRateCardType).
629
+ SetName("Backfill RC").
630
+ SetMetadata(map[string]string{}).
631
+ SetEntitlementTemplate(nil).
632
+ SetDiscounts(nil).
633
+ SetTaxCodeID(tcEntity.ID).
634
+ SetTaxBehavior(behavior).
635
+ Save(ctx)
636
+ require.NoError(t, err)
637
+
638
+ // Read via service — adapter must backfill TaxConfig from dedicated columns
639
+ fetched, err := env.Addon.GetAddon(ctx, addon.GetAddonInput{
640
+ NamespacedID: a.NamespacedID,
641
+ })
642
+ require.NoError(t, err)
643
+
644
+ var backfillRC *addon.RateCard
645
+ for i, rc := range fetched.RateCards {
646
+ if rc.AsMeta().Key == "backfill-rc" {
647
+ backfillRC = &fetched.RateCards[i]
648
+ break
649
+ }
650
+ }
651
+ require.NotNil(t, backfillRC, "backfill rate card must be present in addon")
652
+
653
+ tc := backfillRC.AsMeta().TaxConfig
654
+ require.NotNil(t, tc, "TaxConfig must be backfilled from dedicated columns")
655
+ require.NotNil(t, tc.Stripe, "Stripe code must be backfilled from TaxCode entity")
656
+ assert.Equal(t, "txcd_99000002", tc.Stripe.Code)
657
+ require.NotNil(t, tc.Behavior, "Behavior must be backfilled from tax_behavior column")
658
+ assert.Equal(t, productcatalog.ExclusiveTaxBehavior, *tc.Behavior)
659
+ require.NotNil(t, tc.TaxCodeID, "TaxCodeID must be backfilled from TaxCode entity")
660
+ assert.Equal(t, tcEntity.ID, *tc.TaxCodeID)
661
+ })
662
+
663
+ t.Run("BackfillTaxCodeOnly", func(t *testing.T) {
664
+ input := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], nil))
665
+ input.Key = "backfill-taxcode-only"
666
+ input.Name = "Backfill TaxCode Only"
667
+
668
+ a, err := env.Addon.CreateAddon(ctx, input)
669
+ require.NoError(t, err)
670
+
671
+ tcEntity := taxCodeEnv.CreateTaxCode(t, namespace, taxcode.CreateTaxCodeInput{
672
+ Key: "stripe_txcd_99000010",
673
+ Name: "txcd_99000010",
674
+ AppMappings: taxcode.TaxCodeAppMappings{
675
+ {AppType: app.AppTypeStripe, TaxCode: "txcd_99000010"},
676
+ },
677
+ })
678
+
679
+ // Only tax_code_id, no tax_behavior
680
+ _, err = env.Client.AddonRateCard.Create().
681
+ SetAddonID(a.ID).
682
+ SetNamespace(namespace).
683
+ SetKey("backfill-tc-only").
684
+ SetType(productcatalog.FlatFeeRateCardType).
685
+ SetName("Backfill TC Only").
686
+ SetMetadata(map[string]string{}).
687
+ SetEntitlementTemplate(nil).
688
+ SetDiscounts(nil).
689
+ SetTaxCodeID(tcEntity.ID).
690
+ Save(ctx)
691
+ require.NoError(t, err)
692
+
693
+ fetched, err := env.Addon.GetAddon(ctx, addon.GetAddonInput{NamespacedID: a.NamespacedID})
694
+ require.NoError(t, err)
695
+
696
+ var backfillRC *addon.RateCard
697
+ for i, rc := range fetched.RateCards {
698
+ if rc.AsMeta().Key == "backfill-tc-only" {
699
+ backfillRC = &fetched.RateCards[i]
700
+ break
701
+ }
702
+ }
703
+ require.NotNil(t, backfillRC)
704
+
705
+ tc := backfillRC.AsMeta().TaxConfig
706
+ require.NotNil(t, tc, "TaxConfig must be backfilled from TaxCode entity alone")
707
+ require.NotNil(t, tc.Stripe)
708
+ assert.Equal(t, "txcd_99000010", tc.Stripe.Code)
709
+ assert.Nil(t, tc.Behavior, "Behavior must be nil when tax_behavior column is not set")
710
+ require.NotNil(t, tc.TaxCodeID, "TaxCodeID must be backfilled from TaxCode entity")
711
+ assert.Equal(t, tcEntity.ID, *tc.TaxCodeID)
712
+ })
713
+
714
+ t.Run("BackfillBehaviorOnly", func(t *testing.T) {
715
+ input := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], nil))
716
+ input.Key = "backfill-behavior-only"
717
+ input.Name = "Backfill Behavior Only"
718
+
719
+ a, err := env.Addon.CreateAddon(ctx, input)
720
+ require.NoError(t, err)
721
+
722
+ // Only tax_behavior, no tax_code_id
723
+ behavior := productcatalog.InclusiveTaxBehavior
724
+ _, err = env.Client.AddonRateCard.Create().
725
+ SetAddonID(a.ID).
726
+ SetNamespace(namespace).
727
+ SetKey("backfill-beh-only").
728
+ SetType(productcatalog.FlatFeeRateCardType).
729
+ SetName("Backfill Behavior Only").
730
+ SetMetadata(map[string]string{}).
731
+ SetEntitlementTemplate(nil).
732
+ SetDiscounts(nil).
733
+ SetTaxBehavior(behavior).
734
+ Save(ctx)
735
+ require.NoError(t, err)
736
+
737
+ fetched, err := env.Addon.GetAddon(ctx, addon.GetAddonInput{NamespacedID: a.NamespacedID})
738
+ require.NoError(t, err)
739
+
740
+ var backfillRC *addon.RateCard
741
+ for i, rc := range fetched.RateCards {
742
+ if rc.AsMeta().Key == "backfill-beh-only" {
743
+ backfillRC = &fetched.RateCards[i]
744
+ break
745
+ }
746
+ }
747
+ require.NotNil(t, backfillRC)
748
+
749
+ tc := backfillRC.AsMeta().TaxConfig
750
+ require.NotNil(t, tc, "TaxConfig must be backfilled from tax_behavior column alone")
751
+ require.NotNil(t, tc.Behavior)
752
+ assert.Equal(t, productcatalog.InclusiveTaxBehavior, *tc.Behavior)
753
+ assert.Nil(t, tc.Stripe, "Stripe must be nil when no TaxCode entity is linked")
754
+ })
755
+ }
756
+
757
+ func TestAddonWithPlanTaxCode(t *testing.T) {
758
+ ctx, cancel := context.WithCancel(context.Background())
759
+ defer cancel()
760
+
761
+ env := pctestutils.NewTestEnv(t)
762
+ t.Cleanup(func() { env.Close(t) })
763
+ taxCodeEnv := taxcodetestutils.NewTestEnvFromClient(t, env.Client, nil)
764
+
765
+ namespace := pctestutils.NewTestNamespace(t)
766
+
767
+ err := env.Meter.ReplaceMeters(ctx, pctestutils.NewTestMeters(t, namespace))
768
+ require.NoError(t, err)
769
+
770
+ result, err := env.Meter.ListMeters(ctx, meter.ListMetersParams{
771
+ Page: pagination.Page{PageSize: 1000, PageNumber: 1},
772
+ Namespace: namespace,
773
+ })
774
+ require.NoError(t, err)
775
+ require.NotEmpty(t, result.Items)
776
+
777
+ features := make([]feature.Feature, 0, len(result.Items))
778
+ for _, m := range result.Items {
779
+ feat, err := env.Feature.CreateFeature(ctx, pctestutils.NewTestFeatureFromMeter(t, &m))
780
+ require.NoError(t, err)
781
+ features = append(features, feat)
782
+ }
783
+
784
+ t.Run("BackfillPlanRateCardInAddonResponse", func(t *testing.T) {
785
+ // Create and publish an addon so it can be attached to a plan.
786
+ addonInput := newTestAddonInput(t, namespace, newTestAddonFlatRateCard(features[0], nil))
787
+ addonInput.Key = "addon-with-plan-backfill"
788
+ addonInput.Name = "Addon With Plan Backfill"
789
+
790
+ a, err := env.Addon.CreateAddon(ctx, addonInput)
791
+ require.NoError(t, err)
792
+
793
+ publishAt := time.Now().Truncate(time.Microsecond)
794
+ a, err = env.Addon.PublishAddon(ctx, addon.PublishAddonInput{
795
+ NamespacedID: a.NamespacedID,
796
+ EffectivePeriod: productcatalog.EffectivePeriod{EffectiveFrom: &publishAt},
797
+ })
798
+ require.NoError(t, err)
799
+
800
+ // Create a plan. Rate card billing cadence must match the plan's P1M cadence.
801
+ planInput := pctestutils.NewTestPlan(t, namespace,
802
+ pctestutils.WithPlanPhases(productcatalog.Phase{
803
+ PhaseMeta: productcatalog.PhaseMeta{Key: "default", Name: "Default"},
804
+ RateCards: productcatalog.RateCards{
805
+ &productcatalog.FlatFeeRateCard{
806
+ RateCardMeta: productcatalog.RateCardMeta{
807
+ Key: features[0].Key,
808
+ Name: features[0].Name,
809
+ FeatureKey: lo.ToPtr(features[0].Key),
810
+ FeatureID: lo.ToPtr(features[0].ID),
811
+ Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
812
+ Amount: decimal.NewFromInt(100),
813
+ PaymentTerm: productcatalog.InArrearsPaymentTerm,
814
+ }),
815
+ },
816
+ BillingCadence: &pctestutils.MonthPeriod,
817
+ },
818
+ },
819
+ }),
820
+ func(t *testing.T, p *productcatalog.Plan) {
821
+ t.Helper()
822
+
823
+ p.Key = "plan-for-addon-backfill"
824
+ p.Name = "Plan For Addon Backfill"
825
+ },
826
+ )
827
+
828
+ p, err := env.Plan.CreatePlan(ctx, planInput)
829
+ require.NoError(t, err)
830
+ require.NotEmpty(t, p.Phases)
831
+
832
+ // Attach the plan to the addon.
833
+ _, err = env.PlanAddon.CreatePlanAddon(ctx, planaddon.CreatePlanAddonInput{
834
+ NamespacedModel: models.NamespacedModel{Namespace: namespace},
835
+ PlanID: p.ID,
836
+ AddonID: a.ID,
837
+ FromPlanPhase: p.Phases[0].Key,
838
+ })
839
+ require.NoError(t, err)
840
+
841
+ phaseID := p.Phases[0].PhaseManagedFields.NamespacedID.ID
842
+
843
+ tcEntity := taxCodeEnv.CreateTaxCode(t, namespace, taxcode.CreateTaxCodeInput{
844
+ Key: "stripe_txcd_99000020",
845
+ Name: "txcd_99000020",
846
+ AppMappings: taxcode.TaxCodeAppMappings{
847
+ {AppType: app.AppTypeStripe, TaxCode: "txcd_99000020"},
848
+ },
849
+ })
850
+
851
+ // Insert a PlanRateCard row directly with only dedicated tax columns — no tax_config JSONB.
852
+ // This simulates the legacy schema where TaxConfig is stored in separate columns rather than JSONB.
853
+ behavior := productcatalog.ExclusiveTaxBehavior
854
+ _, err = env.Client.PlanRateCard.Create().
855
+ SetPhaseID(phaseID).
856
+ SetNamespace(namespace).
857
+ SetKey("backfill-plan-rc").
858
+ SetType(productcatalog.FlatFeeRateCardType).
859
+ SetName("Backfill Plan RC").
860
+ SetMetadata(map[string]string{}).
861
+ SetEntitlementTemplate(nil).
862
+ SetDiscounts(nil).
863
+ SetTaxCodeID(tcEntity.ID).
864
+ SetTaxBehavior(behavior).
865
+ Save(ctx)
866
+ require.NoError(t, err)
867
+
868
+ // Fetch the addon with plans expanded. Plan rate cards are mapped through
869
+ // addon/adapter.FromPlanRateCardRow — the path fixed by the backfill change.
870
+ fetched, err := env.Addon.GetAddon(ctx, addon.GetAddonInput{
871
+ NamespacedID: a.NamespacedID,
872
+ Expand: addon.ExpandFields{PlanAddons: true},
873
+ })
874
+ require.NoError(t, err)
875
+ require.NotNil(t, fetched.Plans, "Plans must be expanded in addon response")
876
+ require.Len(t, *fetched.Plans, 1)
877
+
878
+ planInAddon := (*fetched.Plans)[0]
879
+ require.NotEmpty(t, planInAddon.Phases, "plan phases must be present in addon response")
880
+
881
+ var backfillRC productcatalog.RateCard
882
+ for _, rc := range planInAddon.Phases[0].RateCards {
883
+ if rc.AsMeta().Key == "backfill-plan-rc" {
884
+ backfillRC = rc
885
+ break
886
+ }
887
+ }
888
+ require.NotNil(t, backfillRC, "backfill plan rate card must be present in addon response")
889
+
890
+ tc := backfillRC.AsMeta().TaxConfig
891
+ require.NotNil(t, tc, "TaxConfig must be backfilled from dedicated columns via addon adapter path")
892
+ require.NotNil(t, tc.Stripe, "Stripe code must be backfilled from TaxCode entity")
893
+ assert.Equal(t, "txcd_99000020", tc.Stripe.Code)
894
+ require.NotNil(t, tc.Behavior, "Behavior must be backfilled from tax_behavior column")
895
+ assert.Equal(t, productcatalog.ExclusiveTaxBehavior, *tc.Behavior)
896
+ require.NotNil(t, tc.TaxCodeID, "TaxCodeID must be backfilled from TaxCode entity")
897
+ assert.Equal(t, tcEntity.ID, *tc.TaxCodeID)
898
+ })
899
+ }
openmeter/productcatalog/addon/validators.go ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package addon
2
+
3
+ import (
4
+ "fmt"
5
+ "time"
6
+
7
+ "github.com/samber/lo"
8
+
9
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
10
+ "github.com/openmeterio/openmeter/pkg/models"
11
+ )
12
+
13
+ func IsAddonDeleted(at time.Time) models.ValidatorFunc[Addon] {
14
+ return func(a Addon) error {
15
+ if a.IsDeleted() {
16
+ return fmt.Errorf("add-on is deleted [deleted_at=%s]", *a.DeletedAt)
17
+ }
18
+
19
+ return nil
20
+ }
21
+ }
22
+
23
+ func HasAddonStatus(statuses ...productcatalog.AddonStatus) models.ValidatorFunc[Addon] {
24
+ return func(a Addon) error {
25
+ if !lo.Contains(statuses, a.Status()) {
26
+ return fmt.Errorf("invalid %s status, allowed statuses: %+v", a.Status(), statuses)
27
+ }
28
+
29
+ return nil
30
+ }
31
+ }
openmeter/productcatalog/alignment.go ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package productcatalog
2
+
3
+ import (
4
+ "github.com/openmeterio/openmeter/pkg/datetime"
5
+ )
6
+
7
+ // Alignment means that either
8
+ // - the two cadences are identical
9
+ // - if a RateCard's cadence is "longer" than the Plan's cadence, the plan cadence must "divide" without remainder the ratecard's cadence
10
+ // - if a RateCard's cadence is "shorter" than the Plan's cadence, the ratecard's cadence must "divide" without remainder the plan's cadence
11
+ // "longer" and "shorter" are not generally meaningful terms for periods, as for instance sometimes P1M equals P4W, sometimes its longer.
12
+ func ValidateBillingCadencesAlign(planBillingCadence datetime.ISODuration, rateCardBillingCadence datetime.ISODuration) error {
13
+ pSimple := planBillingCadence.Simplify(true)
14
+ rcSimple := rateCardBillingCadence.Simplify(true)
15
+
16
+ // If the two cadences are identical, we're good
17
+ if pSimple.Equal(&rcSimple) {
18
+ return nil
19
+ }
20
+
21
+ // We'll leverage the fact that Period.DibisibleBy() works correctly regardless which period is larger,
22
+ // so we'll just test both ways
23
+
24
+ ok, err := pSimple.DivisibleBy(rcSimple)
25
+ if ok && err == nil {
26
+ return nil
27
+ }
28
+
29
+ ok, err = rcSimple.DivisibleBy(pSimple)
30
+ if ok && err == nil {
31
+ return nil
32
+ }
33
+
34
+ return ErrRateCardBillingCadenceUnaligned
35
+ }
openmeter/productcatalog/discount.go ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package productcatalog
2
+
3
+ import (
4
+ "errors"
5
+
6
+ decimal "github.com/alpacahq/alpacadecimal"
7
+ "github.com/samber/lo"
8
+
9
+ "github.com/openmeterio/openmeter/pkg/equal"
10
+ "github.com/openmeterio/openmeter/pkg/hasher"
11
+ "github.com/openmeterio/openmeter/pkg/models"
12
+ )
13
+
14
+ var (
15
+ _ models.Validator = (*PercentageDiscount)(nil)
16
+ _ hasher.Hasher = (*PercentageDiscount)(nil)
17
+ _ models.Clonable[PercentageDiscount] = (*PercentageDiscount)(nil)
18
+ _ models.CustomValidator[PercentageDiscount] = (*PercentageDiscount)(nil)
19
+ )
20
+
21
+ type PercentageDiscount struct {
22
+ // Percentage defines percentage of the discount.
23
+ Percentage models.Percentage `json:"percentage"`
24
+ }
25
+
26
+ func (d PercentageDiscount) Hash() hasher.Hash {
27
+ var content string
28
+
29
+ content += d.Percentage.String()
30
+
31
+ return hasher.NewHash([]byte(content))
32
+ }
33
+
34
+ func (d PercentageDiscount) ValidateWith(v ...models.ValidatorFunc[PercentageDiscount]) error {
35
+ return models.Validate(d, v...)
36
+ }
37
+
38
+ func PercentageDiscountWithValidValue() models.ValidatorFunc[PercentageDiscount] {
39
+ return func(d PercentageDiscount) error {
40
+ if d.Percentage.LessThan(decimal.Zero) || d.Percentage.GreaterThan(decimal.NewFromInt(100)) {
41
+ return ErrPercentageDiscountInvalidValue
42
+ }
43
+
44
+ return nil
45
+ }
46
+ }
47
+
48
+ func (d PercentageDiscount) Validate() error {
49
+ return d.ValidateWith(PercentageDiscountWithValidValue())
50
+ }
51
+
52
+ func (d PercentageDiscount) ValidateForPrice(_ *Price) error {
53
+ return d.Validate()
54
+ }
55
+
56
+ func (d PercentageDiscount) Clone() PercentageDiscount {
57
+ return PercentageDiscount{
58
+ Percentage: d.Percentage,
59
+ }
60
+ }
61
+
62
+ var (
63
+ _ models.Validator = (*UsageDiscount)(nil)
64
+ _ hasher.Hasher = (*UsageDiscount)(nil)
65
+ _ models.Clonable[UsageDiscount] = (*UsageDiscount)(nil)
66
+ _ models.CustomValidator[UsageDiscount] = (*UsageDiscount)(nil)
67
+ )
68
+
69
+ type UsageDiscount struct {
70
+ Quantity decimal.Decimal `json:"quantity"`
71
+ }
72
+
73
+ func (d UsageDiscount) Hash() hasher.Hash {
74
+ var content string
75
+
76
+ content += d.Quantity.String()
77
+
78
+ return hasher.NewHash([]byte(content))
79
+ }
80
+
81
+ func (d UsageDiscount) ValidateWith(v ...models.ValidatorFunc[UsageDiscount]) error {
82
+ return models.Validate(d, v...)
83
+ }
84
+
85
+ func UsageDiscountWithValidValue() models.ValidatorFunc[UsageDiscount] {
86
+ return func(d UsageDiscount) error {
87
+ if d.Quantity.LessThan(decimal.Zero) {
88
+ return ErrUsageDiscountNegativeQuantity
89
+ }
90
+
91
+ return nil
92
+ }
93
+ }
94
+
95
+ func UsageDiscountWithPrice(price *Price) models.ValidatorFunc[UsageDiscount] {
96
+ return func(d UsageDiscount) error {
97
+ var errs []error
98
+
99
+ if price == nil {
100
+ // We cannot validate usage discount without a price.
101
+ return errors.New("price is required for usage discount")
102
+ }
103
+
104
+ if err := d.Validate(); err != nil {
105
+ errs = append(errs, err)
106
+ }
107
+
108
+ if price.Type() == FlatPriceType {
109
+ errs = append(errs, ErrUsageDiscountWithFlatPrice)
110
+ }
111
+
112
+ return errors.Join(errs...)
113
+ }
114
+ }
115
+
116
+ func (d UsageDiscount) Validate() error {
117
+ return d.ValidateWith(UsageDiscountWithValidValue())
118
+ }
119
+
120
+ func (d UsageDiscount) ValidateForPrice(price *Price) error {
121
+ return d.ValidateWith(UsageDiscountWithPrice(price))
122
+ }
123
+
124
+ func (d UsageDiscount) Clone() UsageDiscount {
125
+ return UsageDiscount{
126
+ Quantity: d.Quantity,
127
+ }
128
+ }
129
+
130
+ var (
131
+ _ models.Equaler[Discounts] = (*Discounts)(nil)
132
+ _ models.Clonable[Discounts] = (*Discounts)(nil)
133
+ _ models.Validator = (*Discounts)(nil)
134
+ )
135
+
136
+ type Discounts struct {
137
+ Percentage *PercentageDiscount `json:"percentage,omitempty"`
138
+ Usage *UsageDiscount `json:"usage,omitempty"`
139
+ }
140
+
141
+ func (d Discounts) Equal(v Discounts) bool {
142
+ if !equal.HasherPtrEqual(d.Percentage, v.Percentage) {
143
+ return false
144
+ }
145
+
146
+ if !equal.HasherPtrEqual(d.Usage, v.Usage) {
147
+ return false
148
+ }
149
+
150
+ return true
151
+ }
152
+
153
+ func (d Discounts) Clone() Discounts {
154
+ out := Discounts{}
155
+
156
+ if d.Percentage != nil {
157
+ out.Percentage = lo.ToPtr(d.Percentage.Clone())
158
+ }
159
+
160
+ if d.Usage != nil {
161
+ out.Usage = lo.ToPtr(d.Usage.Clone())
162
+ }
163
+
164
+ return out
165
+ }
166
+
167
+ func (d *Discounts) Validate() error {
168
+ var errs []error
169
+
170
+ if d == nil {
171
+ return nil
172
+ }
173
+
174
+ if d.Percentage != nil {
175
+ if err := d.Percentage.Validate(); err != nil {
176
+ errs = append(errs, models.ErrorWithFieldPrefix(
177
+ models.NewFieldSelectorGroup(models.NewFieldSelector("percentage")),
178
+ err),
179
+ )
180
+ }
181
+ }
182
+
183
+ if d.Usage != nil {
184
+ if err := d.Usage.Validate(); err != nil {
185
+ errs = append(errs, models.ErrorWithFieldPrefix(
186
+ models.NewFieldSelectorGroup(models.NewFieldSelector("usage")),
187
+ err),
188
+ )
189
+ }
190
+ }
191
+
192
+ if err := errors.Join(errs...); err != nil {
193
+ return models.NewGenericValidationError(models.ErrorWithFieldPrefix(
194
+ models.NewFieldSelectorGroup(models.NewFieldSelector("discounts")),
195
+ err),
196
+ )
197
+ }
198
+
199
+ return nil
200
+ }
201
+
202
+ func (d Discounts) ValidateForPrice(price *Price) error {
203
+ var errs []error
204
+
205
+ if !d.IsEmpty() && price == nil {
206
+ return errors.New("price is required for discounts")
207
+ }
208
+
209
+ if d.Percentage != nil {
210
+ if err := d.Percentage.ValidateForPrice(price); err != nil {
211
+ errs = append(errs, models.ErrorWithFieldPrefix(
212
+ models.NewFieldSelectorGroup(models.NewFieldSelector("percentage")),
213
+ err),
214
+ )
215
+ }
216
+ }
217
+
218
+ if d.Usage != nil {
219
+ if err := d.Usage.ValidateForPrice(price); err != nil {
220
+ errs = append(errs, models.ErrorWithFieldPrefix(
221
+ models.NewFieldSelectorGroup(models.NewFieldSelector("usage")),
222
+ err),
223
+ )
224
+ }
225
+ }
226
+
227
+ if err := errors.Join(errs...); err != nil {
228
+ return models.NewGenericValidationError(models.ErrorWithFieldPrefix(
229
+ models.NewFieldSelectorGroup(models.NewFieldSelector("discounts")),
230
+ err),
231
+ )
232
+ }
233
+
234
+ return nil
235
+ }
236
+
237
+ func (d Discounts) IsEmpty() bool {
238
+ return lo.IsEmpty(d)
239
+ }
openmeter/productcatalog/discount_test.go ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package productcatalog
2
+
3
+ import (
4
+ "testing"
5
+
6
+ decimal "github.com/alpacahq/alpacadecimal"
7
+ json "github.com/json-iterator/go"
8
+ "github.com/stretchr/testify/assert"
9
+ "github.com/stretchr/testify/require"
10
+
11
+ "github.com/openmeterio/openmeter/pkg/models"
12
+ )
13
+
14
+ func TestDiscount_JSON(t *testing.T) {
15
+ tests := []struct {
16
+ Name string
17
+ Discounts Discounts
18
+ ExpectedError bool
19
+ }{
20
+ {
21
+ Name: "Valid - percentage",
22
+ Discounts: Discounts{
23
+ Percentage: &PercentageDiscount{
24
+ Percentage: models.NewPercentage(99.9),
25
+ },
26
+ },
27
+ },
28
+ {
29
+ Name: "Valid - usage",
30
+ Discounts: Discounts{
31
+ Usage: &UsageDiscount{
32
+ Quantity: decimal.NewFromInt(100),
33
+ },
34
+ },
35
+ },
36
+ }
37
+
38
+ for _, test := range tests {
39
+ t.Run(test.Name, func(t *testing.T) {
40
+ b, err := json.Marshal(&test.Discounts)
41
+ require.NoError(t, err)
42
+
43
+ t.Logf("Serialized Discount: %s", string(b))
44
+
45
+ d := Discounts{}
46
+ err = json.Unmarshal(b, &d)
47
+ require.NoError(t, err)
48
+
49
+ assert.Equal(t, test.Discounts, d)
50
+ })
51
+ }
52
+ }
53
+
54
+ func TestDiscountsEqual(t *testing.T) {
55
+ tests := []struct {
56
+ Name string
57
+
58
+ Left Discounts
59
+ Right Discounts
60
+
61
+ ExpectedResult bool
62
+ }{
63
+ {
64
+ Name: "Equal",
65
+ Left: Discounts{
66
+ Percentage: &PercentageDiscount{
67
+ Percentage: models.NewPercentage(100),
68
+ },
69
+ },
70
+ Right: Discounts{
71
+ Percentage: &PercentageDiscount{
72
+ Percentage: models.NewPercentage(100),
73
+ },
74
+ },
75
+ ExpectedResult: true,
76
+ },
77
+ {
78
+ Name: "Diff",
79
+ Left: Discounts{
80
+ Percentage: &PercentageDiscount{
81
+ Percentage: models.NewPercentage(100),
82
+ },
83
+ },
84
+ Right: Discounts{
85
+ Usage: &UsageDiscount{
86
+ Quantity: decimal.NewFromInt(100),
87
+ },
88
+ },
89
+ ExpectedResult: false,
90
+ },
91
+ }
92
+
93
+ for _, test := range tests {
94
+ t.Run(test.Name, func(t *testing.T) {
95
+ match := test.Left.Equal(test.Right)
96
+ assert.Equal(t, test.ExpectedResult, match)
97
+ })
98
+ }
99
+ }
100
+
101
+ func TestDiscountsValidateForPrice(t *testing.T) {
102
+ tests := []struct {
103
+ Name string
104
+
105
+ Discounts Discounts
106
+
107
+ ExpectedError bool
108
+ }{
109
+ {
110
+ Name: "Valid",
111
+ Discounts: Discounts{
112
+ Percentage: &PercentageDiscount{
113
+ Percentage: models.NewPercentage(50),
114
+ },
115
+ },
116
+ ExpectedError: false,
117
+ },
118
+ {
119
+ Name: "Invalid - more than 100% percentage discount",
120
+ Discounts: Discounts{
121
+ Percentage: &PercentageDiscount{
122
+ Percentage: models.NewPercentage(110),
123
+ },
124
+ },
125
+ ExpectedError: true,
126
+ },
127
+ {
128
+ Name: "Valid - usage",
129
+ Discounts: Discounts{
130
+ Usage: &UsageDiscount{
131
+ Quantity: decimal.NewFromInt(100),
132
+ },
133
+ },
134
+ ExpectedError: false,
135
+ },
136
+ {
137
+ Name: "Invalid - usage - negative",
138
+ Discounts: Discounts{
139
+ Usage: &UsageDiscount{
140
+ Quantity: decimal.NewFromInt(-100),
141
+ },
142
+ },
143
+ ExpectedError: true,
144
+ },
145
+ }
146
+
147
+ for _, test := range tests {
148
+ t.Run(test.Name, func(t *testing.T) {
149
+ err := test.Discounts.ValidateForPrice(NewPriceFrom(
150
+ UnitPrice{
151
+ Amount: decimal.NewFromInt(100),
152
+ },
153
+ ))
154
+ if test.ExpectedError {
155
+ require.Error(t, err)
156
+ } else {
157
+ require.NoError(t, err)
158
+ }
159
+ })
160
+ }
161
+ }
openmeter/productcatalog/driver/errors.go ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package productcatalogdriver
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ "github.com/openmeterio/openmeter/openmeter/meter"
8
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
9
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
10
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport/encoder"
11
+ "github.com/openmeterio/openmeter/pkg/pagination"
12
+ )
13
+
14
+ func getErrorEncoder() encoder.ErrorEncoder {
15
+ return func(ctx context.Context, err error, w http.ResponseWriter, r *http.Request) bool {
16
+ return commonhttp.HandleErrorIfTypeMatches[*feature.FeatureNotFoundError](ctx, http.StatusNotFound, err, w) ||
17
+ commonhttp.HandleErrorIfTypeMatches[*feature.FeatureInvalidFiltersError](ctx, http.StatusBadRequest, err, w) ||
18
+ commonhttp.HandleErrorIfTypeMatches[*feature.ForbiddenError](ctx, http.StatusBadRequest, err, w) ||
19
+ commonhttp.HandleErrorIfTypeMatches[*pagination.InvalidError](ctx, http.StatusBadRequest, err, w) ||
20
+ commonhttp.HandleErrorIfTypeMatches[*feature.FeatureInvalidMeterAggregationError](ctx, http.StatusBadRequest, err, w) ||
21
+ commonhttp.HandleErrorIfTypeMatches[*meter.MeterNotFoundError](ctx, http.StatusNotFound, err, w) ||
22
+ commonhttp.HandleErrorIfTypeMatches[*feature.FeatureWithNameAlreadyExistsError](ctx, http.StatusConflict, err, w)
23
+ }
24
+ }
openmeter/productcatalog/driver/feature.go ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package productcatalogdriver
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "net/http"
8
+
9
+ "github.com/samber/lo"
10
+
11
+ "github.com/openmeterio/openmeter/api"
12
+ "github.com/openmeterio/openmeter/openmeter/llmcost"
13
+ "github.com/openmeterio/openmeter/openmeter/meter"
14
+ "github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver"
15
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
16
+ "github.com/openmeterio/openmeter/pkg/convert"
17
+ "github.com/openmeterio/openmeter/pkg/defaultx"
18
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
19
+ "github.com/openmeterio/openmeter/pkg/framework/operation"
20
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
21
+ "github.com/openmeterio/openmeter/pkg/models"
22
+ "github.com/openmeterio/openmeter/pkg/pagination"
23
+ "github.com/openmeterio/openmeter/pkg/sortx"
24
+ "github.com/openmeterio/openmeter/pkg/strcase"
25
+ )
26
+
27
+ type FeatureHandler interface {
28
+ GetFeature() GetFeatureHandler
29
+ CreateFeature() CreateFeatureHandler
30
+ ListFeatures() ListFeaturesHandler
31
+ DeleteFeature() DeleteFeatureHandler
32
+ }
33
+
34
+ type featureHandlers struct {
35
+ namespaceDecoder namespacedriver.NamespaceDecoder
36
+ options []httptransport.HandlerOption
37
+ connector feature.FeatureConnector
38
+ meterService meter.Service
39
+ llmcostService llmcost.Service
40
+ }
41
+
42
+ func NewFeatureHandler(
43
+ connector feature.FeatureConnector,
44
+ namespaceDecoder namespacedriver.NamespaceDecoder,
45
+ meterService meter.Service,
46
+ llmcostService llmcost.Service,
47
+ options ...httptransport.HandlerOption,
48
+ ) FeatureHandler {
49
+ return &featureHandlers{
50
+ namespaceDecoder: namespaceDecoder,
51
+ options: options,
52
+ connector: connector,
53
+ meterService: meterService,
54
+ llmcostService: llmcostService,
55
+ }
56
+ }
57
+
58
+ type (
59
+ GetFeatureHandlerRequest = models.NamespacedID
60
+ GetFeatureHandlerResponse = api.Feature
61
+ GetFeatureHandlerParams = string
62
+ )
63
+
64
+ type GetFeatureHandler httptransport.HandlerWithArgs[GetFeatureHandlerRequest, GetFeatureHandlerResponse, GetFeatureHandlerParams]
65
+
66
+ func (h *featureHandlers) GetFeature() GetFeatureHandler {
67
+ return httptransport.NewHandlerWithArgs(
68
+ func(ctx context.Context, r *http.Request, featureID string) (GetFeatureHandlerRequest, error) {
69
+ ns, err := h.resolveNamespace(ctx)
70
+ if err != nil {
71
+ return models.NamespacedID{}, err
72
+ }
73
+
74
+ return models.NamespacedID{
75
+ Namespace: ns,
76
+ ID: featureID,
77
+ }, nil
78
+ },
79
+ func(ctx context.Context, featureId GetFeatureHandlerRequest) (GetFeatureHandlerResponse, error) {
80
+ feat, err := h.connector.GetFeature(ctx, featureId.Namespace, featureId.ID, feature.IncludeArchivedFeatureFalse)
81
+ if err != nil {
82
+ return api.Feature{}, err
83
+ }
84
+
85
+ resp, err := MapFeatureToResponse(*feat)
86
+ if err != nil {
87
+ return api.Feature{}, err
88
+ }
89
+
90
+ // Resolve LLM pricing if the feature has LLM unit cost
91
+ if feat.UnitCost != nil && feat.UnitCost.Type == feature.UnitCostTypeLLM && h.llmcostService != nil {
92
+ pricing := resolveLLMPricing(ctx, h.llmcostService, feat)
93
+ if pricing != nil {
94
+ enrichFeatureResponseWithPricing(&resp, pricing)
95
+ }
96
+ }
97
+
98
+ return resp, nil
99
+ },
100
+ commonhttp.JSONResponseEncoder,
101
+ httptransport.AppendOptions(
102
+ h.options,
103
+ httptransport.WithErrorEncoder(getErrorEncoder()),
104
+ httptransport.WithOperationName("getFeature"),
105
+ )...,
106
+ )
107
+ }
108
+
109
+ type (
110
+ CreateFeatureHandlerRequest = feature.CreateFeatureInputs
111
+ CreateFeatureHandlerResponse = api.Feature
112
+ )
113
+
114
+ type CreateFeatureHandler httptransport.Handler[CreateFeatureHandlerRequest, CreateFeatureHandlerResponse]
115
+
116
+ func (h *featureHandlers) CreateFeature() CreateFeatureHandler {
117
+ return httptransport.NewHandler(
118
+ func(ctx context.Context, r *http.Request) (feature.CreateFeatureInputs, error) {
119
+ parsedBody := api.CreateFeatureJSONRequestBody{}
120
+ emptyFeature := feature.CreateFeatureInputs{}
121
+ if err := commonhttp.JSONRequestBodyDecoder(r, &parsedBody); err != nil {
122
+ return emptyFeature, err
123
+ }
124
+
125
+ ns, err := h.resolveNamespace(ctx)
126
+ if err != nil {
127
+ return emptyFeature, err
128
+ }
129
+
130
+ // Resolve meter slug to meter ID
131
+ var meterID *string
132
+ if parsedBody.MeterSlug != nil {
133
+ m, err := h.meterService.GetMeterByIDOrSlug(ctx, meter.GetMeterInput{
134
+ Namespace: ns,
135
+ IDOrSlug: *parsedBody.MeterSlug,
136
+ })
137
+ if err != nil {
138
+ return emptyFeature, err
139
+ }
140
+ meterID = &m.ID
141
+ }
142
+
143
+ return MapFeatureCreateInputsRequest(ns, parsedBody, meterID)
144
+ },
145
+ func(ctx context.Context, feature feature.CreateFeatureInputs) (api.Feature, error) {
146
+ createdFeature, err := h.connector.CreateFeature(ctx, feature)
147
+ if err != nil {
148
+ return api.Feature{}, err
149
+ }
150
+ return MapFeatureToResponse(createdFeature)
151
+ },
152
+ commonhttp.JSONResponseEncoderWithStatus[api.Feature](http.StatusCreated),
153
+ httptransport.AppendOptions(
154
+ h.options,
155
+ httptransport.WithOperationName("createFeature"),
156
+ httptransport.WithErrorEncoder(getErrorEncoder()),
157
+ )...,
158
+ )
159
+ }
160
+
161
+ type (
162
+ ListFeaturesHandlerRequest = feature.ListFeaturesParams
163
+ ListFeaturesHandlerResponse = commonhttp.Union[[]api.Feature, pagination.Result[api.Feature]]
164
+ ListFeaturesHandlerParams = api.ListFeaturesParams
165
+ )
166
+
167
+ type ListFeaturesHandler httptransport.HandlerWithArgs[ListFeaturesHandlerRequest, ListFeaturesHandlerResponse, ListFeaturesHandlerParams]
168
+
169
+ func (h *featureHandlers) ListFeatures() ListFeaturesHandler {
170
+ return httptransport.NewHandlerWithArgs(
171
+ func(ctx context.Context, r *http.Request, apiParams ListFeaturesHandlerParams) (ListFeaturesHandlerRequest, error) {
172
+ ns, err := h.resolveNamespace(ctx)
173
+ if err != nil {
174
+ return feature.ListFeaturesParams{}, err
175
+ }
176
+
177
+ params := feature.ListFeaturesParams{
178
+ Namespace: ns,
179
+ IncludeArchived: defaultx.WithDefault(apiParams.IncludeArchived, false),
180
+ Page: pagination.Page{
181
+ PageSize: defaultx.WithDefault(apiParams.PageSize, 0),
182
+ PageNumber: defaultx.WithDefault(apiParams.Page, 0),
183
+ },
184
+ Limit: defaultx.WithDefault(apiParams.Limit, commonhttp.DefaultPageSize),
185
+ Offset: defaultx.WithDefault(apiParams.Offset, 0),
186
+ OrderBy: feature.FeatureOrderBy(
187
+ // Go enum value has a snake_case name, so we need to convert it
188
+ strcase.CamelToSnake(string(lo.FromPtrOr(apiParams.OrderBy, api.FeatureOrderByKey))),
189
+ ),
190
+ Order: sortx.Order(lo.FromPtrOr(apiParams.Order, api.SortOrderASC)),
191
+ MeterSlugs: convert.DerefHeaderPtr[string](apiParams.MeterSlug),
192
+ }
193
+
194
+ if !params.Page.IsZero() {
195
+ params.Page.PageNumber = defaultx.IfZero(params.Page.PageNumber, commonhttp.DefaultPage)
196
+ params.Page.PageSize = defaultx.IfZero(params.Page.PageSize, commonhttp.DefaultPageSize)
197
+ }
198
+
199
+ // TODO: standardize
200
+ if params.Page.PageSize > 1000 {
201
+ return params, commonhttp.NewHTTPError(
202
+ http.StatusBadRequest,
203
+ fmt.Errorf("limit must be less than or equal to %d", 1000),
204
+ )
205
+ }
206
+
207
+ return params, nil
208
+ },
209
+ func(ctx context.Context, params ListFeaturesHandlerRequest) (ListFeaturesHandlerResponse, error) {
210
+ response := ListFeaturesHandlerResponse{
211
+ Option1: &[]api.Feature{},
212
+ Option2: &pagination.Result[api.Feature]{},
213
+ }
214
+
215
+ paged, err := h.connector.ListFeatures(ctx, params)
216
+ if err != nil {
217
+ return response, err
218
+ }
219
+
220
+ mapped := make([]api.Feature, 0, len(paged.Items))
221
+ for _, f := range paged.Items {
222
+ resp, err := MapFeatureToResponse(f)
223
+ if err != nil {
224
+ return response, err
225
+ }
226
+ mapped = append(mapped, resp)
227
+ }
228
+
229
+ if params.Page.IsZero() {
230
+ response.Option1 = &mapped
231
+ } else {
232
+ response.Option1 = nil
233
+ response.Option2 = &pagination.Result[api.Feature]{
234
+ Items: mapped,
235
+ TotalCount: paged.TotalCount,
236
+ Page: paged.Page,
237
+ }
238
+ }
239
+
240
+ return response, err
241
+ },
242
+ commonhttp.JSONResponseEncoder,
243
+ httptransport.AppendOptions(
244
+ h.options,
245
+ httptransport.WithOperationName("listFeatures"),
246
+ )...,
247
+ )
248
+ }
249
+
250
+ type (
251
+ DeleteFeatureHandlerRequest = models.NamespacedID
252
+ DeleteFeatureHandlerResponse = interface{}
253
+ DeleteFeatureHandlerParams = string
254
+ )
255
+
256
+ type DeleteFeatureHandler httptransport.HandlerWithArgs[DeleteFeatureHandlerRequest, DeleteFeatureHandlerResponse, DeleteFeatureHandlerParams]
257
+
258
+ func (h *featureHandlers) DeleteFeature() DeleteFeatureHandler {
259
+ return httptransport.NewHandlerWithArgs(
260
+ func(ctx context.Context, r *http.Request, featureID DeleteFeatureHandlerParams) (DeleteFeatureHandlerRequest, error) {
261
+ id := models.NamespacedID{
262
+ ID: featureID,
263
+ }
264
+
265
+ ns, err := h.resolveNamespace(ctx)
266
+ if err != nil {
267
+ return id, err
268
+ }
269
+
270
+ id.Namespace = ns
271
+
272
+ return id, nil
273
+ },
274
+ operation.AsNoResponseOperation(h.connector.ArchiveFeature),
275
+ commonhttp.EmptyResponseEncoder[DeleteFeatureHandlerResponse](http.StatusNoContent),
276
+ httptransport.AppendOptions(
277
+ h.options,
278
+ httptransport.WithOperationName("deleteFeature"),
279
+ httptransport.WithErrorEncoder(getErrorEncoder()),
280
+ )...,
281
+ )
282
+ }
283
+
284
+ func (h *featureHandlers) resolveNamespace(ctx context.Context) (string, error) {
285
+ ns, ok := h.namespaceDecoder.GetNamespace(ctx)
286
+ if !ok {
287
+ return "", commonhttp.NewHTTPError(http.StatusInternalServerError, errors.New("internal server error"))
288
+ }
289
+
290
+ return ns, nil
291
+ }
openmeter/productcatalog/driver/parser.go ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package productcatalogdriver
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+
7
+ "github.com/alpacahq/alpacadecimal"
8
+ "github.com/samber/lo"
9
+
10
+ "github.com/openmeterio/openmeter/api"
11
+ "github.com/openmeterio/openmeter/openmeter/apiconverter"
12
+ "github.com/openmeterio/openmeter/openmeter/llmcost"
13
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
14
+ "github.com/openmeterio/openmeter/pkg/convert"
15
+ "github.com/openmeterio/openmeter/pkg/filter"
16
+ )
17
+
18
+ func MapFeatureToResponse(f feature.Feature) (api.Feature, error) {
19
+ meterGroupByFilters := feature.ConvertMeterGroupByFiltersToMapString(f.MeterGroupByFilters)
20
+
21
+ resp := api.Feature{
22
+ CreatedAt: f.CreatedAt,
23
+ DeletedAt: nil,
24
+ UpdatedAt: f.UpdatedAt,
25
+ Id: f.ID,
26
+ Key: f.Key,
27
+ Metadata: convert.MapToPointer(f.Metadata),
28
+ Name: f.Name,
29
+ ArchivedAt: f.ArchivedAt,
30
+ MeterGroupByFilters: convert.MapToPointer(meterGroupByFilters),
31
+ AdvancedMeterGroupByFilters: convert.MapToPointer(apiconverter.ConvertStringMapToAPIPtr(f.MeterGroupByFilters)),
32
+ MeterSlug: f.MeterSlug,
33
+ }
34
+
35
+ if f.UnitCost != nil {
36
+ apiUnitCost, err := domainUnitCostToAPI(f.UnitCost)
37
+ if err != nil {
38
+ return api.Feature{}, fmt.Errorf("failed to convert unit cost: %w", err)
39
+ }
40
+ resp.UnitCost = &apiUnitCost
41
+ }
42
+
43
+ return resp, nil
44
+ }
45
+
46
+ func MapFeatureCreateInputsRequest(namespace string, f api.FeatureCreateInputs, meterID *string) (feature.CreateFeatureInputs, error) {
47
+ // if advancedMeterGroupByFilters is set, use it
48
+ // otherwise, use legacy meterGroupByFilters
49
+ meterGroupByFilters := lo.FromPtrOr(apiconverter.ConvertStringMapPtr(f.AdvancedMeterGroupByFilters), map[string]filter.FilterString{})
50
+ if len(meterGroupByFilters) == 0 {
51
+ meterGroupByFilters = feature.ConvertMapStringToMeterGroupByFilters(lo.FromPtrOr(f.MeterGroupByFilters, map[string]string{}))
52
+ }
53
+
54
+ inputs := feature.CreateFeatureInputs{
55
+ Namespace: namespace,
56
+ Name: f.Name,
57
+ Key: f.Key,
58
+ MeterID: meterID,
59
+ MeterGroupByFilters: meterGroupByFilters,
60
+ Metadata: convert.DerefHeaderPtr[string](f.Metadata),
61
+ }
62
+
63
+ if f.UnitCost != nil {
64
+ unitCost, err := apiUnitCostToDomain(f.UnitCost)
65
+ if err != nil {
66
+ return feature.CreateFeatureInputs{}, fmt.Errorf("invalid unit cost: %w", err)
67
+ }
68
+ inputs.UnitCost = unitCost
69
+ }
70
+
71
+ return inputs, nil
72
+ }
73
+
74
+ func domainUnitCostToAPI(u *feature.UnitCost) (api.FeatureUnitCost, error) {
75
+ var out api.FeatureUnitCost
76
+
77
+ switch u.Type {
78
+ case feature.UnitCostTypeManual:
79
+ if err := out.FromFeatureManualUnitCost(api.FeatureManualUnitCost{
80
+ Amount: u.Manual.Amount.String(),
81
+ }); err != nil {
82
+ return out, fmt.Errorf("failed to convert manual unit cost: %w", err)
83
+ }
84
+ case feature.UnitCostTypeLLM:
85
+ llmCost := api.FeatureLLMUnitCost{}
86
+ if u.LLM.ProviderProperty != "" {
87
+ llmCost.ProviderProperty = lo.ToPtr(u.LLM.ProviderProperty)
88
+ }
89
+ if u.LLM.Provider != "" {
90
+ llmCost.Provider = lo.ToPtr(u.LLM.Provider)
91
+ }
92
+ if u.LLM.ModelProperty != "" {
93
+ llmCost.ModelProperty = lo.ToPtr(u.LLM.ModelProperty)
94
+ }
95
+ if u.LLM.Model != "" {
96
+ llmCost.Model = lo.ToPtr(u.LLM.Model)
97
+ }
98
+ if u.LLM.TokenTypeProperty != "" {
99
+ llmCost.TokenTypeProperty = lo.ToPtr(u.LLM.TokenTypeProperty)
100
+ }
101
+ if u.LLM.TokenType != "" {
102
+ llmCost.TokenType = lo.ToPtr(u.LLM.TokenType)
103
+ }
104
+ if err := out.FromFeatureLLMUnitCost(llmCost); err != nil {
105
+ return out, fmt.Errorf("failed to convert LLM unit cost: %w", err)
106
+ }
107
+ default:
108
+ return out, fmt.Errorf("unknown unit cost type: %s", u.Type)
109
+ }
110
+
111
+ return out, nil
112
+ }
113
+
114
+ func apiUnitCostToDomain(u *api.FeatureUnitCost) (*feature.UnitCost, error) {
115
+ discriminator, err := u.Discriminator()
116
+ if err != nil {
117
+ return nil, fmt.Errorf("failed to determine unit cost type: %w", err)
118
+ }
119
+
120
+ switch discriminator {
121
+ case "manual":
122
+ manual, err := u.AsFeatureManualUnitCost()
123
+ if err != nil {
124
+ return nil, fmt.Errorf("failed to parse manual unit cost: %w", err)
125
+ }
126
+
127
+ amount, err := alpacadecimal.NewFromString(manual.Amount)
128
+ if err != nil {
129
+ return nil, fmt.Errorf("invalid manual unit cost amount %q: %w", manual.Amount, err)
130
+ }
131
+
132
+ return &feature.UnitCost{
133
+ Type: feature.UnitCostTypeManual,
134
+ Manual: &feature.ManualUnitCost{
135
+ Amount: amount,
136
+ },
137
+ }, nil
138
+ case "llm":
139
+ llm, err := u.AsFeatureLLMUnitCost()
140
+ if err != nil {
141
+ return nil, fmt.Errorf("failed to parse LLM unit cost: %w", err)
142
+ }
143
+
144
+ return &feature.UnitCost{
145
+ Type: feature.UnitCostTypeLLM,
146
+ LLM: &feature.LLMUnitCost{
147
+ ProviderProperty: lo.FromPtrOr(llm.ProviderProperty, ""),
148
+ Provider: lo.FromPtrOr(llm.Provider, ""),
149
+ ModelProperty: lo.FromPtrOr(llm.ModelProperty, ""),
150
+ Model: lo.FromPtrOr(llm.Model, ""),
151
+ TokenTypeProperty: lo.FromPtrOr(llm.TokenTypeProperty, ""),
152
+ TokenType: lo.FromPtrOr(llm.TokenType, ""),
153
+ },
154
+ }, nil
155
+ default:
156
+ return nil, fmt.Errorf("unknown unit cost type: %s", discriminator)
157
+ }
158
+ }
159
+
160
+ // resolveLLMPricing extracts provider and model from the feature's meterGroupByFilters
161
+ // and resolves the current pricing from the LLM cost database.
162
+ // Returns nil if provider/model can't be determined or pricing can't be resolved.
163
+ func resolveLLMPricing(ctx context.Context, svc llmcost.Service, feat *feature.Feature) *llmcost.ModelPricing {
164
+ if feat.UnitCost == nil || feat.UnitCost.LLM == nil {
165
+ return nil
166
+ }
167
+
168
+ llmConf := feat.UnitCost.LLM
169
+
170
+ // Resolve provider: static value or from meterGroupByFilters
171
+ provider := llmConf.Provider
172
+ if provider == "" {
173
+ provider = extractEqFilterValue(feat.MeterGroupByFilters, llmConf.ProviderProperty)
174
+ }
175
+ if provider == "" {
176
+ return nil
177
+ }
178
+
179
+ // Resolve model: static value or from meterGroupByFilters
180
+ model := llmConf.Model
181
+ if model == "" {
182
+ model = extractEqFilterValue(feat.MeterGroupByFilters, llmConf.ModelProperty)
183
+ }
184
+ if model == "" {
185
+ return nil
186
+ }
187
+
188
+ price, err := svc.ResolvePrice(ctx, llmcost.ResolvePriceInput{
189
+ Namespace: feat.Namespace,
190
+ Provider: llmcost.Provider(provider),
191
+ ModelID: model,
192
+ })
193
+ if err != nil {
194
+ return nil
195
+ }
196
+
197
+ return &price.Pricing
198
+ }
199
+
200
+ // extractEqFilterValue extracts a simple $eq value from a MeterGroupByFilters map for the given key.
201
+ func extractEqFilterValue(filters feature.MeterGroupByFilters, key string) string {
202
+ if filters == nil {
203
+ return ""
204
+ }
205
+
206
+ f, ok := filters[key]
207
+ if !ok || f.Eq == nil {
208
+ return ""
209
+ }
210
+
211
+ return *f.Eq
212
+ }
213
+
214
+ // enrichFeatureResponseWithPricing adds resolved LLM pricing to the feature API response.
215
+ func enrichFeatureResponseWithPricing(resp *api.Feature, pricing *llmcost.ModelPricing) {
216
+ if resp.UnitCost == nil || pricing == nil {
217
+ return
218
+ }
219
+
220
+ llmCost, err := resp.UnitCost.AsFeatureLLMUnitCost()
221
+ if err != nil {
222
+ return
223
+ }
224
+
225
+ apiPricing := api.FeatureLLMUnitCostPricing{
226
+ InputPerToken: pricing.InputPerToken.String(),
227
+ OutputPerToken: pricing.OutputPerToken.String(),
228
+ }
229
+
230
+ if pricing.CacheReadPerToken != nil {
231
+ v := pricing.CacheReadPerToken.String()
232
+ apiPricing.CacheReadPerToken = &v
233
+ }
234
+
235
+ if pricing.CacheWritePerToken != nil {
236
+ v := pricing.CacheWritePerToken.String()
237
+ apiPricing.CacheWritePerToken = &v
238
+ }
239
+
240
+ if pricing.ReasoningPerToken != nil {
241
+ v := pricing.ReasoningPerToken.String()
242
+ apiPricing.ReasoningPerToken = &v
243
+ }
244
+
245
+ llmCost.Pricing = &apiPricing
246
+ _ = resp.UnitCost.FromFeatureLLMUnitCost(llmCost)
247
+ }
openmeter/productcatalog/effectiveperiod.go ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package productcatalog
2
+
3
+ import (
4
+ "errors"
5
+ "time"
6
+
7
+ "github.com/samber/lo"
8
+
9
+ "github.com/openmeterio/openmeter/pkg/models"
10
+ "github.com/openmeterio/openmeter/pkg/timeutil"
11
+ )
12
+
13
+ var (
14
+ _ models.Validator = (*EffectivePeriod)(nil)
15
+ _ models.Equaler[EffectivePeriod] = (*EffectivePeriod)(nil)
16
+ _ models.CustomValidator[EffectivePeriod] = (*EffectivePeriod)(nil)
17
+ )
18
+
19
+ // EffectivePeriod describes lifecycle of resource based on the time period defined by it.
20
+ type EffectivePeriod struct {
21
+ // EffectiveFrom defines the time from the Plan or Addon becomes active.
22
+ EffectiveFrom *time.Time `json:"effectiveFrom,omitempty"`
23
+
24
+ // EffectiveTo defines the time from the Plan or Addon becomes archived.
25
+ EffectiveTo *time.Time `json:"effectiveTo,omitempty"`
26
+ }
27
+
28
+ func (p EffectivePeriod) ValidateWith(v ...models.ValidatorFunc[EffectivePeriod]) error {
29
+ return models.Validate(p, v...)
30
+ }
31
+
32
+ func (p EffectivePeriod) AsPeriod() timeutil.OpenPeriod {
33
+ return timeutil.OpenPeriod{
34
+ From: p.EffectiveFrom,
35
+ To: p.EffectiveTo,
36
+ }
37
+ }
38
+
39
+ func (p EffectivePeriod) Validate() error {
40
+ return p.ValidateWith(ValidateEffectivePeriod())
41
+ }
42
+
43
+ func ValidateEffectivePeriod() models.ValidatorFunc[EffectivePeriod] {
44
+ return func(p EffectivePeriod) error {
45
+ var errs []error
46
+
47
+ from := lo.FromPtr(p.EffectiveFrom)
48
+ to := lo.FromPtr(p.EffectiveTo)
49
+
50
+ if !from.IsZero() && !to.IsZero() && from.After(to) {
51
+ errs = append(errs, ErrEffectivePeriodFromAfterTo.
52
+ WithAttrs(models.Attributes{
53
+ "effectiveFrom": p.EffectiveFrom,
54
+ "effectiveTo": p.EffectiveTo,
55
+ }))
56
+ }
57
+
58
+ if from.IsZero() && !to.IsZero() {
59
+ errs = append(errs, ErrEffectivePeriodFromNotSet.
60
+ WithAttrs(models.Attributes{
61
+ "effectiveFrom": nil,
62
+ "effectiveTo": p.EffectiveTo,
63
+ }))
64
+ }
65
+
66
+ return errors.Join(errs...)
67
+ }
68
+ }
69
+
70
+ // Equal returns true if the two EffectivePeriod objects are equal.
71
+ func (p EffectivePeriod) Equal(o EffectivePeriod) bool {
72
+ return lo.FromPtr(p.EffectiveFrom).Equal(lo.FromPtr(o.EffectiveFrom)) &&
73
+ lo.FromPtr(p.EffectiveTo).Equal(lo.FromPtr(o.EffectiveTo))
74
+ }
openmeter/productcatalog/effectiveperiod_test.go ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package productcatalog
2
+
3
+ import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/samber/lo"
8
+ "github.com/stretchr/testify/assert"
9
+
10
+ "github.com/openmeterio/openmeter/pkg/models"
11
+ )
12
+
13
+ func TestEffectivePeriod_Validate(t *testing.T) {
14
+ now := time.Now()
15
+ yesterday := now.Add(-24 * time.Hour)
16
+
17
+ tests := []struct {
18
+ Name string
19
+
20
+ EffectivePeriod EffectivePeriod
21
+ ExpectedError bool
22
+ ExpectedValidationIssues models.ValidationIssues
23
+ }{
24
+ {
25
+ Name: "Valid/Zero",
26
+ EffectivePeriod: EffectivePeriod{
27
+ EffectiveFrom: nil,
28
+ EffectiveTo: nil,
29
+ },
30
+ ExpectedError: false,
31
+ },
32
+ {
33
+ Name: "Valid/OpenEnded",
34
+ EffectivePeriod: EffectivePeriod{
35
+ EffectiveFrom: lo.ToPtr(yesterday),
36
+ EffectiveTo: nil,
37
+ },
38
+ ExpectedError: false,
39
+ },
40
+ {
41
+ Name: "Valid/Range",
42
+ EffectivePeriod: EffectivePeriod{
43
+ EffectiveFrom: lo.ToPtr(yesterday),
44
+ EffectiveTo: lo.ToPtr(now),
45
+ },
46
+ ExpectedError: false,
47
+ },
48
+ {
49
+ Name: "Invalid/Flipped",
50
+ EffectivePeriod: EffectivePeriod{
51
+ EffectiveFrom: lo.ToPtr(now),
52
+ EffectiveTo: lo.ToPtr(yesterday),
53
+ },
54
+ ExpectedError: true,
55
+ ExpectedValidationIssues: models.ValidationIssues{
56
+ ErrEffectivePeriodFromAfterTo.WithAttrs(models.Attributes{
57
+ "effectiveFrom": lo.ToPtr(now),
58
+ "effectiveTo": lo.ToPtr(yesterday),
59
+ }),
60
+ },
61
+ },
62
+ {
63
+ Name: "Invalid/OpenStart",
64
+ EffectivePeriod: EffectivePeriod{
65
+ EffectiveFrom: nil,
66
+ EffectiveTo: lo.ToPtr(now),
67
+ },
68
+ ExpectedError: true,
69
+ ExpectedValidationIssues: models.ValidationIssues{
70
+ ErrEffectivePeriodFromNotSet.WithAttrs(models.Attributes{
71
+ "effectiveFrom": nil,
72
+ "effectiveTo": lo.ToPtr(now),
73
+ }),
74
+ },
75
+ },
76
+ }
77
+
78
+ for _, test := range tests {
79
+ t.Run(test.Name, func(t *testing.T) {
80
+ if test.ExpectedError {
81
+ err := test.EffectivePeriod.Validate()
82
+ assert.Errorf(t, err, "expected invalid effective period")
83
+
84
+ issues, err := models.AsValidationIssues(err)
85
+ assert.NoError(t, err)
86
+
87
+ models.RequireValidationIssuesMatch(t, test.ExpectedValidationIssues, issues)
88
+ } else {
89
+ assert.NoErrorf(t, test.EffectivePeriod.Validate(), "expected valid effective period")
90
+ }
91
+ })
92
+ }
93
+ }
94
+
95
+ func TestEffectivePeriod_Equal(t *testing.T) {
96
+ tests := []struct {
97
+ Name string
98
+
99
+ Left EffectivePeriod
100
+ Right EffectivePeriod
101
+ Expected bool
102
+ }{
103
+ {
104
+ Name: "Equal/Nil",
105
+ Left: EffectivePeriod{
106
+ EffectiveFrom: nil,
107
+ EffectiveTo: nil,
108
+ },
109
+ Right: EffectivePeriod{
110
+ EffectiveFrom: nil,
111
+ EffectiveTo: nil,
112
+ },
113
+ Expected: true,
114
+ },
115
+ {
116
+ Name: "Equal/ZeroNil",
117
+ Left: EffectivePeriod{
118
+ EffectiveFrom: lo.ToPtr(time.Time{}),
119
+ EffectiveTo: lo.ToPtr(time.Time{}),
120
+ },
121
+ Right: EffectivePeriod{
122
+ EffectiveFrom: nil,
123
+ EffectiveTo: nil,
124
+ },
125
+ Expected: true,
126
+ },
127
+ {
128
+ Name: "Valid/Zero",
129
+ Left: EffectivePeriod{
130
+ EffectiveFrom: lo.ToPtr(time.Time{}),
131
+ EffectiveTo: lo.ToPtr(time.Time{}),
132
+ },
133
+ Right: EffectivePeriod{
134
+ EffectiveFrom: lo.ToPtr(time.Time{}),
135
+ EffectiveTo: lo.ToPtr(time.Time{}),
136
+ },
137
+ Expected: true,
138
+ },
139
+ }
140
+
141
+ for _, test := range tests {
142
+ t.Run(test.Name, func(t *testing.T) {
143
+ eq := test.Left.Equal(test.Right)
144
+ assert.Equalf(t, test.Expected, eq, "expected %v, got %v", test.Expected, eq)
145
+ })
146
+ }
147
+ }
openmeter/productcatalog/entitlement.go ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package productcatalog
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "fmt"
7
+
8
+ "github.com/samber/lo"
9
+
10
+ "github.com/openmeterio/openmeter/openmeter/entitlement"
11
+ "github.com/openmeterio/openmeter/pkg/datetime"
12
+ "github.com/openmeterio/openmeter/pkg/hasher"
13
+ "github.com/openmeterio/openmeter/pkg/models"
14
+ )
15
+
16
+ type entitlementTemplater interface {
17
+ json.Marshaler
18
+ json.Unmarshaler
19
+ models.Validator
20
+ models.Equaler[*EntitlementTemplate]
21
+
22
+ Type() entitlement.EntitlementType
23
+ AsMetered() (MeteredEntitlementTemplate, error)
24
+ AsStatic() (StaticEntitlementTemplate, error)
25
+ AsBoolean() (BooleanEntitlementTemplate, error)
26
+ FromMetered(MeteredEntitlementTemplate)
27
+ FromStatic(StaticEntitlementTemplate)
28
+ FromBoolean(BooleanEntitlementTemplate)
29
+ }
30
+
31
+ var _ entitlementTemplater = (*EntitlementTemplate)(nil)
32
+
33
+ // EntitlementTemplate is the template used for instantiating entitlement.Entitlement for RateCard.
34
+ type EntitlementTemplate struct {
35
+ t entitlement.EntitlementType
36
+ metered *MeteredEntitlementTemplate
37
+ static *StaticEntitlementTemplate
38
+ boolean *BooleanEntitlementTemplate
39
+ }
40
+
41
+ func (e *EntitlementTemplate) Equal(v *EntitlementTemplate) bool {
42
+ if e == nil && v == nil {
43
+ return true
44
+ }
45
+
46
+ if e == nil || v == nil {
47
+ return false
48
+ }
49
+
50
+ if e.t != v.t {
51
+ return false
52
+ }
53
+
54
+ switch e.t {
55
+ case entitlement.EntitlementTypeMetered:
56
+ return e.metered.Equal(v.metered)
57
+ case entitlement.EntitlementTypeStatic:
58
+ return e.static.Equal(v.static)
59
+ case entitlement.EntitlementTypeBoolean:
60
+ return e.boolean.Equal(v.boolean)
61
+ default:
62
+ return false
63
+ }
64
+ }
65
+
66
+ func (e *EntitlementTemplate) MarshalJSON() ([]byte, error) {
67
+ var b []byte
68
+ var err error
69
+ var serde interface{}
70
+
71
+ switch e.t {
72
+ case entitlement.EntitlementTypeMetered:
73
+ serde = struct {
74
+ Type entitlement.EntitlementType `json:"type"`
75
+ *MeteredEntitlementTemplate
76
+ }{
77
+ Type: entitlement.EntitlementTypeMetered,
78
+ MeteredEntitlementTemplate: e.metered,
79
+ }
80
+ case entitlement.EntitlementTypeStatic:
81
+ serde = struct {
82
+ Type entitlement.EntitlementType `json:"type"`
83
+ *StaticEntitlementTemplate
84
+ }{
85
+ Type: entitlement.EntitlementTypeStatic,
86
+ StaticEntitlementTemplate: e.static,
87
+ }
88
+ case entitlement.EntitlementTypeBoolean:
89
+ serde = struct {
90
+ Type entitlement.EntitlementType `json:"type"`
91
+ *BooleanEntitlementTemplate
92
+ }{
93
+ Type: entitlement.EntitlementTypeBoolean,
94
+ BooleanEntitlementTemplate: e.boolean,
95
+ }
96
+ default:
97
+ return nil, fmt.Errorf("invalid Entitlement type: %s", e.t)
98
+ }
99
+
100
+ b, err = json.Marshal(serde)
101
+ if err != nil {
102
+ return nil, fmt.Errorf("failed to JSON serialize EntitlementTemplate: %w", err)
103
+ }
104
+
105
+ return b, nil
106
+ }
107
+
108
+ func (e *EntitlementTemplate) UnmarshalJSON(bytes []byte) error {
109
+ serde := struct {
110
+ Type entitlement.EntitlementType `json:"type"`
111
+ }{}
112
+
113
+ if err := json.Unmarshal(bytes, &serde); err != nil {
114
+ return fmt.Errorf("failed to JSON deserialize EntitlementTemplate type: %w", err)
115
+ }
116
+
117
+ switch serde.Type {
118
+ case entitlement.EntitlementTypeMetered:
119
+ v := &MeteredEntitlementTemplate{}
120
+ if err := json.Unmarshal(bytes, v); err != nil {
121
+ return fmt.Errorf("failed to JSON deserialize EntitlementTemplate: %w", err)
122
+ }
123
+
124
+ e.metered = v
125
+ e.t = entitlement.EntitlementTypeMetered
126
+ case entitlement.EntitlementTypeStatic:
127
+ v := &StaticEntitlementTemplate{}
128
+ if err := json.Unmarshal(bytes, v); err != nil {
129
+ return fmt.Errorf("failed to JSON deserialize EntitlementTemplate: %w", err)
130
+ }
131
+
132
+ e.static = v
133
+ e.t = entitlement.EntitlementTypeStatic
134
+ case entitlement.EntitlementTypeBoolean:
135
+ v := &BooleanEntitlementTemplate{}
136
+ if err := json.Unmarshal(bytes, v); err != nil {
137
+ return fmt.Errorf("failed to JSON deserialize EntitlementTemplate: %w", err)
138
+ }
139
+
140
+ e.boolean = v
141
+ e.t = entitlement.EntitlementTypeBoolean
142
+ default:
143
+ return fmt.Errorf("invalid EntitlementTemplate type: %s", serde.Type)
144
+ }
145
+
146
+ return nil
147
+ }
148
+
149
+ func (e *EntitlementTemplate) Validate() error {
150
+ if e == nil {
151
+ return nil
152
+ }
153
+
154
+ switch e.t {
155
+ case entitlement.EntitlementTypeMetered:
156
+ return e.metered.Validate()
157
+ case entitlement.EntitlementTypeStatic:
158
+ return e.static.Validate()
159
+ case entitlement.EntitlementTypeBoolean:
160
+ return e.boolean.Validate()
161
+ default:
162
+ return fmt.Errorf("invalid entitlement template type: %q", e.t)
163
+ }
164
+ }
165
+
166
+ func (e *EntitlementTemplate) Type() entitlement.EntitlementType {
167
+ return e.t
168
+ }
169
+
170
+ func (e *EntitlementTemplate) AsMetered() (MeteredEntitlementTemplate, error) {
171
+ switch e.t {
172
+ case entitlement.EntitlementTypeMetered:
173
+ if e.metered == nil {
174
+ return MeteredEntitlementTemplate{}, errors.New("invalid metered entitlement template: not initialized")
175
+ }
176
+
177
+ return *e.metered, nil
178
+ case entitlement.EntitlementTypeBoolean, entitlement.EntitlementTypeStatic:
179
+ return MeteredEntitlementTemplate{}, fmt.Errorf("invalid entitlement template: type mismatch: %s", e.t)
180
+ default:
181
+ return MeteredEntitlementTemplate{}, errors.New("invalid entitlement template: not initialized")
182
+ }
183
+ }
184
+
185
+ func (e *EntitlementTemplate) AsStatic() (StaticEntitlementTemplate, error) {
186
+ switch e.t {
187
+ case entitlement.EntitlementTypeStatic:
188
+ if e.static == nil {
189
+ return StaticEntitlementTemplate{}, errors.New("invalid static entitlement template: not initialized")
190
+ }
191
+
192
+ return *e.static, nil
193
+ case entitlement.EntitlementTypeBoolean, entitlement.EntitlementTypeMetered:
194
+ return StaticEntitlementTemplate{}, fmt.Errorf("invalid entitlement template: type mismatch: %s", e.t)
195
+ default:
196
+ return StaticEntitlementTemplate{}, errors.New("invalid entitlement template: not initialized")
197
+ }
198
+ }
199
+
200
+ func (e *EntitlementTemplate) AsBoolean() (BooleanEntitlementTemplate, error) {
201
+ switch e.t {
202
+ case entitlement.EntitlementTypeBoolean:
203
+ if e.boolean == nil {
204
+ return BooleanEntitlementTemplate{}, errors.New("invalid boolean entitlement template: not initialized")
205
+ }
206
+
207
+ return *e.boolean, nil
208
+ case entitlement.EntitlementTypeStatic, entitlement.EntitlementTypeMetered:
209
+ return BooleanEntitlementTemplate{}, fmt.Errorf("invalid entitlement template: type mismatch: %s", e.t)
210
+ default:
211
+ return BooleanEntitlementTemplate{}, errors.New("invalid entitlement template: not initialized")
212
+ }
213
+ }
214
+
215
+ func (e *EntitlementTemplate) FromMetered(t MeteredEntitlementTemplate) {
216
+ e.metered = &t
217
+ e.t = entitlement.EntitlementTypeMetered
218
+ }
219
+
220
+ func (e *EntitlementTemplate) FromStatic(t StaticEntitlementTemplate) {
221
+ e.static = &t
222
+ e.t = entitlement.EntitlementTypeStatic
223
+ }
224
+
225
+ func (e *EntitlementTemplate) FromBoolean(t BooleanEntitlementTemplate) {
226
+ e.boolean = &t
227
+ e.t = entitlement.EntitlementTypeBoolean
228
+ }
229
+
230
+ func NewEntitlementTemplateFrom[T MeteredEntitlementTemplate | StaticEntitlementTemplate | BooleanEntitlementTemplate](c T) *EntitlementTemplate {
231
+ r := &EntitlementTemplate{}
232
+
233
+ switch any(c).(type) {
234
+ case MeteredEntitlementTemplate:
235
+ e := any(c).(MeteredEntitlementTemplate)
236
+ r.FromMetered(e)
237
+ case StaticEntitlementTemplate:
238
+ e := any(c).(StaticEntitlementTemplate)
239
+ r.FromStatic(e)
240
+ case BooleanEntitlementTemplate:
241
+ e := any(c).(BooleanEntitlementTemplate)
242
+ r.FromBoolean(e)
243
+ }
244
+
245
+ return r
246
+ }
247
+
248
+ var (
249
+ _ models.Validator = (*MeteredEntitlementTemplate)(nil)
250
+ _ models.Equaler[*MeteredEntitlementTemplate] = (*MeteredEntitlementTemplate)(nil)
251
+ )
252
+
253
+ type MeteredEntitlementTemplate struct {
254
+ // Metadata a set of key/value pairs describing metadata for the RateCard.
255
+ Metadata models.Metadata `json:"metadata,omitempty"`
256
+
257
+ // IsSoftLimit set to `true` for allowing the subject to use the feature even if the entitlement is exhausted.
258
+ IsSoftLimit bool `json:"isSoftLimit,omitempty"`
259
+
260
+ // IssueAfterReset defines the amount to be automatically granted at entitlement.Entitlement creation or reset.
261
+ IssueAfterReset *float64 `json:"issueAfterReset,omitempty"`
262
+
263
+ // IssueAfterResetPriority defines the grant priority for the default grant.
264
+ IssueAfterResetPriority *uint8 `json:"issueAfterResetPriority,omitempty"`
265
+
266
+ // PreserveOverageAtReset defines whether the overage is preserved after reset.
267
+ PreserveOverageAtReset *bool `json:"preserveOverageAtReset,omitempty"`
268
+
269
+ // UsagePeriod defines the interval of the entitlement in ISO8601 format.
270
+ // Defaults to the billing cadence of the rate card.
271
+ // Example: "P1D12H"
272
+ UsagePeriod datetime.ISODuration `json:"usagePeriod,omitempty"`
273
+ }
274
+
275
+ func (t *MeteredEntitlementTemplate) Equal(v *MeteredEntitlementTemplate) bool {
276
+ if t == nil && v == nil {
277
+ return true
278
+ }
279
+
280
+ if t == nil || v == nil {
281
+ return false
282
+ }
283
+
284
+ if !t.Metadata.Equal(v.Metadata) {
285
+ return false
286
+ }
287
+
288
+ if !t.IsSoftLimit && v.IsSoftLimit {
289
+ return false
290
+ }
291
+
292
+ if (t.IssueAfterReset != nil && v.IssueAfterReset == nil) || (t.IssueAfterReset == nil && v.IssueAfterReset != nil) {
293
+ return false
294
+ }
295
+
296
+ if lo.FromPtr(t.IssueAfterReset) != lo.FromPtr(v.IssueAfterReset) {
297
+ return false
298
+ }
299
+
300
+ if (t.IssueAfterReset != nil && v.IssueAfterReset == nil) ||
301
+ (t.IssueAfterReset == nil && v.IssueAfterReset != nil) {
302
+ return false
303
+ }
304
+
305
+ if lo.FromPtr(t.IssueAfterReset) != lo.FromPtr(v.IssueAfterReset) {
306
+ return false
307
+ }
308
+
309
+ if (t.IssueAfterResetPriority != nil && v.IssueAfterResetPriority == nil) ||
310
+ (t.IssueAfterResetPriority == nil && v.IssueAfterResetPriority != nil) {
311
+ return false
312
+ }
313
+
314
+ if lo.FromPtr(t.IssueAfterResetPriority) != lo.FromPtr(v.IssueAfterResetPriority) {
315
+ return false
316
+ }
317
+
318
+ if (t.PreserveOverageAtReset != nil && v.PreserveOverageAtReset == nil) ||
319
+ (t.PreserveOverageAtReset == nil && v.PreserveOverageAtReset != nil) {
320
+ return false
321
+ }
322
+
323
+ if lo.FromPtr(t.PreserveOverageAtReset) != lo.FromPtr(v.PreserveOverageAtReset) {
324
+ return false
325
+ }
326
+
327
+ return t.UsagePeriod.ISOString() == v.UsagePeriod.ISOString()
328
+ }
329
+
330
+ func (t *MeteredEntitlementTemplate) Validate() error {
331
+ var errs []error
332
+
333
+ if t.IssueAfterResetPriority != nil && t.IssueAfterReset == nil {
334
+ errs = append(errs, ErrEntitlementTemplateInvalidIssueAfterResetWithPriority)
335
+ }
336
+
337
+ if t.UsagePeriod.Sign() != 1 {
338
+ errs = append(errs, ErrEntitlementTemplateNegativeUsagePeriod)
339
+ }
340
+
341
+ hour := datetime.NewISODuration(0, 0, 0, 0, 1, 0, 0)
342
+ if diff, err := t.UsagePeriod.Subtract(hour); err == nil && diff.Sign() == -1 {
343
+ errs = append(errs, ErrEntitlementTemplateUsagePeriodLessThenAnHour)
344
+ }
345
+
346
+ if err := errors.Join(errs...); err != nil {
347
+ return models.NewGenericValidationError(
348
+ models.ErrorWithFieldPrefix(
349
+ models.NewFieldSelectorGroup(models.NewFieldSelector("entitlementTemplate")),
350
+ err),
351
+ )
352
+ }
353
+
354
+ return nil
355
+ }
356
+
357
+ var (
358
+ _ models.Validator = (*StaticEntitlementTemplate)(nil)
359
+ _ models.Equaler[*StaticEntitlementTemplate] = (*StaticEntitlementTemplate)(nil)
360
+ )
361
+
362
+ type StaticEntitlementTemplate struct {
363
+ // Metadata a set of key/value pairs describing metadata for the RateCard.
364
+ Metadata models.Metadata `json:"metadata,omitempty"`
365
+
366
+ // Config stores a JSON parsable configuration for the entitlement.Entitlement.
367
+ // This value is also returned when checking entitlement access, and
368
+ // it is useful for configuring fine-grained access settings to the feature implemented in customers own system.
369
+ Config json.RawMessage `json:"config,omitempty"`
370
+ }
371
+
372
+ func (t *StaticEntitlementTemplate) Equal(v *StaticEntitlementTemplate) bool {
373
+ if t == nil && v == nil {
374
+ return true
375
+ }
376
+
377
+ if t == nil || v == nil {
378
+ return false
379
+ }
380
+
381
+ if !t.Metadata.Equal(v.Metadata) {
382
+ return false
383
+ }
384
+
385
+ return hasher.NewHash(t.Config) == hasher.NewHash(v.Config)
386
+ }
387
+
388
+ func (t *StaticEntitlementTemplate) Validate() error {
389
+ if len(t.Config) > 0 {
390
+ if ok := json.Valid(t.Config); !ok {
391
+ return models.NewGenericValidationError(ErrEntitlementTemplateInvalidJSONConfig)
392
+ }
393
+ }
394
+
395
+ return nil
396
+ }
397
+
398
+ var (
399
+ _ models.Validator = (*BooleanEntitlementTemplate)(nil)
400
+ _ models.Equaler[*BooleanEntitlementTemplate] = (*BooleanEntitlementTemplate)(nil)
401
+ )
402
+
403
+ type BooleanEntitlementTemplate struct {
404
+ // Metadata a set of key/value pairs describing metadata for the RateCard.
405
+ Metadata models.Metadata `json:"metadata,omitempty"`
406
+ }
407
+
408
+ func (t *BooleanEntitlementTemplate) Equal(v *BooleanEntitlementTemplate) bool {
409
+ if t == nil && v == nil {
410
+ return true
411
+ }
412
+
413
+ if t == nil || v == nil {
414
+ return false
415
+ }
416
+
417
+ return t.Metadata.Equal(v.Metadata)
418
+ }
419
+
420
+ func (t *BooleanEntitlementTemplate) Validate() error {
421
+ return nil
422
+ }
openmeter/productcatalog/entitlement_test.go ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package productcatalog
2
+
3
+ import (
4
+ "testing"
5
+
6
+ json "github.com/json-iterator/go"
7
+ "github.com/samber/lo"
8
+ "github.com/stretchr/testify/assert"
9
+ "github.com/stretchr/testify/require"
10
+
11
+ "github.com/openmeterio/openmeter/pkg/datetime"
12
+ )
13
+
14
+ func TestEntitlementTemplate_JSON(t *testing.T) {
15
+ tests := []struct {
16
+ Name string
17
+ EntitlementTemplate *EntitlementTemplate
18
+ ExpectedError bool
19
+ }{
20
+ {
21
+ Name: "Metered",
22
+ EntitlementTemplate: NewEntitlementTemplateFrom(MeteredEntitlementTemplate{
23
+ Metadata: map[string]string{
24
+ "key": "value",
25
+ },
26
+ IsSoftLimit: true,
27
+ IssueAfterReset: lo.ToPtr(500.0),
28
+ IssueAfterResetPriority: lo.ToPtr[uint8](1),
29
+ PreserveOverageAtReset: lo.ToPtr(true),
30
+ UsagePeriod: datetime.MustParseDuration(t, "P1M"),
31
+ }),
32
+ },
33
+ {
34
+ Name: "Static",
35
+ EntitlementTemplate: NewEntitlementTemplateFrom(StaticEntitlementTemplate{
36
+ Metadata: map[string]string{
37
+ "key": "value",
38
+ },
39
+ Config: []byte(`{"key":"value"}`),
40
+ }),
41
+ },
42
+ {
43
+ Name: "Boolean",
44
+ EntitlementTemplate: NewEntitlementTemplateFrom(BooleanEntitlementTemplate{
45
+ Metadata: map[string]string{
46
+ "key": "value",
47
+ },
48
+ }),
49
+ },
50
+ }
51
+
52
+ for _, test := range tests {
53
+ t.Run(test.Name, func(t *testing.T) {
54
+ b, err := json.Marshal(&test.EntitlementTemplate)
55
+ require.NoError(t, err)
56
+
57
+ t.Logf("Serialized EntitlementTemplate: %s", string(b))
58
+
59
+ d := &EntitlementTemplate{}
60
+ err = json.Unmarshal(b, d)
61
+ require.NoError(t, err)
62
+
63
+ assert.Equal(t, test.EntitlementTemplate, d)
64
+ })
65
+ }
66
+ }
67
+
68
+ func TestEntitlementTemplateEqual(t *testing.T) {
69
+ tests := []struct {
70
+ Name string
71
+
72
+ Left *EntitlementTemplate
73
+ Right *EntitlementTemplate
74
+
75
+ ExpectedResult bool
76
+ }{
77
+ {
78
+ Name: "Equal",
79
+ Left: NewEntitlementTemplateFrom(
80
+ MeteredEntitlementTemplate{
81
+ Metadata: map[string]string{"name": "metered"},
82
+ IsSoftLimit: true,
83
+ IssueAfterReset: lo.ToPtr(1000.0),
84
+ IssueAfterResetPriority: lo.ToPtr[uint8](5),
85
+ PreserveOverageAtReset: lo.ToPtr(true),
86
+ UsagePeriod: datetime.MustParseDuration(t, "P1M"),
87
+ },
88
+ ),
89
+ Right: NewEntitlementTemplateFrom(
90
+ MeteredEntitlementTemplate{
91
+ Metadata: map[string]string{"name": "metered"},
92
+ IsSoftLimit: true,
93
+ IssueAfterReset: lo.ToPtr(1000.0),
94
+ IssueAfterResetPriority: lo.ToPtr[uint8](5),
95
+ PreserveOverageAtReset: lo.ToPtr(true),
96
+ UsagePeriod: datetime.MustParseDuration(t, "P1M"),
97
+ },
98
+ ),
99
+ ExpectedResult: true,
100
+ },
101
+ {
102
+ Name: "ContentMismatch",
103
+ Left: NewEntitlementTemplateFrom(
104
+ MeteredEntitlementTemplate{
105
+ Metadata: map[string]string{"name": "metered1"},
106
+ IsSoftLimit: true,
107
+ IssueAfterReset: lo.ToPtr(1000.0),
108
+ IssueAfterResetPriority: lo.ToPtr[uint8](5),
109
+ PreserveOverageAtReset: lo.ToPtr(true),
110
+ UsagePeriod: datetime.MustParseDuration(t, "P1M"),
111
+ },
112
+ ),
113
+ Right: NewEntitlementTemplateFrom(
114
+ MeteredEntitlementTemplate{
115
+ Metadata: map[string]string{"name": "metered2"},
116
+ IsSoftLimit: false,
117
+ IssueAfterReset: lo.ToPtr(2000.0),
118
+ IssueAfterResetPriority: lo.ToPtr[uint8](1),
119
+ PreserveOverageAtReset: lo.ToPtr(false),
120
+ UsagePeriod: datetime.MustParseDuration(t, "P3M"),
121
+ },
122
+ ),
123
+ ExpectedResult: false,
124
+ },
125
+ {
126
+ Name: "TypeMismatch",
127
+ Left: NewEntitlementTemplateFrom(
128
+ MeteredEntitlementTemplate{
129
+ Metadata: map[string]string{"name": "metered1"},
130
+ IsSoftLimit: true,
131
+ IssueAfterReset: lo.ToPtr(1000.0),
132
+ IssueAfterResetPriority: lo.ToPtr[uint8](5),
133
+ PreserveOverageAtReset: lo.ToPtr(true),
134
+ UsagePeriod: datetime.MustParseDuration(t, "P1M"),
135
+ },
136
+ ),
137
+ Right: NewEntitlementTemplateFrom(
138
+ StaticEntitlementTemplate{
139
+ Metadata: map[string]string{"name": "metered2"},
140
+ Config: []byte(`"name": "metered1"`),
141
+ },
142
+ ),
143
+ ExpectedResult: false,
144
+ },
145
+ }
146
+
147
+ for _, test := range tests {
148
+ t.Run(test.Name, func(t *testing.T) {
149
+ match := test.Left.Equal(test.Right)
150
+ assert.Equal(t, test.ExpectedResult, match)
151
+ })
152
+ }
153
+ }
openmeter/productcatalog/errors.go ADDED
@@ -0,0 +1,667 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package productcatalog
2
+
3
+ import (
4
+ "fmt"
5
+ "net/http"
6
+ "strings"
7
+
8
+ "github.com/samber/lo"
9
+
10
+ "github.com/openmeterio/openmeter/pkg/datetime"
11
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
12
+ "github.com/openmeterio/openmeter/pkg/models"
13
+ )
14
+
15
+ // PlanAddon errors
16
+
17
+ const ErrCodePlanAddonIncompatibleStatus models.ErrorCode = "plan_addon_incompatible_status"
18
+
19
+ var ErrPlanAddonIncompatibleStatus = models.NewValidationIssue(
20
+ ErrCodePlanAddonIncompatibleStatus,
21
+ "plan status is incompatible with the addon status",
22
+ models.WithFieldString("status"),
23
+ models.WithWarningSeverity(),
24
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
25
+ )
26
+
27
+ const ErrCodePlanAddonMaxQuantityMustBeSet models.ErrorCode = "plan_addon_max_quantity_must_be_set"
28
+
29
+ var ErrPlanAddonMaxQuantityMustBeSet = models.NewValidationIssue(
30
+ ErrCodePlanAddonMaxQuantityMustBeSet,
31
+ "maximum quantity, when set, must be a positive integer for add-ons with multiple instance type",
32
+ models.WithFieldString("maxQuantity"),
33
+ models.WithWarningSeverity(),
34
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
35
+ )
36
+
37
+ const ErrCodePlanAddonMaxQuantityMustNotBeSet models.ErrorCode = "plan_addon_max_quantity_must_not_be_set"
38
+
39
+ var ErrPlanAddonMaxQuantityMustNotBeSet = models.NewValidationIssue(
40
+ ErrCodePlanAddonMaxQuantityMustNotBeSet,
41
+ "maximum quantity must not be set for add-on with single instance type",
42
+ models.WithFieldString("maxQuantity"),
43
+ models.WithWarningSeverity(),
44
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
45
+ )
46
+
47
+ const ErrCodePlanAddonCurrencyMismatch models.ErrorCode = "plan_addon_currency_mismatch"
48
+
49
+ var ErrPlanAddonCurrencyMismatch = models.NewValidationIssue(
50
+ ErrCodePlanAddonCurrencyMismatch,
51
+ "currency of the plan and addon must match",
52
+ models.WithFieldString("currency"),
53
+ models.WithWarningSeverity(),
54
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
55
+ )
56
+
57
+ const ErrCodePlanAddonUnknownPlanPhaseKey models.ErrorCode = "plan_addon_unknown_plan_phase_key"
58
+
59
+ var ErrPlanAddonUnknownPlanPhaseKey = models.NewValidationIssue(
60
+ ErrCodePlanAddonUnknownPlanPhaseKey,
61
+ "add-on must define valid/existing plan phase key from which the add-on is available for purchase",
62
+ models.WithFieldString("fromPlanPhase"),
63
+ models.WithWarningSeverity(),
64
+ )
65
+
66
+ // RateCard errors
67
+
68
+ const ErrCodeRateCardKeyMismatch models.ErrorCode = "rate_card_key_mismatch"
69
+
70
+ var ErrRateCardKeyMismatch = models.NewValidationIssue(
71
+ ErrCodeRateCardKeyMismatch,
72
+ "key must match",
73
+ models.WithFieldString("key"),
74
+ models.WithWarningSeverity(),
75
+ )
76
+
77
+ const ErrCodeRateCardPriceTypeMismatch models.ErrorCode = "rate_card_price_type_mismatch"
78
+
79
+ var ErrRateCardPriceTypeMismatch = models.NewValidationIssue(
80
+ ErrCodeRateCardPriceTypeMismatch,
81
+ "price type must match",
82
+ models.WithFieldString("price"),
83
+ models.WithWarningSeverity(),
84
+ )
85
+
86
+ const ErrCodeRateCardPricePaymentTermMismatch models.ErrorCode = "rate_card_price_payment_term_mismatch"
87
+
88
+ var ErrRateCardPricePaymentTermMismatch = models.NewValidationIssue(
89
+ ErrCodeRateCardPricePaymentTermMismatch,
90
+ "price payment term must match",
91
+ models.WithFieldString("price"),
92
+ models.WithWarningSeverity(),
93
+ )
94
+
95
+ const ErrCodeRateCardOnlyFlatPriceAllowed models.ErrorCode = "rate_card_only_flat_price_allowed"
96
+
97
+ var ErrRateCardOnlyFlatPriceAllowed = models.NewValidationIssue(
98
+ ErrCodeRateCardOnlyFlatPriceAllowed,
99
+ "only flat price is allowed",
100
+ models.WithFieldString("price"),
101
+ models.WithWarningSeverity(),
102
+ )
103
+
104
+ const ErrCodeRateCardFeatureNotFound models.ErrorCode = "rate_card_feature_not_found"
105
+
106
+ var ErrRateCardFeatureNotFound = models.NewValidationIssue(
107
+ ErrCodeRateCardFeatureNotFound,
108
+ "feature not found",
109
+ models.WithFieldString("featureKey"),
110
+ models.WithCriticalSeverity(),
111
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
112
+ )
113
+
114
+ const ErrCodeRateCardFeatureArchived models.ErrorCode = "rate_card_feature_archived"
115
+
116
+ var ErrRateCardFeatureArchived = models.NewValidationIssue(
117
+ ErrCodeRateCardFeatureArchived,
118
+ "feature archived",
119
+ models.WithFieldString("featureKey"),
120
+ models.WithCriticalSeverity(),
121
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
122
+ )
123
+
124
+ const ErrCodeRateCardFeatureMismatch models.ErrorCode = "rate_card_feature_mismatch"
125
+
126
+ var ErrRateCardFeatureMismatch = models.NewValidationIssue(
127
+ ErrCodeRateCardFeatureMismatch,
128
+ "feature id and key must reference the same feature",
129
+ models.WithFieldString("featureKey"),
130
+ models.WithCriticalSeverity(),
131
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
132
+ )
133
+
134
+ const ErrCodeRateCardFeatureIDMismatch models.ErrorCode = "rate_card_feature_id_mismatch"
135
+
136
+ var ErrRateCardFeatureIDMismatch = models.NewValidationIssue(
137
+ ErrCodeRateCardFeatureIDMismatch,
138
+ "feature identifiers id must match",
139
+ models.WithFieldString("featureId"),
140
+ models.WithWarningSeverity(),
141
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
142
+ )
143
+
144
+ const ErrCodeRateCardFeatureKeyMismatch models.ErrorCode = "rate_card_feature_key_mismatch"
145
+
146
+ var ErrRateCardFeatureKeyMismatch = models.NewValidationIssue(
147
+ ErrCodeRateCardFeatureKeyMismatch,
148
+ "feature key must match",
149
+ models.WithFieldString("featureKey"),
150
+ models.WithWarningSeverity(),
151
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
152
+ )
153
+
154
+ const ErrCodeRateCardBillingCadenceMismatch models.ErrorCode = "rate_card_billing_cadence_mismatch"
155
+
156
+ var ErrRateCardBillingCadenceMismatch = models.NewValidationIssue(
157
+ ErrCodeRateCardBillingCadenceMismatch,
158
+ "billing cadence must match",
159
+ models.WithFieldString("billingCadence"),
160
+ models.WithWarningSeverity(),
161
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
162
+ )
163
+
164
+ const ErrCodeAddonRateCardUnitConfigMismatch models.ErrorCode = "addon_rate_card_unit_config_mismatch"
165
+
166
+ // ErrAddonRateCardUnitConfigMismatch is raised when an addon rate card carries a unit_config that differs
167
+ // from the rate card it extends. Addons layer price/entitlement/discounts additively but do not
168
+ // redefine the unit conversion; a divergent unit_config would be silently dropped by the overlay, so
169
+ // it is rejected here rather than accepted and ignored.
170
+ var ErrAddonRateCardUnitConfigMismatch = models.NewValidationIssue(
171
+ ErrCodeAddonRateCardUnitConfigMismatch,
172
+ "unit config must match",
173
+ models.WithFieldString("unitConfig"),
174
+ models.WithWarningSeverity(),
175
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
176
+ )
177
+
178
+ const ErrCodeRateCardEntitlementTemplateTypeMismatch models.ErrorCode = "rate_card_entitlement_template_type_mismatch"
179
+
180
+ var ErrRateCardEntitlementTemplateTypeMismatch = models.NewValidationIssue(
181
+ ErrCodeRateCardEntitlementTemplateTypeMismatch,
182
+ "entitlement template type must match",
183
+ models.WithFieldString("type"),
184
+ models.WithWarningSeverity(),
185
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
186
+ )
187
+
188
+ const ErrCodeRateCardStaticEntitlementTemplateNotAllowed models.ErrorCode = "rate_card_static_entitlement_template_not_allowed"
189
+
190
+ var ErrRateCardStaticEntitlementTemplateNotAllowed = models.NewValidationIssue(
191
+ ErrCodeRateCardStaticEntitlementTemplateNotAllowed,
192
+ "static entitlement template is not allowed",
193
+ models.WithFieldString("type"),
194
+ models.WithWarningSeverity(),
195
+ )
196
+
197
+ const ErrCodeRateCardMeteredEntitlementTemplateUsagePeriodMismatch models.ErrorCode = "rate_card_metered_entitlement_template_usage_period_mismatch"
198
+
199
+ var ErrRateCardMeteredEntitlementTemplateUsagePeriodMismatch = models.NewValidationIssue(
200
+ ErrCodeRateCardMeteredEntitlementTemplateUsagePeriodMismatch,
201
+ "usage period for metered entitlement template must match",
202
+ models.WithFieldString("usagePeriod"),
203
+ models.WithWarningSeverity(),
204
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
205
+ )
206
+
207
+ const ErrCodeRateCardPercentageDiscountNotAllowed models.ErrorCode = "rate_card_percentage_discount_not_allowed"
208
+
209
+ var ErrRateCardPercentageDiscountNotAllowed = models.NewValidationIssue(
210
+ ErrCodeRateCardPercentageDiscountNotAllowed,
211
+ "percentage discount is not allowed",
212
+ models.WithFieldString("percentage"),
213
+ models.WithWarningSeverity(),
214
+ )
215
+
216
+ const ErrCodeRateCardDuplicatedKey models.ErrorCode = "rate_card_duplicated_key"
217
+
218
+ var ErrRateCardDuplicatedKey = models.NewValidationIssue(
219
+ ErrCodeRateCardDuplicatedKey,
220
+ "duplicated key",
221
+ models.WithFieldString("key"),
222
+ models.WithCriticalSeverity(),
223
+ )
224
+
225
+ const ErrCodeRateCardEntitlementTemplateWithNoFeature models.ErrorCode = "entitlement_template_with_no_feature"
226
+
227
+ var ErrRateCardEntitlementTemplateWithNoFeature = models.NewValidationIssue(
228
+ ErrCodeRateCardEntitlementTemplateWithNoFeature,
229
+ "entitlement template requires feature to be associated with",
230
+ models.WithFieldString("featureKey"),
231
+ models.WithWarningSeverity(),
232
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
233
+ )
234
+
235
+ const ErrCodeEffectivePeriodFromAfterTo models.ErrorCode = "effective_period_from_after_to"
236
+
237
+ var ErrEffectivePeriodFromAfterTo = models.NewValidationIssue(
238
+ ErrCodeEffectivePeriodFromAfterTo,
239
+ "effectiveFrom is after effectiveTo",
240
+ models.WithFieldString("effectiveFrom"),
241
+ models.WithWarningSeverity(),
242
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
243
+ )
244
+
245
+ const ErrCodeEffectivePeriodFromNotSet models.ErrorCode = "effective_period_from_not_set"
246
+
247
+ var ErrEffectivePeriodFromNotSet = models.NewValidationIssue(
248
+ ErrCodeEffectivePeriodFromNotSet,
249
+ "effectiveFrom is must be provided if effectiveTo is set",
250
+ models.WithFieldString("effectiveFrom"),
251
+ models.WithWarningSeverity(),
252
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
253
+ )
254
+
255
+ const ErrCodeCurrencyInvalid models.ErrorCode = "currency_invalid"
256
+
257
+ var ErrCurrencyInvalid = models.NewValidationIssue(
258
+ ErrCodeCurrencyInvalid,
259
+ "currency is invalid",
260
+ models.WithFieldString("currency"),
261
+ models.WithCriticalSeverity(),
262
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
263
+ )
264
+
265
+ const ErrCodeEntitlementTemplateInvalidIssueAfterResetWithPriority models.ErrorCode = "entitlement_template_invalid_issue_after_reset_with_priority"
266
+
267
+ var ErrEntitlementTemplateInvalidIssueAfterResetWithPriority = models.NewValidationIssue(
268
+ ErrCodeEntitlementTemplateInvalidIssueAfterResetWithPriority,
269
+ "invalid entitlement template as issue after reset is required if issue after reset priority is set",
270
+ models.WithFieldString("issueAfterReset"),
271
+ models.WithWarningSeverity(),
272
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
273
+ )
274
+
275
+ const ErrCodeEntitlementTemplateIssueAfterResetRequired models.ErrorCode = "entitlement_template_issue_after_reset_required"
276
+
277
+ var ErrEntitlementTemplateIssueAfterResetRequired = models.NewValidationIssue(
278
+ ErrCodeEntitlementTemplateIssueAfterResetRequired,
279
+ "issueAfterReset is required for metered entitlement templates",
280
+ models.WithFieldString("entitlementTemplate", "issueAfterReset"),
281
+ models.WithWarningSeverity(),
282
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
283
+ )
284
+
285
+ const ErrCodeEntitlementTemplateNegativeUsagePeriod models.ErrorCode = "entitlement_template_negative_usage_period"
286
+
287
+ var ErrEntitlementTemplateNegativeUsagePeriod = models.NewValidationIssue(
288
+ ErrCodeEntitlementTemplateNegativeUsagePeriod,
289
+ "usage period must be positive",
290
+ models.WithFieldString("usagePeriod"),
291
+ models.WithWarningSeverity(),
292
+ )
293
+
294
+ const ErrCodeEntitlementTemplateUsagePeriodLessThenAnHour models.ErrorCode = "entitlement_template_usage_period_less_then_an_hour"
295
+
296
+ var ErrEntitlementTemplateUsagePeriodLessThenAnHour = models.NewValidationIssue(
297
+ ErrCodeEntitlementTemplateUsagePeriodLessThenAnHour,
298
+ "usage period must be at least 1 hour",
299
+ models.WithFieldString("usagePeriod"),
300
+ models.WithWarningSeverity(),
301
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
302
+ )
303
+
304
+ const ErrCodeEntitlementTemplateInvalidJSONConfig models.ErrorCode = "entitlement_template_invalid_json_config"
305
+
306
+ var ErrEntitlementTemplateInvalidJSONConfig = models.NewValidationIssue(
307
+ ErrCodeEntitlementTemplateInvalidJSONConfig,
308
+ "invalid JSON in static entitlement config",
309
+ models.WithFieldString("entitlementTemplate", "config"),
310
+ models.WithCriticalSeverity(),
311
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
312
+ )
313
+
314
+ const ErrCodeRateCardKeyFeatureKeyMismatch models.ErrorCode = "rate_card_key_feature_key_mismatch"
315
+
316
+ var ErrRateCardKeyFeatureKeyMismatch = models.NewValidationIssue(
317
+ ErrCodeRateCardKeyFeatureKeyMismatch,
318
+ "rate card key must match feature key",
319
+ models.WithFieldString("key"),
320
+ models.WithCriticalSeverity(),
321
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
322
+ )
323
+
324
+ const ErrCodePercentageDiscountInvalidValue models.ErrorCode = "percentage_discount_invalid_value"
325
+
326
+ var ErrPercentageDiscountInvalidValue = models.NewValidationIssue(
327
+ ErrCodePercentageDiscountInvalidValue,
328
+ "percentage must be between 0 and 100",
329
+ models.WithFieldString("percentage"),
330
+ models.WithCriticalSeverity(),
331
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
332
+ )
333
+
334
+ const ErrCodeUsageDiscountNegativeQuantity models.ErrorCode = "usage_discount_negative_quantity"
335
+
336
+ var ErrUsageDiscountNegativeQuantity = models.NewValidationIssue(
337
+ ErrCodeUsageDiscountNegativeQuantity,
338
+ "usage must be greater than 0",
339
+ models.WithFieldString("quantity"),
340
+ models.WithWarningSeverity(),
341
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
342
+ )
343
+
344
+ const ErrCodeUsageDiscountWithFlatPrice models.ErrorCode = "usage_discount_with_flat_price"
345
+
346
+ var ErrUsageDiscountWithFlatPrice = models.NewValidationIssue(
347
+ ErrCodeUsageDiscountWithFlatPrice,
348
+ "usage discount is not supported for flat price",
349
+ models.WithWarningSeverity(),
350
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
351
+ )
352
+
353
+ const ErrCodeBillingCadenceInvalidValue models.ErrorCode = "billing_cadence_invalid_value"
354
+
355
+ var ErrBillingCadenceInvalidValue = models.NewValidationIssue(
356
+ ErrCodeBillingCadenceInvalidValue,
357
+ "billing cadence must be positive and 1 hour long duration at least",
358
+ models.WithFieldString("billingCadence"),
359
+ models.WithWarningSeverity(),
360
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
361
+ )
362
+
363
+ const ErrCodeRateCardMultipleBillingCadence models.ErrorCode = "rate_card_multiple_billing_cadence"
364
+
365
+ var ErrRateCardMultipleBillingCadence = models.NewValidationIssue(
366
+ ErrCodeRateCardMultipleBillingCadence,
367
+ "ratecards with prices must have the exact same billing cadence",
368
+ models.WithFieldString("billingCadence"),
369
+ models.WithWarningSeverity(),
370
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
371
+ )
372
+
373
+ const ErrCodeRateCardBillingCadenceUnaligned models.ErrorCode = "rate_card_billing_cadence_unaligned"
374
+
375
+ var ErrRateCardBillingCadenceUnaligned = models.NewValidationIssue(
376
+ ErrCodeRateCardBillingCadenceUnaligned,
377
+ "ratecards with prices must have compatible billing cadence",
378
+ models.WithFieldString("billingCadence"),
379
+ models.WithWarningSeverity(),
380
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
381
+ )
382
+
383
+ const ErrCodeRateCardUsageBasedPriceWithNoFeature models.ErrorCode = "usage_based_price_with_no_feature"
384
+
385
+ var ErrRateCardUsageBasedPriceWithNoFeature = models.NewValidationIssue(
386
+ ErrCodeRateCardUsageBasedPriceWithNoFeature,
387
+ "usage-based price requires feature to be associated with",
388
+ models.WithFieldString("featureKey"),
389
+ models.WithWarningSeverity(),
390
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
391
+ )
392
+
393
+ const ErrCodeRateCardUnitConfigRequiresUsageBasedPrice models.ErrorCode = "unit_config_requires_usage_based_price"
394
+
395
+ var ErrRateCardUnitConfigRequiresUsageBasedPrice = models.NewValidationIssue(
396
+ ErrCodeRateCardUnitConfigRequiresUsageBasedPrice,
397
+ "unit config requires a usage-based price (unit, graduated, or volume)",
398
+ models.WithFieldString("unit_config"),
399
+ models.WithWarningSeverity(),
400
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
401
+ )
402
+
403
+ const ErrCodeUnitConfigNotRepresentable models.ErrorCode = "unit_config_not_representable"
404
+
405
+ var ErrUnitConfigNotRepresentable = models.NewValidationIssue(
406
+ ErrCodeUnitConfigNotRepresentable,
407
+ "this resource uses unit_config and is only available via the v3 API",
408
+ models.WithCriticalSeverity(),
409
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
410
+ )
411
+
412
+ const ErrCodeRateCardUsageBasedPriceWithFeatureAndNoMeter models.ErrorCode = "usage_based_price_with_feature_and_no_meter"
413
+
414
+ var ErrRateCardUsageBasedPriceWithFeatureAndNoMeter = models.NewValidationIssue(
415
+ ErrCodeRateCardUsageBasedPriceWithFeatureAndNoMeter,
416
+ "usage-based price requires feature with meter to be associated with",
417
+ models.WithFieldString("featureKey"),
418
+ models.WithWarningSeverity(),
419
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
420
+ )
421
+
422
+ // Addon errors
423
+
424
+ const ErrCodeAddonKeyEmpty models.ErrorCode = "addon_key_empty"
425
+
426
+ var ErrAddonKeyEmpty = models.NewValidationIssue(
427
+ ErrCodeAddonKeyEmpty,
428
+ "key must not be empty",
429
+ models.WithFieldString("key"),
430
+ models.WithCriticalSeverity(),
431
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
432
+ )
433
+
434
+ const ErrCodeAddonNameEmpty models.ErrorCode = "addon_name_empty"
435
+
436
+ var ErrAddonNameEmpty = models.NewValidationIssue(
437
+ ErrCodeAddonNameEmpty,
438
+ "name must not be empty",
439
+ models.WithFieldString("name"),
440
+ models.WithWarningSeverity(),
441
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
442
+ )
443
+
444
+ const ErrCodeAddonInvalidInstanceType models.ErrorCode = "addon_invalid_instance_type"
445
+
446
+ var ErrAddonInvalidInstanceType = models.NewValidationIssue(
447
+ ErrCodeAddonInvalidInstanceType,
448
+ "invalid instance type",
449
+ models.WithFieldString("instanceType"),
450
+ models.WithCriticalSeverity(),
451
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
452
+ )
453
+
454
+ const ErrCodeAddonInvalidStatus models.ErrorCode = "addon_invalid_status"
455
+
456
+ var ErrAddonInvalidStatus = models.NewValidationIssue(
457
+ ErrCodeAddonInvalidStatus,
458
+ "invalid status",
459
+ models.WithFieldString("status"),
460
+ models.WithCriticalSeverity(),
461
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
462
+ )
463
+
464
+ const ErrCodeAddonInvalidStatusForPublish models.ErrorCode = "addon_invalid_status_for_publish"
465
+
466
+ var ErrAddonInvalidStatusForPublish = models.NewValidationIssue(
467
+ ErrCodeAddonInvalidStatusForPublish,
468
+ "only draft add-ons can be published",
469
+ models.WithFieldString("status"),
470
+ models.WithCriticalSeverity(),
471
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
472
+ )
473
+
474
+ const ErrCodeAddonInvalidPriceForMultiInstance models.ErrorCode = "addon_invalid_ratecard_price_for_multi_instance"
475
+
476
+ var ErrAddonInvalidPriceForMultiInstance = models.NewValidationIssue(
477
+ ErrCodeAddonInvalidPriceForMultiInstance,
478
+ "only free or flat price ratecards are allowed for add-on with multiple instance type",
479
+ models.WithFieldString("price"),
480
+ models.WithWarningSeverity(),
481
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
482
+ )
483
+
484
+ const ErrCodeAddonHasNoRateCards models.ErrorCode = "addon_has_no_rate_cards"
485
+
486
+ var ErrAddonHasNoRateCards = models.NewValidationIssue(
487
+ ErrCodeAddonHasNoRateCards,
488
+ "add-on must have at least one rate card",
489
+ models.WithFieldString("rateCards"),
490
+ models.WithWarningSeverity(),
491
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
492
+ )
493
+
494
+ // Generic errors
495
+
496
+ const ErrCodeResourceKeyEmpty models.ErrorCode = "resource_key_empty"
497
+
498
+ var ErrResourceKeyEmpty = models.NewValidationIssue(
499
+ ErrCodeResourceKeyEmpty,
500
+ "key must not be empty",
501
+ models.WithFieldString("key"),
502
+ models.WithCriticalSeverity(),
503
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
504
+ )
505
+
506
+ const ErrCodeResourceNameEmpty models.ErrorCode = "resource_name_empty"
507
+
508
+ var ErrResourceNameEmpty = models.NewValidationIssue(
509
+ ErrCodeResourceNameEmpty,
510
+ "name must not be empty",
511
+ models.WithFieldString("name"),
512
+ models.WithCriticalSeverity(),
513
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
514
+ )
515
+
516
+ const ErrCodeNamespaceEmpty models.ErrorCode = "resource_namespace_empty"
517
+
518
+ var ErrNamespaceEmpty = models.NewValidationIssue(
519
+ ErrCodeNamespaceEmpty,
520
+ "namespace must not be empty",
521
+ models.WithFieldString("namespace"),
522
+ models.WithCriticalSeverity(),
523
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
524
+ )
525
+
526
+ const ErrCodeIDEmpty models.ErrorCode = "resource_id_empty"
527
+
528
+ var ErrIDEmpty = models.NewValidationIssue(
529
+ ErrCodeIDEmpty,
530
+ "id must not be empty",
531
+ models.WithFieldString("id"),
532
+ models.WithCriticalSeverity(),
533
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
534
+ )
535
+
536
+ // Alignment errors
537
+
538
+ const ErrCodeDeprecatedUnalignedSubscription models.ErrorCode = "deprecated_unaligned_subscription"
539
+
540
+ var ErrDeprecatedUnalignedSubscription = models.NewValidationIssue(
541
+ ErrCodeDeprecatedUnalignedSubscription,
542
+ "unaligned subscriptions are being deprecated",
543
+ models.WithWarningSeverity(),
544
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
545
+ )
546
+
547
+ // Plan errors
548
+
549
+ var ErrPlanBillingCadenceAllowedValues = []datetime.ISODurationString{
550
+ "P1W",
551
+ "P2W",
552
+ "P4W",
553
+ "P1M",
554
+ "P3M",
555
+ "P6M",
556
+ "P12M",
557
+ "P1Y",
558
+ }
559
+
560
+ const ErrCodePlanBillingCadenceInvalid models.ErrorCode = "plan_billing_cadence_invalid"
561
+
562
+ var ErrPlanBillingCadenceInvalid = models.NewValidationIssue(
563
+ ErrCodePlanBillingCadenceInvalid,
564
+ fmt.Sprintf("billing cadence must be one of the following: %s", strings.Join(lo.Map(ErrPlanBillingCadenceAllowedValues, func(v datetime.ISODurationString, _ int) string { return v.String() }), ", ")),
565
+ models.WithFieldString("billingCadence"),
566
+ models.WithCriticalSeverity(),
567
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
568
+ )
569
+
570
+ const ErrCodePlanPhaseWithNegativeDuration models.ErrorCode = "plan_phase_with_negative_duration"
571
+
572
+ var ErrPlanPhaseWithNegativeDuration = models.NewValidationIssue(
573
+ ErrCodePlanPhaseWithNegativeDuration,
574
+ "duration must be positive",
575
+ models.WithFieldString("duration"),
576
+ models.WithWarningSeverity(),
577
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
578
+ )
579
+
580
+ const ErrCodePlanPhaseDurationLessThenAnHour models.ErrorCode = "plan_phase_duration_less_then_an_hour"
581
+
582
+ var ErrPlanPhaseDurationLessThenAnHour = models.NewValidationIssue(
583
+ ErrCodePlanPhaseDurationLessThenAnHour,
584
+ "duration must be at least 1 hour",
585
+ models.WithFieldString("duration"),
586
+ models.WithWarningSeverity(),
587
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
588
+ )
589
+
590
+ const ErrCodePlanPhaseDuplicatedKey models.ErrorCode = "plan_phase_duplicated_key"
591
+
592
+ var ErrPlanPhaseDuplicatedKey = models.NewValidationIssue(
593
+ ErrCodePlanPhaseDuplicatedKey,
594
+ "duplicated key",
595
+ models.WithFieldString("key"),
596
+ models.WithCriticalSeverity(),
597
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
598
+ )
599
+
600
+ const ErrCodePlanInvalidStatus models.ErrorCode = "plan_invalid_status"
601
+
602
+ var ErrPlanInvalidStatus = models.NewValidationIssue(
603
+ ErrCodePlanInvalidStatus,
604
+ "invalid status",
605
+ models.WithFieldString("status"),
606
+ models.WithCriticalSeverity(),
607
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
608
+ )
609
+
610
+ const ErrCodePlanWithNoPhases models.ErrorCode = "plan_with_no_phases"
611
+
612
+ var ErrPlanWithNoPhases = models.NewValidationIssue(
613
+ ErrCodePlanWithNoPhases,
614
+ "plan must have at least one phase",
615
+ models.WithFieldString("phases"),
616
+ models.WithWarningSeverity(),
617
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
618
+ )
619
+
620
+ const ErrCodePlanHasNonLastPhaseWithNoDuration models.ErrorCode = "plan_has_non_last_phase_with_no_duration"
621
+
622
+ var ErrPlanHasNonLastPhaseWithNoDuration = models.NewValidationIssue(
623
+ ErrCodePlanHasNonLastPhaseWithNoDuration,
624
+ "duration must be set for plan phase if it is not the last one",
625
+ models.WithFieldString("duration"),
626
+ models.WithWarningSeverity(),
627
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
628
+ )
629
+
630
+ const ErrCodePlanHasLastPhaseWithDuration models.ErrorCode = "plan_has_last_phase_with_duration"
631
+
632
+ var ErrPlanHasLastPhaseWithDuration = models.NewValidationIssue(
633
+ ErrCodePlanHasLastPhaseWithDuration,
634
+ "duration must not be set for the last plan phase",
635
+ models.WithFieldString("duration"),
636
+ models.WithWarningSeverity(),
637
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
638
+ )
639
+
640
+ const ErrCodePlanPhaseHasNoRateCards models.ErrorCode = "plan_phase_has_no_rate_cards"
641
+
642
+ var ErrPlanPhaseHasNoRateCards = models.NewValidationIssue(
643
+ ErrCodePlanPhaseHasNoRateCards,
644
+ "plan phase must have at least one rate card",
645
+ models.WithFieldString("rateCards"),
646
+ models.WithWarningSeverity(),
647
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
648
+ )
649
+
650
+ const ErrCodePlanHasIncompatibleAddon models.ErrorCode = "plan_has_incompatible_addon"
651
+
652
+ var ErrPlanHasIncompatibleAddon = models.NewValidationIssue(
653
+ ErrCodePlanHasIncompatibleAddon,
654
+ "plan has incompatible add-on assignment",
655
+ models.WithWarningSeverity(),
656
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
657
+ )
658
+
659
+ const ErrCodePlanBillingCadenceNotCompatible models.ErrorCode = "plan_billing_cadence_not_compatible"
660
+
661
+ var ErrPlanBillingCadenceNotCompatible = models.NewValidationIssue(
662
+ ErrCodePlanBillingCadenceNotCompatible,
663
+ "plan billing cadence is not compatible with rate card billing cadence",
664
+ models.WithFieldString("billingCadence"),
665
+ models.WithWarningSeverity(),
666
+ commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
667
+ )
openmeter/productcatalog/feature/connector.go ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package feature
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "slices"
8
+
9
+ "github.com/oapi-codegen/nullable"
10
+ "github.com/oklog/ulid/v2"
11
+ "github.com/samber/lo"
12
+
13
+ meterpkg "github.com/openmeterio/openmeter/openmeter/meter"
14
+ "github.com/openmeterio/openmeter/openmeter/watermill/eventbus"
15
+ "github.com/openmeterio/openmeter/pkg/clock"
16
+ "github.com/openmeterio/openmeter/pkg/filter"
17
+ "github.com/openmeterio/openmeter/pkg/models"
18
+ "github.com/openmeterio/openmeter/pkg/pagination"
19
+ "github.com/openmeterio/openmeter/pkg/ref"
20
+ "github.com/openmeterio/openmeter/pkg/sortx"
21
+ )
22
+
23
+ type CreateFeatureInputs struct {
24
+ Name string `json:"name"`
25
+ Description *string `json:"description,omitempty"`
26
+ Key string `json:"key"`
27
+ Namespace string `json:"namespace"`
28
+ MeterID *string `json:"meterID"`
29
+ MeterGroupByFilters MeterGroupByFilters `json:"meterGroupByFilters"`
30
+ UnitCost *UnitCost `json:"unitCost"`
31
+ Metadata map[string]string `json:"metadata"`
32
+ }
33
+
34
+ type UpdateFeatureInputs struct {
35
+ Namespace string `json:"namespace"`
36
+ ID string `json:"id"`
37
+ UnitCost nullable.Nullable[UnitCost] `json:"unitCost"`
38
+ }
39
+
40
+ func (i UpdateFeatureInputs) Validate() error {
41
+ var errs []error
42
+
43
+ if i.Namespace == "" {
44
+ errs = append(errs, errors.New("namespace is required"))
45
+ }
46
+
47
+ if i.ID == "" {
48
+ errs = append(errs, errors.New("id is required"))
49
+ }
50
+
51
+ if !i.UnitCost.IsSpecified() {
52
+ errs = append(errs, errors.New("unitCost is required"))
53
+ }
54
+
55
+ return models.NewNillableGenericValidationError(errors.Join(errs...))
56
+ }
57
+
58
+ // TODO: refactor to service pattern
59
+ type FeatureConnector interface {
60
+ // Feature Management
61
+ CreateFeature(ctx context.Context, feature CreateFeatureInputs) (Feature, error)
62
+ UpdateFeature(ctx context.Context, input UpdateFeatureInputs) (Feature, error)
63
+ // Should just use deletedAt, there's no real "archiving"
64
+ ArchiveFeature(ctx context.Context, featureID models.NamespacedID) error
65
+ ListFeatures(ctx context.Context, params ListFeaturesParams) (pagination.Result[Feature], error)
66
+ GetFeature(ctx context.Context, namespace string, idOrKey string, includeArchived IncludeArchivedFeature) (*Feature, error)
67
+
68
+ // ResolveFeatureMeters resolves the feature meters for a given namespace and feature refs.
69
+ // Keys always resolve to the latest available feature for that key.
70
+ // Explicit IDs are returned in the ID index, and also in the key index when they are the latest feature for that key.
71
+ ResolveFeatureMeters(ctx context.Context, namespace string, featureRefs ...ref.IDOrKey) (FeatureMeters, error)
72
+ }
73
+
74
+ type IncludeArchivedFeature bool
75
+
76
+ const (
77
+ IncludeArchivedFeatureTrue IncludeArchivedFeature = true
78
+ IncludeArchivedFeatureFalse IncludeArchivedFeature = false
79
+ )
80
+
81
+ // FeatureOrderBy is the order by clause for features
82
+ type FeatureOrderBy string
83
+
84
+ const (
85
+ FeatureOrderByKey FeatureOrderBy = "key"
86
+ FeatureOrderByName FeatureOrderBy = "name"
87
+ FeatureOrderByCreatedAt FeatureOrderBy = "created_at"
88
+ FeatureOrderByUpdatedAt FeatureOrderBy = "updated_at"
89
+ )
90
+
91
+ func (f FeatureOrderBy) Values() []FeatureOrderBy {
92
+ return []FeatureOrderBy{
93
+ FeatureOrderByKey,
94
+ FeatureOrderByName,
95
+ FeatureOrderByCreatedAt,
96
+ FeatureOrderByUpdatedAt,
97
+ }
98
+ }
99
+
100
+ func (f FeatureOrderBy) Validate() error {
101
+ if !slices.Contains(f.Values(), f) {
102
+ return models.NewGenericValidationError(fmt.Errorf("invalid feature order by: %s", f))
103
+ }
104
+
105
+ return nil
106
+ }
107
+
108
+ type ListFeaturesParams struct {
109
+ IDsOrKeys []string
110
+ Key *filter.FilterString
111
+ Name *filter.FilterString
112
+ Namespace string
113
+ MeterIDs *filter.FilterULID
114
+ MeterSlugs []string // Kept for ingest pipeline compat (queries via ent edge on meter key)
115
+ IncludeArchived bool
116
+ Page pagination.Page
117
+ OrderBy FeatureOrderBy
118
+ Order sortx.Order
119
+ // will be deprecated
120
+ Limit int
121
+ // will be deprecated
122
+ Offset int
123
+ }
124
+
125
+ func (p ListFeaturesParams) Validate() error {
126
+ var errs []error
127
+
128
+ if p.Namespace == "" {
129
+ errs = append(errs, errors.New("namespace is required"))
130
+ }
131
+ if p.MeterIDs != nil {
132
+ if err := p.MeterIDs.Validate(); err != nil {
133
+ errs = append(errs, err)
134
+ }
135
+ }
136
+ if !p.Page.IsZero() {
137
+ if err := p.Page.Validate(); err != nil {
138
+ errs = append(errs, err)
139
+ }
140
+ }
141
+ if p.Key != nil {
142
+ if err := p.Key.Validate(); err != nil {
143
+ errs = append(errs, err)
144
+ }
145
+ }
146
+ if p.Name != nil {
147
+ if err := p.Name.Validate(); err != nil {
148
+ errs = append(errs, err)
149
+ }
150
+ }
151
+ if p.OrderBy != "" {
152
+ if err := p.OrderBy.Validate(); err != nil {
153
+ errs = append(errs, err)
154
+ }
155
+ }
156
+
157
+ return models.NewNillableGenericValidationError(errors.Join(errs...))
158
+ }
159
+
160
+ type featureConnector struct {
161
+ featureRepo FeatureRepo
162
+ meterService meterpkg.Service
163
+ publisher eventbus.Publisher
164
+
165
+ validMeterAggregations []meterpkg.MeterAggregation
166
+ }
167
+
168
+ func NewFeatureConnector(
169
+ featureRepo FeatureRepo,
170
+ meterService meterpkg.Service,
171
+ publisher eventbus.Publisher,
172
+ ) FeatureConnector {
173
+ return &featureConnector{
174
+ featureRepo: featureRepo,
175
+ meterService: meterService,
176
+ publisher: publisher,
177
+
178
+ validMeterAggregations: []meterpkg.MeterAggregation{
179
+ meterpkg.MeterAggregationSum,
180
+ meterpkg.MeterAggregationCount,
181
+ meterpkg.MeterAggregationUniqueCount,
182
+ meterpkg.MeterAggregationLatest,
183
+ },
184
+ }
185
+ }
186
+
187
+ // CreateFeature creates a new feature
188
+ func (c *featureConnector) CreateFeature(ctx context.Context, feature CreateFeatureInputs) (Feature, error) {
189
+ // Validate meter configuration
190
+ var resolvedMeter *meterpkg.Meter
191
+
192
+ if feature.MeterID != nil {
193
+ meterID := *feature.MeterID
194
+
195
+ // nosemgrep: trailofbits.go.invalid-usage-of-modified-variable.invalid-usage-of-modified-variable
196
+ meter, err := c.meterService.GetMeterByIDOrSlug(ctx, meterpkg.GetMeterInput{
197
+ Namespace: feature.Namespace,
198
+ IDOrSlug: meterID,
199
+ })
200
+ if err != nil {
201
+ if meterpkg.IsMeterNotFoundError(err) {
202
+ return Feature{}, meterpkg.NewMeterNotFoundError(meterID)
203
+ }
204
+ return Feature{}, fmt.Errorf("get meter %s: %w", meterID, err)
205
+ }
206
+
207
+ // Normalize to meter ID
208
+ feature.MeterID = &meter.ID
209
+
210
+ resolvedMeter = &meter
211
+
212
+ if !slices.Contains(c.validMeterAggregations, meter.Aggregation) {
213
+ return Feature{}, &FeatureInvalidMeterAggregationError{Aggregation: meter.Aggregation, MeterSlug: meter.Key, ValidAggregations: c.validMeterAggregations}
214
+ }
215
+
216
+ if feature.MeterGroupByFilters != nil {
217
+ if err = feature.MeterGroupByFilters.Validate(meter); err != nil {
218
+ return Feature{}, err
219
+ }
220
+ }
221
+ }
222
+
223
+ // Validate unit cost
224
+ if feature.UnitCost != nil {
225
+ if err := feature.UnitCost.Validate(); err != nil {
226
+ return Feature{}, models.NewGenericValidationError(err)
227
+ }
228
+
229
+ if feature.UnitCost.Type == UnitCostTypeLLM {
230
+ if resolvedMeter == nil {
231
+ return Feature{}, models.NewGenericValidationError(
232
+ fmt.Errorf("LLM unit cost requires a meter to be associated with the feature"),
233
+ )
234
+ }
235
+
236
+ if err := feature.UnitCost.ValidateWithMeter(*resolvedMeter); err != nil {
237
+ return Feature{}, models.NewGenericValidationError(err)
238
+ }
239
+ }
240
+ }
241
+
242
+ // Validate feature key
243
+ if _, err := ulid.Parse(feature.Key); err == nil {
244
+ return Feature{}, models.NewGenericValidationError(fmt.Errorf("Feature key cannot be a valid ULID"))
245
+ }
246
+
247
+ // Check key is not taken
248
+ found, err := c.featureRepo.GetByIdOrKey(ctx, feature.Namespace, feature.Key, false)
249
+ if err != nil {
250
+ if _, ok := err.(*FeatureNotFoundError); !ok {
251
+ return Feature{}, err
252
+ }
253
+ } else {
254
+ return Feature{}, &FeatureWithNameAlreadyExistsError{Name: feature.Key, ID: found.ID}
255
+ }
256
+
257
+ // Create the feature
258
+ createdFeature, err := c.featureRepo.CreateFeature(ctx, feature)
259
+ if err != nil {
260
+ return Feature{}, err
261
+ }
262
+
263
+ // Populate MeterSlug from resolved meter for v1 API backward compat
264
+ if resolvedMeter != nil {
265
+ createdFeature.MeterSlug = &resolvedMeter.Key
266
+ }
267
+
268
+ // Publish the feature created event
269
+ featureCreatedEvent := NewFeatureCreateEvent(ctx, &createdFeature)
270
+ if err := c.publisher.Publish(ctx, featureCreatedEvent); err != nil {
271
+ return createdFeature, fmt.Errorf("failed to publish feature created event: %w", err)
272
+ }
273
+
274
+ return createdFeature, nil
275
+ }
276
+
277
+ // UpdateFeature updates a feature's unit cost
278
+ func (c *featureConnector) UpdateFeature(ctx context.Context, input UpdateFeatureInputs) (Feature, error) {
279
+ if err := input.Validate(); err != nil {
280
+ return Feature{}, err
281
+ }
282
+
283
+ // Get the feature (rejects archived/not found)
284
+ feat, err := c.GetFeature(ctx, input.Namespace, input.ID, IncludeArchivedFeatureFalse)
285
+ if err != nil {
286
+ return Feature{}, err
287
+ }
288
+
289
+ // Validate unit cost if a value is provided (not null/clear)
290
+ if !input.UnitCost.IsNull() {
291
+ unitCost, err := input.UnitCost.Get()
292
+ if err != nil {
293
+ return Feature{}, models.NewGenericValidationError(err)
294
+ }
295
+
296
+ if err := unitCost.Validate(); err != nil {
297
+ return Feature{}, models.NewGenericValidationError(err)
298
+ }
299
+
300
+ if unitCost.Type == UnitCostTypeLLM {
301
+ if feat.MeterSlug == nil {
302
+ return Feature{}, models.NewGenericValidationError(
303
+ fmt.Errorf("LLM unit cost requires a meter to be associated with the feature"),
304
+ )
305
+ }
306
+
307
+ meter, err := c.meterService.GetMeterByIDOrSlug(ctx, meterpkg.GetMeterInput{
308
+ Namespace: input.Namespace,
309
+ IDOrSlug: *feat.MeterSlug,
310
+ })
311
+ if err != nil {
312
+ return Feature{}, err
313
+ }
314
+
315
+ if err := unitCost.ValidateWithMeter(meter); err != nil {
316
+ return Feature{}, models.NewGenericValidationError(err)
317
+ }
318
+ }
319
+ }
320
+
321
+ updatedFeature, err := c.featureRepo.UpdateFeature(ctx, input)
322
+ if err != nil {
323
+ return Feature{}, err
324
+ }
325
+
326
+ // Publish the feature updated event
327
+ featureUpdatedEvent := NewFeatureUpdateEvent(ctx, &updatedFeature)
328
+ if err := c.publisher.Publish(ctx, featureUpdatedEvent); err != nil {
329
+ return updatedFeature, fmt.Errorf("failed to publish feature updated event: %w", err)
330
+ }
331
+
332
+ return updatedFeature, nil
333
+ }
334
+
335
+ // ArchiveFeature archives a feature
336
+ func (c *featureConnector) ArchiveFeature(ctx context.Context, featureID models.NamespacedID) error {
337
+ // Get the feature
338
+ feat, err := c.GetFeature(ctx, featureID.Namespace, featureID.ID, false)
339
+ if err != nil {
340
+ return err
341
+ }
342
+
343
+ archivedAt := lo.ToPtr(clock.Now())
344
+
345
+ // Archive the feature
346
+ err = c.featureRepo.ArchiveFeature(ctx, ArchiveFeatureInput{
347
+ Namespace: feat.Namespace,
348
+ ID: feat.ID,
349
+ At: archivedAt,
350
+ })
351
+ if err != nil {
352
+ return err
353
+ }
354
+
355
+ feat.ArchivedAt = archivedAt
356
+
357
+ // Publish the feature archived event
358
+ featureArchivedEvent := NewFeatureArchiveEvent(ctx, feat)
359
+ if err := c.publisher.Publish(ctx, featureArchivedEvent); err != nil {
360
+ return fmt.Errorf("failed to publish feature archived event: %w", err)
361
+ }
362
+
363
+ return nil
364
+ }
365
+
366
+ // ListFeatures lists features
367
+ func (c *featureConnector) ListFeatures(ctx context.Context, params ListFeaturesParams) (pagination.Result[Feature], error) {
368
+ if err := params.Validate(); err != nil {
369
+ return pagination.Result[Feature]{}, err
370
+ }
371
+
372
+ return c.featureRepo.ListFeatures(ctx, params)
373
+ }
374
+
375
+ // GetFeature gets a feature
376
+ func (c *featureConnector) GetFeature(ctx context.Context, namespace string, idOrKey string, includeArchived IncludeArchivedFeature) (*Feature, error) {
377
+ feature, err := c.featureRepo.GetByIdOrKey(ctx, namespace, idOrKey, bool(includeArchived))
378
+ if err != nil {
379
+ return nil, err
380
+ }
381
+ return feature, nil
382
+ }
openmeter/productcatalog/feature/connector_test.go ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package feature
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/alpacahq/alpacadecimal"
7
+ "github.com/oapi-codegen/nullable"
8
+ "github.com/stretchr/testify/assert"
9
+ )
10
+
11
+ func TestUpdateFeatureInputsValidate(t *testing.T) {
12
+ validUnitCost := nullable.NewNullableWithValue(UnitCost{
13
+ Type: UnitCostTypeManual,
14
+ Manual: &ManualUnitCost{
15
+ Amount: alpacadecimal.NewFromFloat(0.05),
16
+ },
17
+ })
18
+
19
+ t.Run("valid with unit cost", func(t *testing.T) {
20
+ input := UpdateFeatureInputs{
21
+ Namespace: "ns",
22
+ ID: "feat-1",
23
+ UnitCost: validUnitCost,
24
+ }
25
+ assert.NoError(t, input.Validate())
26
+ })
27
+
28
+ t.Run("valid with clear unit cost (null)", func(t *testing.T) {
29
+ input := UpdateFeatureInputs{
30
+ Namespace: "ns",
31
+ ID: "feat-1",
32
+ UnitCost: nullable.NewNullNullable[UnitCost](),
33
+ }
34
+ assert.NoError(t, input.Validate())
35
+ })
36
+
37
+ t.Run("invalid without unit cost specified", func(t *testing.T) {
38
+ input := UpdateFeatureInputs{
39
+ Namespace: "ns",
40
+ ID: "feat-1",
41
+ }
42
+ err := input.Validate()
43
+ assert.Error(t, err)
44
+ assert.Contains(t, err.Error(), "unitCost is required")
45
+ })
46
+
47
+ t.Run("invalid missing namespace", func(t *testing.T) {
48
+ input := UpdateFeatureInputs{
49
+ ID: "feat-1",
50
+ UnitCost: validUnitCost,
51
+ }
52
+ err := input.Validate()
53
+ assert.Error(t, err)
54
+ assert.Contains(t, err.Error(), "namespace is required")
55
+ })
56
+
57
+ t.Run("invalid missing id", func(t *testing.T) {
58
+ input := UpdateFeatureInputs{
59
+ Namespace: "ns",
60
+ UnitCost: validUnitCost,
61
+ }
62
+ err := input.Validate()
63
+ assert.Error(t, err)
64
+ assert.Contains(t, err.Error(), "id is required")
65
+ })
66
+ }
openmeter/productcatalog/feature/event.go ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package feature
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+
8
+ "github.com/oklog/ulid/v2"
9
+
10
+ "github.com/openmeterio/openmeter/openmeter/event/metadata"
11
+ "github.com/openmeterio/openmeter/openmeter/session"
12
+ )
13
+
14
+ const (
15
+ FeatureEventSubsystem metadata.EventSubsystem = "feature"
16
+ FeatureCreateEventName metadata.EventName = "feature.created"
17
+ FeatureUpdateEventName metadata.EventName = "feature.updated"
18
+ FeatureArchiveEventName metadata.EventName = "feature.archived"
19
+ )
20
+
21
+ // NewFeatureCreateEvent creates a new feature create event
22
+ func NewFeatureCreateEvent(ctx context.Context, feature *Feature) FeatureCreateEvent {
23
+ return FeatureCreateEvent{
24
+ Feature: feature,
25
+ UserID: session.GetSessionUserID(ctx),
26
+ }
27
+ }
28
+
29
+ // FeatureCreateEvent is an event that is emitted when a feature is created
30
+ type FeatureCreateEvent struct {
31
+ Feature *Feature `json:"feature"`
32
+ UserID *string `json:"userId,omitempty"`
33
+ }
34
+
35
+ func (e FeatureCreateEvent) EventName() string {
36
+ return metadata.GetEventName(metadata.EventType{
37
+ Subsystem: FeatureEventSubsystem,
38
+ Name: FeatureCreateEventName,
39
+ Version: "v1",
40
+ })
41
+ }
42
+
43
+ func (e FeatureCreateEvent) EventMetadata() metadata.EventMetadata {
44
+ resourcePath := metadata.ComposeResourcePath(e.Feature.Namespace, metadata.EntityFeature, e.Feature.ID)
45
+
46
+ return metadata.EventMetadata{
47
+ ID: ulid.Make().String(),
48
+ Source: resourcePath,
49
+ Subject: resourcePath,
50
+ Time: e.Feature.CreatedAt,
51
+ }
52
+ }
53
+
54
+ func (e FeatureCreateEvent) Validate() error {
55
+ var errs []error
56
+
57
+ if e.Feature == nil {
58
+ return fmt.Errorf("feature is required")
59
+ }
60
+
61
+ if err := e.Feature.Validate(); err != nil {
62
+ errs = append(errs, fmt.Errorf("feature: %w", err))
63
+ }
64
+
65
+ return errors.Join(errs...)
66
+ }
67
+
68
+ // NewFeatureUpdateEvent creates a new feature update event
69
+ func NewFeatureUpdateEvent(ctx context.Context, feature *Feature) FeatureUpdateEvent {
70
+ return FeatureUpdateEvent{
71
+ Feature: feature,
72
+ UserID: session.GetSessionUserID(ctx),
73
+ }
74
+ }
75
+
76
+ // FeatureUpdateEvent is an event that is emitted when a feature is updated
77
+ type FeatureUpdateEvent struct {
78
+ Feature *Feature `json:"feature"`
79
+ UserID *string `json:"userId,omitempty"`
80
+ }
81
+
82
+ func (e FeatureUpdateEvent) EventName() string {
83
+ return metadata.GetEventName(metadata.EventType{
84
+ Subsystem: FeatureEventSubsystem,
85
+ Name: FeatureUpdateEventName,
86
+ Version: "v1",
87
+ })
88
+ }
89
+
90
+ func (e FeatureUpdateEvent) EventMetadata() metadata.EventMetadata {
91
+ resourcePath := metadata.ComposeResourcePath(e.Feature.Namespace, metadata.EntityFeature, e.Feature.ID)
92
+
93
+ return metadata.EventMetadata{
94
+ ID: ulid.Make().String(),
95
+ Source: resourcePath,
96
+ Subject: resourcePath,
97
+ Time: e.Feature.UpdatedAt,
98
+ }
99
+ }
100
+
101
+ func (e FeatureUpdateEvent) Validate() error {
102
+ var errs []error
103
+
104
+ if e.Feature == nil {
105
+ return fmt.Errorf("feature is required")
106
+ }
107
+
108
+ if err := e.Feature.Validate(); err != nil {
109
+ errs = append(errs, fmt.Errorf("feature: %w", err))
110
+ }
111
+
112
+ return errors.Join(errs...)
113
+ }
114
+
115
+ // NewFeatureArchiveEvent creates a new feature delete event
116
+ func NewFeatureArchiveEvent(ctx context.Context, feature *Feature) FeatureArchiveEvent {
117
+ return FeatureArchiveEvent{
118
+ Feature: feature,
119
+ UserID: session.GetSessionUserID(ctx),
120
+ }
121
+ }
122
+
123
+ // FeatureArchiveEvent is an event that is emitted when a feature is archived
124
+ type FeatureArchiveEvent struct {
125
+ Feature *Feature `json:"feature"`
126
+ UserID *string `json:"userId,omitempty"`
127
+ }
128
+
129
+ func (e FeatureArchiveEvent) EventName() string {
130
+ return metadata.GetEventName(metadata.EventType{
131
+ Subsystem: FeatureEventSubsystem,
132
+ Name: FeatureArchiveEventName,
133
+ Version: "v1",
134
+ })
135
+ }
136
+
137
+ func (e FeatureArchiveEvent) EventMetadata() metadata.EventMetadata {
138
+ resourcePath := metadata.ComposeResourcePath(e.Feature.Namespace, metadata.EntityFeature, e.Feature.ID)
139
+
140
+ return metadata.EventMetadata{
141
+ ID: ulid.Make().String(),
142
+ Source: resourcePath,
143
+ Subject: resourcePath,
144
+ Time: *e.Feature.ArchivedAt,
145
+ }
146
+ }
147
+
148
+ func (e FeatureArchiveEvent) Validate() error {
149
+ var errs []error
150
+
151
+ if e.Feature == nil {
152
+ return fmt.Errorf("feature is required")
153
+ }
154
+
155
+ if e.Feature.ArchivedAt == nil {
156
+ return fmt.Errorf("feature archived at is required")
157
+ }
158
+
159
+ if err := e.Feature.Validate(); err != nil {
160
+ errs = append(errs, fmt.Errorf("feature: %w", err))
161
+ }
162
+
163
+ return errors.Join(errs...)
164
+ }
openmeter/productcatalog/feature/feature.go ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package feature
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "time"
7
+
8
+ "github.com/openmeterio/openmeter/openmeter/meter"
9
+ "github.com/openmeterio/openmeter/pkg/filter"
10
+ )
11
+
12
+ type FeatureNotFoundError struct {
13
+ ID string
14
+ }
15
+
16
+ func (e *FeatureNotFoundError) Error() string {
17
+ return fmt.Sprintf("feature not found: %s", e.ID)
18
+ }
19
+
20
+ type FeatureInvalidFiltersError struct {
21
+ RequestedFilters MeterGroupByFilters
22
+ MeterGroupByColumns []string
23
+ }
24
+
25
+ func (e *FeatureInvalidFiltersError) Error() string {
26
+ return fmt.Sprintf("invalid filters for feature: %v, available columns: %v", e.RequestedFilters, e.MeterGroupByColumns)
27
+ }
28
+
29
+ type FeatureWithNameAlreadyExistsError struct {
30
+ Name string
31
+ ID string
32
+ }
33
+
34
+ func (e *FeatureWithNameAlreadyExistsError) Error() string {
35
+ // Is it an issue that we leak ID on another Feature here?
36
+ // Shouldn't be an isue as it's namespaced.
37
+ return fmt.Sprintf("feature %s with key %s already exists", e.ID, e.Name)
38
+ }
39
+
40
+ type FeatureInvalidMeterAggregationError struct {
41
+ MeterSlug string
42
+ Aggregation meter.MeterAggregation
43
+ ValidAggregations []meter.MeterAggregation
44
+ }
45
+
46
+ func (e *FeatureInvalidMeterAggregationError) Error() string {
47
+ validAggregations := ""
48
+ for i, validAggregation := range e.ValidAggregations {
49
+ if i > 0 {
50
+ validAggregations += ", "
51
+ }
52
+ validAggregations += string(validAggregation)
53
+ }
54
+ return fmt.Sprintf("meter %s's aggregation is %s but features can only be created for %s", e.MeterSlug, e.Aggregation, validAggregations)
55
+ }
56
+
57
+ type ForbiddenError struct {
58
+ Msg string
59
+ ID string
60
+ }
61
+
62
+ func (e *ForbiddenError) Error() string {
63
+ return fmt.Sprintf("forbidden for feature %s: %s", e.ID, e.Msg)
64
+ }
65
+
66
+ // MeterGroupByFilters is a map of filters that can be applied to a meter when querying the usage for a feature.
67
+ type MeterGroupByFilters map[string]filter.FilterString
68
+
69
+ func (f MeterGroupByFilters) Validate(meter meter.Meter) error {
70
+ for filterProp, filterValue := range f {
71
+ if _, ok := meter.GroupBy[filterProp]; !ok {
72
+ meterGroupByColumns := make([]string, 0, len(meter.GroupBy))
73
+ for k := range meter.GroupBy {
74
+ meterGroupByColumns = append(meterGroupByColumns, k)
75
+ }
76
+ return &FeatureInvalidFiltersError{
77
+ RequestedFilters: f,
78
+ MeterGroupByColumns: meterGroupByColumns,
79
+ }
80
+ }
81
+
82
+ if err := filterValue.Validate(); err != nil {
83
+ return err
84
+ }
85
+ }
86
+
87
+ return nil
88
+ }
89
+
90
+ // ConvertMapStringToMeterGroupByFilters converts a map[string]string legacy format to MeterGroupByFilters
91
+ func ConvertMapStringToMeterGroupByFilters(m map[string]string) MeterGroupByFilters {
92
+ if m == nil {
93
+ return MeterGroupByFilters{}
94
+ }
95
+
96
+ result := make(MeterGroupByFilters, len(m))
97
+ for k, v := range m {
98
+ result[k] = filter.FilterString{Eq: &v}
99
+ }
100
+
101
+ return result
102
+ }
103
+
104
+ // ConvertMeterGroupByFiltersToMapString converts a MeterGroupByFilters to a legacy map[string]string format
105
+ // if all filters are equality filters, otherwise returns nil.
106
+ func ConvertMeterGroupByFiltersToMapString(f MeterGroupByFilters) map[string]string {
107
+ if f == nil {
108
+ return nil
109
+ }
110
+
111
+ result := make(map[string]string, len(f))
112
+ for k, v := range f {
113
+ if v.Eq == nil {
114
+ return nil
115
+ }
116
+ result[k] = *v.Eq
117
+ }
118
+
119
+ return result
120
+ }
121
+
122
+ // Feature is a feature or service offered to a customer.
123
+ // For example: CPU-Hours, Tokens, API Calls, etc.
124
+ type Feature struct {
125
+ Namespace string `json:"namespace"`
126
+ ID string `json:"id,omitempty"`
127
+
128
+ // Name The name of the feature.
129
+ Name string `json:"name"`
130
+ // Description Optional description of the feature.
131
+ Description *string `json:"description,omitempty"`
132
+ // Key The unique key of the feature.
133
+ Key string `json:"key"`
134
+
135
+ // MeterID The ID of the meter that the feature is associated with.
136
+ MeterID *string `json:"meterID,omitempty"`
137
+
138
+ // Deprecated: MeterSlug is kept temporarily for v1 API backward compatibility. Use MeterID.
139
+ MeterSlug *string `json:"meterSlug,omitempty"`
140
+
141
+ // MeterGroupByFilters Optional meter group by filters. Useful if the meter scope is broader than what feature tracks.
142
+ MeterGroupByFilters MeterGroupByFilters `json:"meterGroupByFilters,omitempty"`
143
+
144
+ // UnitCost is an optional per-unit cost: either a fixed manual amount or dynamic LLM cost lookup.
145
+ UnitCost *UnitCost `json:"unitCost,omitempty"`
146
+
147
+ // Metadata Additional metadata.
148
+ Metadata map[string]string `json:"metadata,omitempty"`
149
+
150
+ // Read-only fields
151
+ ArchivedAt *time.Time `json:"archivedAt,omitempty"`
152
+
153
+ CreatedAt time.Time `json:"createdAt"`
154
+ UpdatedAt time.Time `json:"updatedAt"`
155
+ }
156
+
157
+ // Validate validates the feature.
158
+ func (f *Feature) Validate() error {
159
+ var errs []error
160
+
161
+ if f.Namespace == "" {
162
+ errs = append(errs, fmt.Errorf("namespace is required"))
163
+ }
164
+
165
+ if f.ID == "" {
166
+ errs = append(errs, fmt.Errorf("id is required"))
167
+ }
168
+
169
+ if f.Name == "" {
170
+ errs = append(errs, fmt.Errorf("name is required"))
171
+ }
172
+
173
+ if f.Key == "" {
174
+ errs = append(errs, fmt.Errorf("key is required"))
175
+ }
176
+
177
+ if f.MeterID != nil {
178
+ if *f.MeterID == "" {
179
+ errs = append(errs, fmt.Errorf("meter id cannot be empty"))
180
+ }
181
+ }
182
+
183
+ if f.CreatedAt.IsZero() {
184
+ errs = append(errs, fmt.Errorf("created at is required"))
185
+ }
186
+
187
+ if f.UpdatedAt.IsZero() {
188
+ errs = append(errs, fmt.Errorf("updated at is required"))
189
+ }
190
+
191
+ if f.UnitCost != nil {
192
+ if err := f.UnitCost.Validate(); err != nil {
193
+ errs = append(errs, fmt.Errorf("unit cost is invalid: %w", err))
194
+ }
195
+ }
196
+
197
+ return errors.Join(errs...)
198
+ }
openmeter/productcatalog/feature/featuremeter.go ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package feature
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "time"
7
+
8
+ "github.com/samber/lo"
9
+
10
+ "github.com/openmeterio/openmeter/openmeter/meter"
11
+ "github.com/openmeterio/openmeter/pkg/models"
12
+ "github.com/openmeterio/openmeter/pkg/ref"
13
+ )
14
+
15
+ type FeatureMeter struct {
16
+ Feature Feature
17
+ Meter *meter.Meter
18
+ }
19
+
20
+ type FeatureMeters interface {
21
+ Get(featureKey string, requireMeter bool) (FeatureMeter, error)
22
+ GetByID(featureID string, requireMeter bool) (FeatureMeter, error)
23
+ }
24
+
25
+ type FeatureMeterCollection struct {
26
+ ByKey map[string]FeatureMeter
27
+ ByID map[string]FeatureMeter
28
+ }
29
+
30
+ func (f FeatureMeterCollection) Get(featureKey string, requireMeter bool) (FeatureMeter, error) {
31
+ featureMeter, exists := f.ByKey[featureKey]
32
+ if !exists {
33
+ return FeatureMeter{}, models.NewGenericNotFoundError(fmt.Errorf("feature[%s] not found", featureKey))
34
+ }
35
+
36
+ if requireMeter && featureMeter.Meter == nil {
37
+ return FeatureMeter{}, models.NewGenericValidationError(fmt.Errorf("feature[%s] has no meter associated", featureMeter.Feature.Key))
38
+ }
39
+
40
+ return featureMeter, nil
41
+ }
42
+
43
+ func (f FeatureMeterCollection) GetByID(featureID string, requireMeter bool) (FeatureMeter, error) {
44
+ featureMeter, exists := f.ByID[featureID]
45
+ if !exists {
46
+ return FeatureMeter{}, models.NewGenericNotFoundError(fmt.Errorf("feature[%s] not found", featureID))
47
+ }
48
+
49
+ if requireMeter && featureMeter.Meter == nil {
50
+ return FeatureMeter{}, models.NewGenericValidationError(fmt.Errorf("feature[%s] has no meter associated", featureMeter.Feature.Key))
51
+ }
52
+
53
+ return featureMeter, nil
54
+ }
55
+
56
+ func (c *featureConnector) ResolveFeatureMeters(ctx context.Context, namespace string, featureRefs ...ref.IDOrKey) (FeatureMeters, error) {
57
+ if namespace == "" {
58
+ return nil, fmt.Errorf("namespace is required")
59
+ }
60
+
61
+ if len(featureRefs) == 0 {
62
+ return FeatureMeterCollection{
63
+ ByKey: map[string]FeatureMeter{},
64
+ ByID: map[string]FeatureMeter{},
65
+ }, nil
66
+ }
67
+
68
+ featuresToResolve := lo.Uniq(lo.FlatMap(featureRefs, func(featureRef ref.IDOrKey, _ int) []string {
69
+ out := featureRef.GetKeys()
70
+ out = append(out, featureRef.GetIDs()...)
71
+ return out
72
+ }))
73
+
74
+ // Let's resolve the features
75
+ features, err := c.featureRepo.ListFeatures(ctx, ListFeaturesParams{
76
+ IDsOrKeys: featuresToResolve,
77
+ Namespace: namespace,
78
+ IncludeArchived: true,
79
+ })
80
+ if err != nil {
81
+ return nil, fmt.Errorf("listing features: %w", err)
82
+ }
83
+
84
+ out := resolveFeatureMeters(features.Items)
85
+ if err := ensureFeatureIDsResolved(featureRefs, out); err != nil {
86
+ return nil, err
87
+ }
88
+
89
+ metersToResolve := lo.Uniq(
90
+ lo.Filter(
91
+ lo.Map(lo.Values(out.ByID), func(fm FeatureMeter, _ int) string {
92
+ f := fm.Feature
93
+ if f.MeterID == nil {
94
+ return ""
95
+ }
96
+
97
+ return *f.MeterID
98
+ }),
99
+ func(meterID string, _ int) bool {
100
+ return meterID != ""
101
+ },
102
+ ),
103
+ )
104
+
105
+ meters, err := c.meterService.ListMeters(ctx, meter.ListMetersParams{
106
+ IDFilter: lo.ToPtr(metersToResolve),
107
+ Namespace: namespace,
108
+ IncludeDeleted: true,
109
+ })
110
+ if err != nil {
111
+ return nil, fmt.Errorf("listing meters: %w", err)
112
+ }
113
+
114
+ metersByID := lo.SliceToMap(meters.Items, func(m meter.Meter) (string, meter.Meter) {
115
+ return m.ID, m
116
+ })
117
+
118
+ for featureID, featureMeter := range out.ByID {
119
+ if featureMeter.Feature.MeterID == nil {
120
+ out.ByID[featureID] = featureMeter
121
+ continue
122
+ }
123
+
124
+ meter, exists := metersByID[*featureMeter.Feature.MeterID]
125
+ if exists {
126
+ featureMeter.Meter = &meter
127
+ }
128
+
129
+ out.ByID[featureID] = featureMeter
130
+ if latest, ok := out.ByKey[featureMeter.Feature.Key]; ok && latest.Feature.ID == featureID {
131
+ out.ByKey[featureMeter.Feature.Key] = featureMeter
132
+ }
133
+ }
134
+
135
+ return out, nil
136
+ }
137
+
138
+ func resolveFeatureMeters(features []Feature) FeatureMeterCollection {
139
+ featuresByKey := getLastFeatures(features)
140
+
141
+ out := FeatureMeterCollection{
142
+ ByKey: make(map[string]FeatureMeter, len(featuresByKey)),
143
+ ByID: make(map[string]FeatureMeter, len(features)),
144
+ }
145
+
146
+ for _, feat := range features {
147
+ out.ByID[feat.ID] = FeatureMeter{
148
+ Feature: feat,
149
+ }
150
+ }
151
+
152
+ for featureKey, feat := range featuresByKey {
153
+ out.ByKey[featureKey] = out.ByID[feat.ID]
154
+ }
155
+
156
+ return out
157
+ }
158
+
159
+ func ensureFeatureIDsResolved(featureRefs []ref.IDOrKey, resolved FeatureMeterCollection) error {
160
+ for _, featureID := range lo.Uniq(lo.FlatMap(featureRefs, func(featureRef ref.IDOrKey, _ int) []string {
161
+ return featureRef.GetIDs()
162
+ })) {
163
+ if _, ok := resolved.ByID[featureID]; !ok {
164
+ return models.NewGenericNotFoundError(fmt.Errorf("feature[%s] not found", featureID))
165
+ }
166
+ }
167
+
168
+ return nil
169
+ }
170
+
171
+ type lastEntityAccessor[T any] interface {
172
+ GetKey(T) string
173
+ GetDeletedAt(T) *time.Time
174
+ }
175
+
176
+ func getLastEntity[T any](entities []T, accessor lastEntityAccessor[T]) map[string]T {
177
+ featuresByKey := lo.GroupBy(entities, func(entity T) string {
178
+ return accessor.GetKey(entity)
179
+ })
180
+
181
+ out := make(map[string]T, len(featuresByKey))
182
+ for key, features := range featuresByKey {
183
+ // Let's try to find an unarchived feature
184
+ out[key] = latestEntity(features, accessor)
185
+ }
186
+
187
+ return out
188
+ }
189
+
190
+ func latestEntity[T any](entities []T, accessor lastEntityAccessor[T]) T {
191
+ for _, entity := range entities {
192
+ if accessor.GetDeletedAt(entity) == nil {
193
+ return entity
194
+ }
195
+ }
196
+
197
+ // Otherwise, let's find the most recently archived feature:
198
+ // - all entities have non-nil deleted at (or we would have returned already)
199
+ // - and we have at least one entity due to the definition of the groupBy
200
+ mostRecentlyArchivedFeature := entities[0]
201
+ for _, entity := range entities {
202
+ if accessor.GetDeletedAt(entity).After(*accessor.GetDeletedAt(mostRecentlyArchivedFeature)) {
203
+ mostRecentlyArchivedFeature = entity
204
+ }
205
+ }
206
+
207
+ return mostRecentlyArchivedFeature
208
+ }
209
+
210
+ type featureAccessor struct{}
211
+
212
+ var _ lastEntityAccessor[Feature] = (*featureAccessor)(nil)
213
+
214
+ func (a featureAccessor) GetKey(f Feature) string {
215
+ return f.Key
216
+ }
217
+
218
+ func (a featureAccessor) GetDeletedAt(f Feature) *time.Time {
219
+ return f.ArchivedAt
220
+ }
221
+
222
+ func getLastFeatures(features []Feature) map[string]Feature {
223
+ return getLastEntity(features, featureAccessor{})
224
+ }
openmeter/productcatalog/feature/featuremeter_test.go ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package feature
2
+
3
+ import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/samber/lo"
8
+ "github.com/stretchr/testify/require"
9
+
10
+ "github.com/openmeterio/openmeter/pkg/models"
11
+ "github.com/openmeterio/openmeter/pkg/ref"
12
+ )
13
+
14
+ func TestGetLastFeatures(t *testing.T) {
15
+ tcs := []struct {
16
+ name string
17
+ features []Feature
18
+ expected map[string]string
19
+ }{
20
+ {
21
+ name: "single-active",
22
+ features: []Feature{
23
+ {ID: "id-active", ArchivedAt: nil, Key: "feature-1-active"},
24
+ },
25
+ expected: map[string]string{"feature-1-active": "id-active"},
26
+ },
27
+ {
28
+ name: "single-archived",
29
+ features: []Feature{
30
+ {ID: "id-archived", ArchivedAt: lo.ToPtr(time.Now()), Key: "feature-1-archived"},
31
+ },
32
+ expected: map[string]string{"feature-1-archived": "id-archived"},
33
+ },
34
+ {
35
+ name: "multi-archived",
36
+ features: []Feature{
37
+ {ID: "id-archived", ArchivedAt: lo.ToPtr(time.Now()), Key: "feature-1"},
38
+ {ID: "id-active", ArchivedAt: nil, Key: "feature-1"},
39
+ },
40
+ expected: map[string]string{"feature-1": "id-active"},
41
+ },
42
+ {
43
+ name: "archived-ordering",
44
+ features: []Feature{
45
+ {ID: "id-archived-1", ArchivedAt: lo.ToPtr(time.Now()), Key: "feature-1"},
46
+ {ID: "id-archived-2", ArchivedAt: lo.ToPtr(time.Now().Add(5 * time.Second)), Key: "feature-1"},
47
+ },
48
+ expected: map[string]string{"feature-1": "id-archived-2"},
49
+ },
50
+ }
51
+
52
+ for _, tc := range tcs {
53
+ t.Run(tc.name, func(t *testing.T) {
54
+ out := getLastFeatures(tc.features)
55
+
56
+ featureKeyToID := map[string]string{}
57
+ for key, feat := range out {
58
+ featureKeyToID[key] = feat.ID
59
+ }
60
+
61
+ require.Equal(t, tc.expected, featureKeyToID)
62
+ })
63
+ }
64
+ }
65
+
66
+ func TestResolveFeatureMeters(t *testing.T) {
67
+ archivedAt := time.Now()
68
+
69
+ features := []Feature{
70
+ {ID: "feature-old", Key: "tokens", ArchivedAt: &archivedAt},
71
+ {ID: "feature-new", Key: "tokens", ArchivedAt: nil},
72
+ {ID: "feature-other", Key: "requests", ArchivedAt: nil},
73
+ }
74
+
75
+ t.Run("key resolves latest while explicit ids remain addressable", func(t *testing.T) {
76
+ out := resolveFeatureMeters(features)
77
+
78
+ byKey, err := out.Get("tokens", false)
79
+ require.NoError(t, err)
80
+ require.Equal(t, "feature-new", byKey.Feature.ID)
81
+
82
+ byLatestID, err := out.GetByID("feature-new", false)
83
+ require.NoError(t, err)
84
+ require.Equal(t, "feature-new", byLatestID.Feature.ID)
85
+
86
+ byArchivedID, err := out.GetByID("feature-old", false)
87
+ require.NoError(t, err)
88
+ require.Equal(t, "feature-old", byArchivedID.Feature.ID)
89
+ require.Equal(t, "tokens", byArchivedID.Feature.Key)
90
+ })
91
+
92
+ t.Run("requested ids must all resolve", func(t *testing.T) {
93
+ out := resolveFeatureMeters(features)
94
+
95
+ require.NoError(t, ensureFeatureIDsResolved([]ref.IDOrKey{
96
+ {ID: "feature-old"},
97
+ {ID: "feature-new"},
98
+ }, out))
99
+
100
+ err := ensureFeatureIDsResolved([]ref.IDOrKey{
101
+ {ID: "feature-old"},
102
+ {ID: "missing-feature"},
103
+ }, out)
104
+ require.Error(t, err)
105
+ require.True(t, models.IsGenericNotFoundError(err))
106
+ require.ErrorContains(t, err, "missing-feature")
107
+ })
108
+ }
openmeter/productcatalog/feature/meter_group_by_filters_test.go ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package feature
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/samber/lo"
7
+ "github.com/stretchr/testify/require"
8
+
9
+ "github.com/openmeterio/openmeter/openmeter/meter"
10
+ "github.com/openmeterio/openmeter/pkg/filter"
11
+ )
12
+
13
+ func TestMeterGroupByFiltersValidate(t *testing.T) {
14
+ m := meter.Meter{
15
+ Key: "tokens_total",
16
+ GroupBy: map[string]string{
17
+ "provider": "$.provider",
18
+ "model": "$.model",
19
+ },
20
+ }
21
+
22
+ t.Run("valid single operator", func(t *testing.T) {
23
+ f := MeterGroupByFilters{
24
+ "provider": filter.FilterString{Eq: lo.ToPtr("openai")},
25
+ }
26
+
27
+ require.NoError(t, f.Validate(m))
28
+ })
29
+
30
+ t.Run("unknown dimension key", func(t *testing.T) {
31
+ f := MeterGroupByFilters{
32
+ "nonexistent": filter.FilterString{Eq: lo.ToPtr("value")},
33
+ }
34
+
35
+ err := f.Validate(m)
36
+ require.Error(t, err)
37
+ })
38
+
39
+ t.Run("multiple operators on a single filter rejected", func(t *testing.T) {
40
+ f := MeterGroupByFilters{
41
+ "provider": filter.FilterString{
42
+ Eq: lo.ToPtr("openai"),
43
+ Ne: lo.ToPtr("anthropic"),
44
+ },
45
+ }
46
+
47
+ err := f.Validate(m)
48
+ require.Error(t, err)
49
+ })
50
+ }
openmeter/productcatalog/feature/repository.go ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package feature
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "time"
7
+
8
+ "github.com/openmeterio/openmeter/pkg/framework/entutils"
9
+ "github.com/openmeterio/openmeter/pkg/models"
10
+ "github.com/openmeterio/openmeter/pkg/pagination"
11
+ )
12
+
13
+ type ArchiveFeatureInput struct {
14
+ Namespace string
15
+ ID string
16
+ At *time.Time
17
+ }
18
+
19
+ func (i ArchiveFeatureInput) Validate() error {
20
+ var errs []error
21
+
22
+ if i.Namespace == "" {
23
+ errs = append(errs, errors.New("namespace is required"))
24
+ }
25
+
26
+ if i.ID == "" {
27
+ errs = append(errs, errors.New("id is required"))
28
+ }
29
+
30
+ if i.At != nil && i.At.IsZero() {
31
+ errs = append(errs, errors.New("at must not be zero"))
32
+ }
33
+
34
+ return models.NewNillableGenericValidationError(errors.Join(errs...))
35
+ }
36
+
37
+ type FeatureRepo interface {
38
+ CreateFeature(ctx context.Context, feature CreateFeatureInputs) (Feature, error)
39
+ UpdateFeature(ctx context.Context, input UpdateFeatureInputs) (Feature, error)
40
+ ArchiveFeature(ctx context.Context, params ArchiveFeatureInput) error
41
+ ListFeatures(ctx context.Context, params ListFeaturesParams) (pagination.Result[Feature], error)
42
+ HasActiveFeatureForMeter(ctx context.Context, namespace string, meterID string) (bool, error)
43
+
44
+ GetByIdOrKey(ctx context.Context, namespace string, idOrKey string, includeArchived bool) (*Feature, error)
45
+ entutils.TxCreator
46
+ entutils.TxUser[FeatureRepo]
47
+ }
openmeter/productcatalog/feature/unitcost.go ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package feature
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+
7
+ "github.com/alpacahq/alpacadecimal"
8
+
9
+ "github.com/openmeterio/openmeter/openmeter/meter"
10
+ )
11
+
12
+ // UnitCostType identifies the type of unit cost.
13
+ type UnitCostType string
14
+
15
+ const (
16
+ UnitCostTypeLLM UnitCostType = "llm"
17
+ UnitCostTypeManual UnitCostType = "manual"
18
+ )
19
+
20
+ // UnitCost represents an optional per-unit cost configuration for a feature.
21
+ type UnitCost struct {
22
+ // Type is the unit cost type: "llm" or "manual".
23
+ Type UnitCostType `json:"type"`
24
+
25
+ // Manual is set when type is "manual".
26
+ Manual *ManualUnitCost `json:"manual,omitempty"`
27
+
28
+ // LLM is set when type is "llm".
29
+ LLM *LLMUnitCost `json:"llm,omitempty"`
30
+ }
31
+
32
+ // ManualUnitCost is a fixed per-unit cost amount.
33
+ type ManualUnitCost struct {
34
+ // Amount is the per-unit cost in USD.
35
+ Amount alpacadecimal.Decimal `json:"amount"`
36
+ }
37
+
38
+ // LLMUnitCost configures dynamic cost lookup from the LLM cost database.
39
+ // For each dimension (provider, model, token type), either a static value
40
+ // or a meter group-by property name can be specified (mutually exclusive).
41
+ type LLMUnitCost struct {
42
+ // ProviderProperty is the meter group-by key that holds the LLM provider value.
43
+ // Mutually exclusive with Provider.
44
+ ProviderProperty string `json:"provider_property,omitempty"`
45
+
46
+ // Provider is a static LLM provider value (e.g. "openai", "anthropic").
47
+ // Mutually exclusive with ProviderProperty.
48
+ Provider string `json:"provider,omitempty"`
49
+
50
+ // ModelProperty is the meter group-by key that holds the model ID value.
51
+ // Mutually exclusive with Model.
52
+ ModelProperty string `json:"model_property,omitempty"`
53
+
54
+ // Model is a static model ID value (e.g. "gpt-4", "claude-3-5-sonnet").
55
+ // Mutually exclusive with ModelProperty.
56
+ Model string `json:"model,omitempty"`
57
+
58
+ // TokenTypeProperty is the meter group-by key that holds the token type.
59
+ // Mutually exclusive with TokenType.
60
+ TokenTypeProperty string `json:"token_type_property,omitempty"`
61
+
62
+ // TokenType is a static token type value (e.g. "input", "output").
63
+ // Use this when the feature tracks a single token type.
64
+ // Mutually exclusive with TokenTypeProperty.
65
+ TokenType string `json:"token_type,omitempty"`
66
+ }
67
+
68
+ // Validate validates the unit cost configuration.
69
+ func (u *UnitCost) Validate() error {
70
+ if u == nil {
71
+ return nil
72
+ }
73
+
74
+ switch u.Type {
75
+ case UnitCostTypeManual:
76
+ if u.Manual == nil {
77
+ return errors.New("manual unit cost configuration is required when type is manual")
78
+ }
79
+
80
+ if u.LLM != nil {
81
+ return errors.New("llm configuration must not be set when type is manual")
82
+ }
83
+
84
+ if u.Manual.Amount.IsNegative() {
85
+ return errors.New("manual unit cost amount must be non-negative")
86
+ }
87
+
88
+ return nil
89
+
90
+ case UnitCostTypeLLM:
91
+ if u.LLM == nil {
92
+ return errors.New("LLM unit cost configuration is required when type is llm")
93
+ }
94
+
95
+ if u.Manual != nil {
96
+ return errors.New("manual configuration must not be set when type is llm")
97
+ }
98
+
99
+ var errs []error
100
+
101
+ // Provider: exactly one of property or static value
102
+ if u.LLM.ProviderProperty == "" && u.LLM.Provider == "" {
103
+ errs = append(errs, errors.New("either provider_property or provider is required for LLM unit cost"))
104
+ }
105
+ if u.LLM.ProviderProperty != "" && u.LLM.Provider != "" {
106
+ errs = append(errs, errors.New("provider_property and provider are mutually exclusive"))
107
+ }
108
+
109
+ // Model: exactly one of property or static value
110
+ if u.LLM.ModelProperty == "" && u.LLM.Model == "" {
111
+ errs = append(errs, errors.New("either model_property or model is required for LLM unit cost"))
112
+ }
113
+ if u.LLM.ModelProperty != "" && u.LLM.Model != "" {
114
+ errs = append(errs, errors.New("model_property and model are mutually exclusive"))
115
+ }
116
+
117
+ // Token type: exactly one of property or static value
118
+ if u.LLM.TokenTypeProperty == "" && u.LLM.TokenType == "" {
119
+ errs = append(errs, errors.New("either token_type_property or token_type is required for LLM unit cost"))
120
+ }
121
+ if u.LLM.TokenTypeProperty != "" && u.LLM.TokenType != "" {
122
+ errs = append(errs, errors.New("token_type_property and token_type are mutually exclusive"))
123
+ }
124
+
125
+ if u.LLM.TokenType != "" {
126
+ validTypes := map[LLMTokenType]bool{
127
+ LLMTokenTypeInput: true, LLMTokenTypeOutput: true,
128
+ LLMTokenTypeCacheRead: true, LLMTokenTypeReasoning: true,
129
+ LLMTokenTypeCacheWrite: true,
130
+ LLMTokenTypeRequest: true, LLMTokenTypeResponse: true,
131
+ }
132
+ if !validTypes[LLMTokenType(u.LLM.TokenType)] {
133
+ errs = append(errs, fmt.Errorf("invalid token_type %q: expected one of input, output, cache_read, reasoning, cache_write, request, response", u.LLM.TokenType))
134
+ }
135
+ }
136
+
137
+ return errors.Join(errs...)
138
+
139
+ default:
140
+ return fmt.Errorf("invalid unit cost type: %s", u.Type)
141
+ }
142
+ }
143
+
144
+ // ValidateWithMeter validates that the LLM unit cost property names exist in the meter's GroupBy keys.
145
+ func (u *UnitCost) ValidateWithMeter(m meter.Meter) error {
146
+ if u == nil || u.Type != UnitCostTypeLLM || u.LLM == nil {
147
+ return nil
148
+ }
149
+
150
+ var errs []error
151
+
152
+ if u.LLM.ProviderProperty != "" {
153
+ if _, ok := m.GroupBy[u.LLM.ProviderProperty]; !ok {
154
+ errs = append(errs, fmt.Errorf("provider_property %q not found in meter group-by keys", u.LLM.ProviderProperty))
155
+ }
156
+ }
157
+
158
+ if u.LLM.ModelProperty != "" {
159
+ if _, ok := m.GroupBy[u.LLM.ModelProperty]; !ok {
160
+ errs = append(errs, fmt.Errorf("model_property %q not found in meter group-by keys", u.LLM.ModelProperty))
161
+ }
162
+ }
163
+
164
+ if u.LLM.TokenTypeProperty != "" {
165
+ if _, ok := m.GroupBy[u.LLM.TokenTypeProperty]; !ok {
166
+ errs = append(errs, fmt.Errorf("token_type_property %q not found in meter group-by keys", u.LLM.TokenTypeProperty))
167
+ }
168
+ }
169
+
170
+ return errors.Join(errs...)
171
+ }
172
+
173
+ // LLMTokenType identifies a token dimension for LLM pricing.
174
+ type LLMTokenType string
175
+
176
+ const (
177
+ LLMTokenTypeInput LLMTokenType = "input"
178
+ LLMTokenTypeOutput LLMTokenType = "output"
179
+ LLMTokenTypeCacheRead LLMTokenType = "cache_read"
180
+ LLMTokenTypeCacheWrite LLMTokenType = "cache_write"
181
+ LLMTokenTypeReasoning LLMTokenType = "reasoning"
182
+
183
+ // LLMTokenTypeRequest is an alias for input tokens used by some providers.
184
+ LLMTokenTypeRequest LLMTokenType = "request"
185
+ // LLMTokenTypeResponse is an alias for output tokens used by some providers.
186
+ LLMTokenTypeResponse LLMTokenType = "response"
187
+ )
openmeter/productcatalog/featureresolver.go ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package productcatalog
2
+
3
+ import (
4
+ "context"
5
+
6
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
7
+ )
8
+
9
+ type FeatureResolver interface {
10
+ Resolve(ctx context.Context, namespace string, id, key *string) (*feature.Feature, error)
11
+ BatchResolve(ctx context.Context, namespace string, idOrKeys ...string) (map[string]*feature.Feature, error)
12
+ WithNamespace(namespace string) NamespacedFeatureResolver
13
+ }
14
+
15
+ type NamespacedFeatureResolver interface {
16
+ Resolve(ctx context.Context, id, key *string) (*feature.Feature, error)
17
+ BatchResolve(ctx context.Context, idOrKeys ...string) (map[string]*feature.Feature, error)
18
+ Namespace() string
19
+ }
openmeter/productcatalog/featureresolver/ratecard.go ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package featureresolver
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+
8
+ "github.com/samber/lo"
9
+
10
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
11
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
12
+ "github.com/openmeterio/openmeter/pkg/models"
13
+ )
14
+
15
+ func ResolveFeaturesForRateCards(
16
+ ctx context.Context,
17
+ resolver productcatalog.FeatureResolver,
18
+ namespace string,
19
+ rateCards *productcatalog.RateCards,
20
+ ) error {
21
+ if rateCards == nil || len(*rateCards) == 0 {
22
+ return nil
23
+ }
24
+
25
+ featureIDAndKeys := make([]string, 0, 2*len(*rateCards))
26
+
27
+ for _, rc := range *rateCards {
28
+ if !rc.HasFeature() {
29
+ continue
30
+ }
31
+
32
+ if id := rc.GetFeatureID(); id != nil && *id != "" {
33
+ featureIDAndKeys = append(featureIDAndKeys, *id)
34
+ }
35
+
36
+ if key := rc.GetFeatureKey(); key != nil && *key != "" {
37
+ featureIDAndKeys = append(featureIDAndKeys, *key)
38
+ }
39
+ }
40
+
41
+ features, err := resolver.BatchResolve(ctx, namespace, featureIDAndKeys...)
42
+ if err != nil {
43
+ return fmt.Errorf("failed to resolve features: %w", err)
44
+ }
45
+
46
+ var errs []error
47
+
48
+ for _, rc := range *rateCards {
49
+ if !rc.HasFeature() {
50
+ continue
51
+ }
52
+
53
+ var f *feature.Feature
54
+
55
+ id := rc.GetFeatureID()
56
+ hasID := id != nil && *id != ""
57
+
58
+ key := rc.GetFeatureKey()
59
+ hasKey := key != nil && *key != ""
60
+
61
+ fieldSelector := models.NewFieldSelectorGroup(
62
+ models.NewFieldSelector("ratecards").WithExpression(
63
+ models.NewFieldAttrValue("key", rc.Key())),
64
+ )
65
+
66
+ if hasID {
67
+ f = features[*id]
68
+
69
+ if f == nil {
70
+ errs = append(errs, models.ErrorWithFieldPrefix(fieldSelector,
71
+ fmt.Errorf("feature not found [ratecard.key=%s feature.id=%s]: %w",
72
+ rc.Key(), lo.FromPtr(id), productcatalog.ErrRateCardFeatureNotFound),
73
+ ))
74
+
75
+ continue
76
+ }
77
+
78
+ if f.ID != *id {
79
+ errs = append(errs, models.ErrorWithFieldPrefix(fieldSelector,
80
+ fmt.Errorf("feature id conflict [ratecard.key=%s feature.id=%s feature.key=%s]: %w",
81
+ rc.Key(), lo.FromPtr(id), lo.FromPtr(key), productcatalog.ErrRateCardFeatureMismatch),
82
+ ))
83
+
84
+ continue
85
+ }
86
+ }
87
+
88
+ if hasKey {
89
+ if f == nil {
90
+ f = features[*key]
91
+ }
92
+
93
+ if f == nil {
94
+ errs = append(errs, models.ErrorWithFieldPrefix(fieldSelector,
95
+ fmt.Errorf("feature not found [ratecard.key=%s feature.key=%s]: %w",
96
+ rc.Key(), lo.FromPtr(key), productcatalog.ErrRateCardFeatureNotFound),
97
+ ))
98
+
99
+ continue
100
+ }
101
+
102
+ if f.Key != *key {
103
+ errs = append(errs, models.ErrorWithFieldPrefix(fieldSelector,
104
+ fmt.Errorf("feature key conflict [ratecard.key=%s feature.id=%s feature.key=%s]: %w",
105
+ rc.Key(), lo.FromPtr(id), lo.FromPtr(key), productcatalog.ErrRateCardFeatureMismatch),
106
+ ))
107
+
108
+ continue
109
+ }
110
+ }
111
+
112
+ if f == nil {
113
+ errs = append(errs, models.ErrorWithFieldPrefix(fieldSelector,
114
+ fmt.Errorf("feature not found [ratecard.key=%s]: %w", rc.Key(), productcatalog.ErrRateCardFeatureNotFound),
115
+ ))
116
+ } else {
117
+ rc.SetFeature(&(f).ID, &(f).Key)
118
+ }
119
+ }
120
+
121
+ return models.NewNillableGenericValidationError(errors.Join(errs...))
122
+ }
openmeter/productcatalog/featureresolver/ratecard_test.go ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package featureresolver_test
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/meter"
12
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
13
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
14
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/featureresolver"
15
+ pctestutils "github.com/openmeterio/openmeter/openmeter/productcatalog/testutils"
16
+ "github.com/openmeterio/openmeter/pkg/datetime"
17
+ "github.com/openmeterio/openmeter/pkg/models"
18
+ "github.com/openmeterio/openmeter/pkg/pagination"
19
+ )
20
+
21
+ func Test_ResolveFeaturesForRateCards(t *testing.T) {
22
+ // Setup test environment
23
+ env := pctestutils.NewTestEnv(t)
24
+ t.Cleanup(func() {
25
+ env.Close(t)
26
+ })
27
+
28
+ // Run database migrations
29
+
30
+ // Get new namespace ID
31
+ namespace := pctestutils.NewTestNamespace(t)
32
+
33
+ // Setup meter repository
34
+ err := env.Meter.ReplaceMeters(t.Context(), pctestutils.NewTestMeters(t, namespace))
35
+ require.NoError(t, err, "replacing meters must not fail")
36
+
37
+ result, err := env.Meter.ListMeters(t.Context(), meter.ListMetersParams{
38
+ Page: pagination.Page{
39
+ PageSize: 1000,
40
+ PageNumber: 1,
41
+ },
42
+ Namespace: namespace,
43
+ })
44
+ require.NoErrorf(t, err, "listing meters must not fail")
45
+
46
+ meters := result.Items
47
+ require.NotEmptyf(t, meters, "list of Meters must not be empty")
48
+
49
+ // Set a feature for each meter
50
+ features := make([]feature.Feature, 0, len(meters))
51
+ for _, m := range meters {
52
+ input := pctestutils.NewTestFeatureFromMeter(t, &m)
53
+
54
+ feat, err := env.Feature.CreateFeature(t.Context(), input)
55
+ require.NoErrorf(t, err, "creating feature must not fail")
56
+ require.NotNil(t, feat, "feature must not be empty")
57
+
58
+ features = append(features, feat)
59
+ }
60
+ require.NotEmptyf(t, features, "list of Features must not be empty")
61
+ require.Lenf(t, features, len(meters), "list of Features must have the same length as the list of Meters")
62
+
63
+ MonthPeriod := datetime.MustParseDuration(t, "P1M")
64
+
65
+ tests := []struct {
66
+ name string
67
+ ratecards *productcatalog.RateCards
68
+ expectedErr error
69
+ }{
70
+ {
71
+ name: "success",
72
+ ratecards: &productcatalog.RateCards{
73
+ &productcatalog.FlatFeeRateCard{
74
+ RateCardMeta: productcatalog.RateCardMeta{
75
+ Key: features[0].Key,
76
+ Name: features[0].Name,
77
+ Description: lo.ToPtr("RateCard 1"),
78
+ Metadata: models.Metadata{"name": features[0].Name},
79
+ FeatureKey: lo.ToPtr(features[0].Key),
80
+ TaxConfig: &productcatalog.TaxConfig{
81
+ Stripe: &productcatalog.StripeTaxConfig{
82
+ Code: "txcd_10000000",
83
+ },
84
+ },
85
+ Price: productcatalog.NewPriceFrom(
86
+ productcatalog.FlatPrice{
87
+ Amount: decimal.NewFromInt(0),
88
+ PaymentTerm: productcatalog.InArrearsPaymentTerm,
89
+ }),
90
+ },
91
+ BillingCadence: &MonthPeriod,
92
+ },
93
+ &productcatalog.UsageBasedRateCard{
94
+ RateCardMeta: productcatalog.RateCardMeta{
95
+ Key: features[1].Key,
96
+ Name: features[1].Name,
97
+ Description: lo.ToPtr("RateCard 2"),
98
+ Metadata: models.Metadata{"name": features[1].Name},
99
+ FeatureID: lo.ToPtr(features[1].ID),
100
+ TaxConfig: &productcatalog.TaxConfig{
101
+ Stripe: &productcatalog.StripeTaxConfig{
102
+ Code: "txcd_10000000",
103
+ },
104
+ },
105
+ Price: productcatalog.NewPriceFrom(
106
+ productcatalog.TieredPrice{
107
+ Mode: productcatalog.VolumeTieredPrice,
108
+ Tiers: []productcatalog.PriceTier{
109
+ {
110
+ UpToAmount: lo.ToPtr(decimal.NewFromInt(1000)),
111
+ FlatPrice: &productcatalog.PriceTierFlatPrice{
112
+ Amount: decimal.NewFromInt(100),
113
+ },
114
+ UnitPrice: &productcatalog.PriceTierUnitPrice{
115
+ Amount: decimal.NewFromInt(50),
116
+ },
117
+ },
118
+ {
119
+ UpToAmount: nil,
120
+ FlatPrice: &productcatalog.PriceTierFlatPrice{
121
+ Amount: decimal.NewFromInt(75),
122
+ },
123
+ UnitPrice: &productcatalog.PriceTierUnitPrice{
124
+ Amount: decimal.NewFromInt(25),
125
+ },
126
+ },
127
+ },
128
+ Commitments: productcatalog.Commitments{
129
+ MinimumAmount: lo.ToPtr(decimal.NewFromInt(1000)),
130
+ MaximumAmount: nil,
131
+ },
132
+ }),
133
+ },
134
+ BillingCadence: MonthPeriod,
135
+ },
136
+ &productcatalog.FlatFeeRateCard{
137
+ RateCardMeta: productcatalog.RateCardMeta{
138
+ Key: features[2].Key,
139
+ Name: features[2].Name,
140
+ Description: lo.ToPtr("RateCard 3"),
141
+ Metadata: models.Metadata{"name": features[2].Name},
142
+ FeatureKey: lo.ToPtr(features[2].Key),
143
+ TaxConfig: &productcatalog.TaxConfig{
144
+ Stripe: &productcatalog.StripeTaxConfig{
145
+ Code: "txcd_10000000",
146
+ },
147
+ },
148
+ Price: productcatalog.NewPriceFrom(
149
+ productcatalog.FlatPrice{
150
+ Amount: decimal.NewFromInt(0),
151
+ PaymentTerm: productcatalog.InArrearsPaymentTerm,
152
+ }),
153
+ },
154
+ BillingCadence: &MonthPeriod,
155
+ },
156
+ },
157
+ },
158
+ {
159
+ name: "not found",
160
+ ratecards: &productcatalog.RateCards{
161
+ &productcatalog.FlatFeeRateCard{
162
+ RateCardMeta: productcatalog.RateCardMeta{
163
+ Key: "abracadabra",
164
+ Name: "abracadabra",
165
+ Description: lo.ToPtr("RateCard 4"),
166
+ Metadata: models.Metadata{"name": "abracadabra"},
167
+ FeatureKey: lo.ToPtr("abracadabra"),
168
+ TaxConfig: &productcatalog.TaxConfig{
169
+ Stripe: &productcatalog.StripeTaxConfig{
170
+ Code: "txcd_10000000",
171
+ },
172
+ },
173
+ Price: productcatalog.NewPriceFrom(
174
+ productcatalog.FlatPrice{
175
+ Amount: decimal.NewFromInt(0),
176
+ PaymentTerm: productcatalog.InArrearsPaymentTerm,
177
+ }),
178
+ },
179
+ BillingCadence: &MonthPeriod,
180
+ },
181
+ &productcatalog.FlatFeeRateCard{
182
+ RateCardMeta: productcatalog.RateCardMeta{
183
+ Key: "abracadabra-2",
184
+ Name: "abracadabra-2",
185
+ Description: lo.ToPtr("RateCard 4"),
186
+ Metadata: models.Metadata{"name": "abracadabra-2"},
187
+ FeatureID: lo.ToPtr("abracadabra-2"),
188
+ TaxConfig: &productcatalog.TaxConfig{
189
+ Stripe: &productcatalog.StripeTaxConfig{
190
+ Code: "txcd_10000000",
191
+ },
192
+ },
193
+ Price: productcatalog.NewPriceFrom(
194
+ productcatalog.FlatPrice{
195
+ Amount: decimal.NewFromInt(0),
196
+ PaymentTerm: productcatalog.InArrearsPaymentTerm,
197
+ }),
198
+ },
199
+ BillingCadence: &MonthPeriod,
200
+ },
201
+ },
202
+ expectedErr: productcatalog.ErrRateCardFeatureNotFound,
203
+ },
204
+ {
205
+ name: "mismatch",
206
+ ratecards: &productcatalog.RateCards{
207
+ &productcatalog.FlatFeeRateCard{
208
+ RateCardMeta: productcatalog.RateCardMeta{
209
+ Key: features[0].Key,
210
+ Name: features[0].Name,
211
+ Description: lo.ToPtr("RateCard 4"),
212
+ Metadata: models.Metadata{"name": features[0].Name},
213
+ FeatureKey: lo.ToPtr(features[0].Key),
214
+ FeatureID: lo.ToPtr(features[1].ID),
215
+ TaxConfig: &productcatalog.TaxConfig{
216
+ Stripe: &productcatalog.StripeTaxConfig{
217
+ Code: "txcd_10000000",
218
+ },
219
+ },
220
+ Price: productcatalog.NewPriceFrom(
221
+ productcatalog.FlatPrice{
222
+ Amount: decimal.NewFromInt(0),
223
+ PaymentTerm: productcatalog.InArrearsPaymentTerm,
224
+ }),
225
+ },
226
+ BillingCadence: &MonthPeriod,
227
+ },
228
+ },
229
+ expectedErr: productcatalog.ErrRateCardFeatureMismatch,
230
+ },
231
+ {
232
+ name: "id is actually a key",
233
+ ratecards: &productcatalog.RateCards{
234
+ &productcatalog.FlatFeeRateCard{
235
+ RateCardMeta: productcatalog.RateCardMeta{
236
+ Key: features[0].Key,
237
+ Name: features[0].Name,
238
+ FeatureID: lo.ToPtr(features[0].Key), // wrong slot
239
+ Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{Amount: decimal.NewFromInt(0), PaymentTerm: productcatalog.InArrearsPaymentTerm}),
240
+ },
241
+ BillingCadence: &MonthPeriod,
242
+ },
243
+ },
244
+ expectedErr: productcatalog.ErrRateCardFeatureMismatch,
245
+ },
246
+ {
247
+ name: "key is actually an id",
248
+ ratecards: &productcatalog.RateCards{
249
+ &productcatalog.FlatFeeRateCard{
250
+ RateCardMeta: productcatalog.RateCardMeta{
251
+ Key: features[0].Key,
252
+ Name: features[0].Name,
253
+ FeatureKey: lo.ToPtr(features[0].ID), // wrong slot
254
+ Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{Amount: decimal.NewFromInt(0), PaymentTerm: productcatalog.InArrearsPaymentTerm}),
255
+ },
256
+ BillingCadence: &MonthPeriod,
257
+ },
258
+ },
259
+ expectedErr: productcatalog.ErrRateCardFeatureMismatch,
260
+ },
261
+ }
262
+
263
+ resolver, err := featureresolver.New(env.Feature)
264
+ require.NoError(t, err, "creating feature resolver must not fail")
265
+
266
+ for _, test := range tests {
267
+ t.Run(test.name, func(t *testing.T) {
268
+ err = featureresolver.ResolveFeaturesForRateCards(t.Context(), resolver, namespace, test.ratecards)
269
+ if test.expectedErr != nil {
270
+ require.Error(t, err, "expected error")
271
+ assert.ErrorIsf(t, err, test.expectedErr, "expected error message")
272
+ } else {
273
+ for idx, rc := range *test.ratecards {
274
+ assert.Equal(t, features[idx].ID, lo.FromPtr(rc.GetFeatureID()), "resolved feature id must be equal to the one we set")
275
+ assert.Equal(t, features[idx].Key, lo.FromPtr(rc.GetFeatureKey()), "resolved feature key must be equal to the one we set")
276
+ }
277
+ }
278
+ })
279
+ }
280
+ }
openmeter/productcatalog/featureresolver/resolver.go ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package featureresolver
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+
8
+ "github.com/samber/lo"
9
+
10
+ "github.com/openmeterio/openmeter/openmeter/productcatalog"
11
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
12
+ "github.com/openmeterio/openmeter/pkg/models"
13
+ "github.com/openmeterio/openmeter/pkg/pagination"
14
+ )
15
+
16
+ // NOTE: this should live under the feature package after it gets refactored
17
+
18
+ func New(service feature.FeatureConnector) (productcatalog.FeatureResolver, error) {
19
+ if service == nil {
20
+ return nil, errors.New("feature connector is not set")
21
+ }
22
+
23
+ return &resolver{
24
+ service: service,
25
+ }, nil
26
+ }
27
+
28
+ var _ productcatalog.NamespacedFeatureResolver = (*namespacedResolver)(nil)
29
+
30
+ type namespacedResolver struct {
31
+ resolver *resolver
32
+ namespace string
33
+ }
34
+
35
+ func (n *namespacedResolver) Namespace() string {
36
+ return n.namespace
37
+ }
38
+
39
+ func (n *namespacedResolver) Resolve(ctx context.Context, id, key *string) (*feature.Feature, error) {
40
+ return n.resolver.Resolve(ctx, n.namespace, id, key)
41
+ }
42
+
43
+ func (n *namespacedResolver) BatchResolve(ctx context.Context, idOrKeys ...string) (map[string]*feature.Feature, error) {
44
+ return n.resolver.BatchResolve(ctx, n.namespace, idOrKeys...)
45
+ }
46
+
47
+ var _ productcatalog.FeatureResolver = (*resolver)(nil)
48
+
49
+ type resolver struct {
50
+ service feature.FeatureConnector
51
+ }
52
+
53
+ func (r *resolver) WithNamespace(namespace string) productcatalog.NamespacedFeatureResolver {
54
+ return &namespacedResolver{
55
+ resolver: r,
56
+ namespace: namespace,
57
+ }
58
+ }
59
+
60
+ func (r *resolver) Resolve(ctx context.Context, namespace string, id, key *string) (*feature.Feature, error) {
61
+ hasID := id != nil && *id != ""
62
+ hasKey := key != nil && *key != ""
63
+
64
+ if !hasID && !hasKey {
65
+ return nil, errors.New("feature id or key is required")
66
+ }
67
+
68
+ batch := make([]string, 0, 2)
69
+
70
+ if hasID {
71
+ batch = append(batch, *id)
72
+ }
73
+
74
+ if hasKey {
75
+ batch = append(batch, *key)
76
+ }
77
+
78
+ features, err := r.BatchResolve(ctx, namespace, batch...)
79
+ if err != nil {
80
+ return nil, fmt.Errorf("failed to fetch feature: %w", err)
81
+ }
82
+
83
+ var f *feature.Feature
84
+
85
+ if hasID {
86
+ f = features[*id]
87
+
88
+ if f == nil {
89
+ return nil, models.NewGenericNotFoundError(fmt.Errorf("feature [feature.id=%s]", lo.FromPtr(id)))
90
+ }
91
+
92
+ if f.ID != *id {
93
+ return nil, models.NewGenericConflictError(fmt.Errorf("feature [feature.id=%s feature.key=%s]", lo.FromPtr(id), lo.FromPtr(key)))
94
+ }
95
+ }
96
+
97
+ if hasKey {
98
+ if f == nil {
99
+ f = features[*key]
100
+ }
101
+
102
+ if f == nil {
103
+ return nil, models.NewGenericNotFoundError(fmt.Errorf("feature [feature.key=%s]", lo.FromPtr(key)))
104
+ }
105
+
106
+ if features[*key] == nil {
107
+ return nil, models.NewGenericNotFoundError(fmt.Errorf("feature [feature.key=%s]", lo.FromPtr(key)))
108
+ }
109
+
110
+ if f.Key != *key {
111
+ return nil, models.NewGenericConflictError(fmt.Errorf("feature [feature.id=%s feature.key=%s]", lo.FromPtr(id), lo.FromPtr(key)))
112
+ }
113
+ }
114
+
115
+ return f, nil
116
+ }
117
+
118
+ func (r *resolver) BatchResolve(ctx context.Context, namespace string, idsOrKeys ...string) (map[string]*feature.Feature, error) {
119
+ if namespace == "" {
120
+ return nil, errors.New("namespace is not set")
121
+ }
122
+
123
+ if len(idsOrKeys) == 0 {
124
+ return nil, nil
125
+ }
126
+
127
+ features, err := pagination.CollectAll(ctx, pagination.NewPaginator(func(ctx context.Context, page pagination.Page) (pagination.Result[feature.Feature], error) {
128
+ return r.service.ListFeatures(ctx, feature.ListFeaturesParams{
129
+ IDsOrKeys: idsOrKeys,
130
+ Namespace: namespace,
131
+ IncludeArchived: false,
132
+ Page: page,
133
+ })
134
+ }), min(len(idsOrKeys), 100))
135
+ if err != nil {
136
+ return nil, fmt.Errorf("failed to fetch features: %w", err)
137
+ }
138
+
139
+ result := lo.SliceToMap(idsOrKeys, func(item string) (string, *feature.Feature) {
140
+ return item, nil
141
+ })
142
+
143
+ for idx := range features {
144
+ f := features[idx]
145
+
146
+ if _, ok := result[f.ID]; ok {
147
+ result[f.ID] = &f
148
+ }
149
+
150
+ if _, ok := result[f.Key]; ok {
151
+ result[f.Key] = &f
152
+ }
153
+ }
154
+
155
+ return result, nil
156
+ }
openmeter/productcatalog/featureresolver/resolver_test.go ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package featureresolver_test
2
+
3
+ import (
4
+ "errors"
5
+ "testing"
6
+
7
+ "github.com/samber/lo"
8
+ "github.com/stretchr/testify/assert"
9
+ "github.com/stretchr/testify/require"
10
+
11
+ "github.com/openmeterio/openmeter/openmeter/meter"
12
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
13
+ "github.com/openmeterio/openmeter/openmeter/productcatalog/featureresolver"
14
+ pctestutils "github.com/openmeterio/openmeter/openmeter/productcatalog/testutils"
15
+ "github.com/openmeterio/openmeter/pkg/models"
16
+ "github.com/openmeterio/openmeter/pkg/pagination"
17
+ )
18
+
19
+ func Test_NamespacedFeatureResolver(t *testing.T) {
20
+ // Setup test environment
21
+ env := pctestutils.NewTestEnv(t)
22
+ t.Cleanup(func() {
23
+ env.Close(t)
24
+ })
25
+
26
+ // Run database migrations
27
+
28
+ // Get new namespace ID
29
+ namespace := pctestutils.NewTestNamespace(t)
30
+
31
+ // Setup meter repository
32
+ err := env.Meter.ReplaceMeters(t.Context(), pctestutils.NewTestMeters(t, namespace))
33
+ require.NoError(t, err, "replacing meters must not fail")
34
+
35
+ result, err := env.Meter.ListMeters(t.Context(), meter.ListMetersParams{
36
+ Page: pagination.Page{
37
+ PageSize: 1000,
38
+ PageNumber: 1,
39
+ },
40
+ Namespace: namespace,
41
+ })
42
+ require.NoErrorf(t, err, "listing meters must not fail")
43
+
44
+ meters := result.Items
45
+ require.NotEmptyf(t, meters, "list of Meters must not be empty")
46
+
47
+ // Set a feature for each meter
48
+ features := make([]feature.Feature, 0, len(meters))
49
+ for _, m := range meters {
50
+ input := pctestutils.NewTestFeatureFromMeter(t, &m)
51
+
52
+ feat, err := env.Feature.CreateFeature(t.Context(), input)
53
+ require.NoErrorf(t, err, "creating feature must not fail")
54
+ require.NotNil(t, feat, "feature must not be empty")
55
+
56
+ features = append(features, feat)
57
+ }
58
+ require.NotEmptyf(t, features, "list of Features must not be empty")
59
+ require.Lenf(t, features, len(meters), "list of Features must have the same length as the list of Meters")
60
+
61
+ resolver, err := featureresolver.New(env.Feature)
62
+ require.NoError(t, err, "creating feature resolver must not fail")
63
+
64
+ namespacedResolver := resolver.WithNamespace(namespace)
65
+
66
+ t.Run("Resolve", func(t *testing.T) {
67
+ tests := []struct {
68
+ name string
69
+ featureID *string
70
+ featureKey *string
71
+
72
+ expectedError error
73
+ }{
74
+ {
75
+ name: "nil",
76
+ featureID: nil,
77
+ featureKey: nil,
78
+ expectedError: errors.New("feature id or key is required"),
79
+ },
80
+ {
81
+ name: "by id",
82
+ featureID: &features[0].ID,
83
+ featureKey: nil,
84
+ expectedError: nil,
85
+ },
86
+ {
87
+ name: "by key",
88
+ featureID: nil,
89
+ featureKey: &features[0].Key,
90
+ expectedError: nil,
91
+ },
92
+ {
93
+ name: "by both id and key",
94
+ featureID: &features[0].ID,
95
+ featureKey: &features[0].Key,
96
+ expectedError: nil,
97
+ },
98
+ {
99
+ name: "by non-existing id",
100
+ featureID: lo.ToPtr("abracadabraId"),
101
+ featureKey: nil,
102
+ expectedError: new(models.GenericNotFoundError),
103
+ },
104
+ {
105
+ name: "by non-existing key",
106
+ featureID: nil,
107
+ featureKey: lo.ToPtr("abracadabraKey"),
108
+ expectedError: new(models.GenericNotFoundError),
109
+ },
110
+ {
111
+ name: "by non-existing id and key",
112
+ featureID: lo.ToPtr("abracadabraId"),
113
+ featureKey: lo.ToPtr("abracadabraKey"),
114
+ expectedError: new(models.GenericNotFoundError),
115
+ },
116
+ {
117
+ name: "mismatched id and key",
118
+ featureID: &features[0].ID,
119
+ featureKey: &features[1].Key,
120
+ expectedError: new(models.GenericConflictError),
121
+ },
122
+ {
123
+ name: "id is actually a key",
124
+ featureID: &features[0].Key,
125
+ featureKey: nil,
126
+ expectedError: new(models.GenericConflictError),
127
+ },
128
+ {
129
+ name: "key is actually an id",
130
+ featureID: nil,
131
+ featureKey: &features[0].ID,
132
+ expectedError: new(models.GenericConflictError),
133
+ },
134
+ }
135
+
136
+ for _, test := range tests {
137
+ t.Run(test.name, func(t *testing.T) {
138
+ var f *feature.Feature
139
+
140
+ f, err = namespacedResolver.Resolve(t.Context(), test.featureID, test.featureKey)
141
+ if test.expectedError != nil {
142
+ assert.ErrorAsf(t, err, &test.expectedError, "expected error %v", test.expectedError)
143
+ } else {
144
+ require.NoErrorf(t, err, "expected no error: %v", err)
145
+
146
+ if test.featureID != nil {
147
+ assert.Equalf(t, *test.featureID, f.ID, "resolved feature id must be equal to the one we set")
148
+ }
149
+
150
+ if test.featureKey != nil {
151
+ assert.Equalf(t, *test.featureKey, f.Key, "resolved feature key must be equal to the one we set")
152
+ }
153
+ }
154
+ })
155
+ }
156
+ })
157
+
158
+ t.Run("BatchResolve", func(t *testing.T) {
159
+ testBatch := map[string]*feature.Feature{
160
+ features[0].ID: &features[0],
161
+ features[0].Key: &features[0],
162
+ features[1].ID: &features[1],
163
+ features[1].Key: &features[1],
164
+ features[2].ID: &features[2],
165
+ features[2].Key: &features[2],
166
+ "abracadabra": nil,
167
+ }
168
+
169
+ idOrKeys := lo.MapToSlice(testBatch, func(key string, _ *feature.Feature) string {
170
+ return key
171
+ })
172
+
173
+ var resolved map[string]*feature.Feature
174
+
175
+ resolved, err = namespacedResolver.BatchResolve(t.Context(), idOrKeys...)
176
+ require.NoErrorf(t, err, "expected no error: %v", err)
177
+
178
+ for k, f := range testBatch {
179
+ if f != nil {
180
+ assert.NotNilf(t, resolved[k], "resolved feature must not be nil")
181
+ assert.Equalf(t, f.ID, resolved[k].ID, "resolved feature id must be equal to the one we set")
182
+ assert.Equalf(t, f.Key, resolved[k].Key, "resolved feature key must be equal to the one we set")
183
+ } else {
184
+ assert.Nilf(t, resolved[k], "resolved feature must be nil")
185
+ }
186
+ }
187
+ })
188
+ }
openmeter/productcatalog/http/errors.go ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package http
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+
7
+ "github.com/openmeterio/openmeter/pkg/framework/commonhttp"
8
+ "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport/encoder"
9
+ "github.com/openmeterio/openmeter/pkg/models"
10
+ )
11
+
12
+ func ValidationErrorEncoder(kind ResourceKind) encoder.ErrorEncoder {
13
+ return func(ctx context.Context, err error, w http.ResponseWriter, r *http.Request) bool {
14
+ issues, err := models.AsValidationIssues(err)
15
+
16
+ if err == nil && len(issues) > 0 {
17
+ err = validationError{
18
+ kind: kind,
19
+ issues: issues,
20
+ }
21
+
22
+ return commonhttp.HandleErrorIfTypeMatches[validationError](ctx, http.StatusBadRequest, err, w, validationErrorToExtensions)
23
+ }
24
+
25
+ return false
26
+ }
27
+ }
28
+
29
+ var _ error = (*validationError)(nil)
30
+
31
+ type validationError struct {
32
+ kind ResourceKind
33
+ issues models.ValidationIssues
34
+ }
35
+
36
+ func (e validationError) Error() string {
37
+ return "invalid " + string(e.kind)
38
+ }
39
+
40
+ func validationErrorToExtensions(err validationError) map[string]interface{} {
41
+ if len(err.issues) == 0 {
42
+ return nil
43
+ }
44
+
45
+ var issues []map[string]interface{}
46
+ for _, issue := range err.issues {
47
+ issues = append(issues, issue.AsErrorExtension())
48
+ }
49
+
50
+ return map[string]interface{}{
51
+ "validationErrors": issues,
52
+ }
53
+ }