Upload folder using huggingface_hub (part 9)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- openmeter/productcatalog/adapter/transaction.go +34 -0
- openmeter/productcatalog/addon.go +338 -0
- openmeter/productcatalog/addon/adapter/adapter.go +83 -0
- openmeter/productcatalog/addon/adapter/adapter_test.go +548 -0
- openmeter/productcatalog/addon/adapter/addon.go +572 -0
- openmeter/productcatalog/addon/adapter/mapping.go +398 -0
- openmeter/productcatalog/addon/addon.go +62 -0
- openmeter/productcatalog/addon/assert.go +134 -0
- openmeter/productcatalog/addon/errors.go +72 -0
- openmeter/productcatalog/addon/errors_test.go +94 -0
- openmeter/productcatalog/addon/event.go +240 -0
- openmeter/productcatalog/addon/httpdriver/addon.go +398 -0
- openmeter/productcatalog/addon/httpdriver/driver.go +55 -0
- openmeter/productcatalog/addon/httpdriver/mapping.go +113 -0
- openmeter/productcatalog/addon/plan.go +33 -0
- openmeter/productcatalog/addon/ratecard.go +218 -0
- openmeter/productcatalog/addon/ratecard_test.go +399 -0
- openmeter/productcatalog/addon/repository.go +20 -0
- openmeter/productcatalog/addon/service.go +468 -0
- openmeter/productcatalog/addon/service/addon.go +685 -0
- openmeter/productcatalog/addon/service/service.go +62 -0
- openmeter/productcatalog/addon/service/service_test.go +610 -0
- openmeter/productcatalog/addon/service/taxcode_test.go +899 -0
- openmeter/productcatalog/addon/validators.go +31 -0
- openmeter/productcatalog/alignment.go +35 -0
- openmeter/productcatalog/discount.go +239 -0
- openmeter/productcatalog/discount_test.go +161 -0
- openmeter/productcatalog/driver/errors.go +24 -0
- openmeter/productcatalog/driver/feature.go +291 -0
- openmeter/productcatalog/driver/parser.go +247 -0
- openmeter/productcatalog/effectiveperiod.go +74 -0
- openmeter/productcatalog/effectiveperiod_test.go +147 -0
- openmeter/productcatalog/entitlement.go +422 -0
- openmeter/productcatalog/entitlement_test.go +153 -0
- openmeter/productcatalog/errors.go +667 -0
- openmeter/productcatalog/feature/connector.go +382 -0
- openmeter/productcatalog/feature/connector_test.go +66 -0
- openmeter/productcatalog/feature/event.go +164 -0
- openmeter/productcatalog/feature/feature.go +198 -0
- openmeter/productcatalog/feature/featuremeter.go +224 -0
- openmeter/productcatalog/feature/featuremeter_test.go +108 -0
- openmeter/productcatalog/feature/meter_group_by_filters_test.go +50 -0
- openmeter/productcatalog/feature/repository.go +47 -0
- openmeter/productcatalog/feature/unitcost.go +187 -0
- openmeter/productcatalog/featureresolver.go +19 -0
- openmeter/productcatalog/featureresolver/ratecard.go +122 -0
- openmeter/productcatalog/featureresolver/ratecard_test.go +280 -0
- openmeter/productcatalog/featureresolver/resolver.go +156 -0
- openmeter/productcatalog/featureresolver/resolver_test.go +188 -0
- 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: ¤cyStr,
|
| 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, ¶ms.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, ¶ms.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 |
+
}
|