| package subscription |
|
|
| import ( |
| "errors" |
| "fmt" |
|
|
| "github.com/samber/lo" |
|
|
| "github.com/openmeterio/openmeter/pkg/models" |
| "github.com/openmeterio/openmeter/pkg/slicesx" |
| ) |
|
|
| func ValidateUniqueConstraintBySubscriptions(subs []SubscriptionSpec) error { |
| var errs []error |
|
|
| if overlaps := models.NewSortedCadenceList(subs).GetOverlaps(); len(overlaps) > 0 { |
| for _, overlap := range overlaps { |
| |
| errs = append(errs, |
| ErrOnlySingleSubscriptionAllowed.WithAttrs(models.Attributes{ |
| ErrCodeOnlySingleSubscriptionAllowed: SubscriptionSubscriptionLevelUniqueConstraintErrorDetail{ |
| This: SubscriptionSubscriptionLevelUniqueConstraintErrorDetailSide{ |
| Subscription: overlap.Item1, |
| Cadence: overlap.Item1.GetCadence(), |
| Selectors: subscriptionSpecToFieldSelectors(overlap.Item1), |
| }, |
| Other: SubscriptionSubscriptionLevelUniqueConstraintErrorDetailSide{ |
| Subscription: overlap.Item2, |
| Cadence: overlap.Item2.GetCadence(), |
| Selectors: subscriptionSpecToFieldSelectors(overlap.Item2), |
| }, |
| }, |
| }).WithField(subscriptionSpecToFieldSelectors(overlap.Item1))) |
|
|
| errs = append(errs, |
| ErrOnlySingleSubscriptionAllowed.WithAttrs(models.Attributes{ |
| ErrCodeOnlySingleSubscriptionAllowed: SubscriptionSubscriptionLevelUniqueConstraintErrorDetail{ |
| This: SubscriptionSubscriptionLevelUniqueConstraintErrorDetailSide{ |
| Subscription: overlap.Item2, |
| Cadence: overlap.Item2.GetCadence(), |
| Selectors: subscriptionSpecToFieldSelectors(overlap.Item2), |
| }, |
| Other: SubscriptionSubscriptionLevelUniqueConstraintErrorDetailSide{ |
| Subscription: overlap.Item1, |
| Cadence: overlap.Item1.GetCadence(), |
| Selectors: subscriptionSpecToFieldSelectors(overlap.Item1), |
| }, |
| }, |
| }).WithField(subscriptionSpecToFieldSelectors(overlap.Item2))) |
| } |
| } |
|
|
| return errors.Join(errs...) |
| } |
|
|
| func ValidateUniqueConstraintByFeatures(subs []SubscriptionSpec) error { |
| return featureLevelUniqueConstraintValidator{}.Validate(subs) |
| } |
|
|
| type SubscriptionSubscriptionLevelUniqueConstraintErrorDetailSide struct { |
| Subscription SubscriptionSpec `json:"subscription"` |
| Cadence models.CadencedModel `json:"cadence"` |
| Selectors *models.FieldDescriptor `json:"selectors"` |
| } |
|
|
| type SubscriptionSubscriptionLevelUniqueConstraintErrorDetail = models.Overlap[SubscriptionSubscriptionLevelUniqueConstraintErrorDetailSide] |
|
|
| type SubscriptionFeatureLevelUniqueConstraintErrorDetailSide struct { |
| Item SubscriptionItemSpec `json:"-"` |
| Cadence models.CadencedModel `json:"cadence"` |
| Selectors *models.FieldDescriptor `json:"selectors"` |
| PlanRef PlanRef `json:"plan_ref"` |
| } |
|
|
| type SubscriptionFeatureLevelUniqueConstraintErrorDetail = models.Overlap[SubscriptionFeatureLevelUniqueConstraintErrorDetailSide] |
|
|
| |
| type featureLevelUniqueConstraintValidator struct{} |
|
|
| func (v featureLevelUniqueConstraintValidator) Validate(subs []SubscriptionSpec) error { |
| relevantItems := v.collectRelevantItems(subs, v.itemIsRelevant) |
| timelinesForRelevantItems, err := v.buildRelevantTimelines(relevantItems) |
| if err != nil { |
| return err |
| } |
|
|
| var errs []error |
| for _, timeline := range timelinesForRelevantItems { |
| if overlaps := timeline.GetOverlaps(); len(overlaps) > 0 { |
| for _, overlap := range overlaps { |
| |
| errs = append(errs, |
| ErrOnlySingleSubscriptionItemAllowedAtATime. |
| WithAttrs(overlap.Item1.GetErrorAttributes(overlap.Item2)). |
| WithField(overlap.Item1.Item.GetSelectors())) |
|
|
| errs = append(errs, |
| ErrOnlySingleSubscriptionItemAllowedAtATime. |
| WithAttrs(overlap.Item2.GetErrorAttributes(overlap.Item1)). |
| WithField(overlap.Item2.Item.GetSelectors())) |
| } |
| } |
| } |
|
|
| return errors.Join(errs...) |
| } |
|
|
| func (v featureLevelUniqueConstraintValidator) buildRelevantTimelines(itemMap map[string][]itemSpecWithCircularReferences) (map[string]models.CadenceList[validationTimelineEntry], error) { |
| timelines := make(map[string]models.CadenceList[validationTimelineEntry]) |
|
|
| for itemKey, items := range itemMap { |
| validationTimelineEntries, err := slicesx.MapWithErr(items, func(item itemSpecWithCircularReferences) (validationTimelineEntry, error) { |
| phaseCadence, err := item.SubscriptionSpec.GetPhaseCadence(item.SubscriptionPhaseSpec.PhaseKey) |
| if err != nil { |
| return validationTimelineEntry{}, fmt.Errorf("failed to get phase cadence for item %s: %w", itemKey, err) |
| } |
|
|
| itemCadence := item.SubscriptionItemSpec.GetCadence(phaseCadence) |
|
|
| return validationTimelineEntry{ |
| Item: &item, |
| Cadence: itemCadence, |
| }, nil |
| }) |
| if err != nil { |
| return nil, err |
| } |
|
|
| timelines[itemKey] = models.NewSortedCadenceList(validationTimelineEntries) |
| } |
|
|
| return timelines, nil |
| } |
|
|
| func (v featureLevelUniqueConstraintValidator) collectRelevantItems(subs []SubscriptionSpec, condition func(item *SubscriptionItemSpec) bool) map[string][]itemSpecWithCircularReferences { |
| relevantItems := make(map[string][]itemSpecWithCircularReferences) |
|
|
| for si := range subs { |
| sub := subs[si] |
| for pi := range sub.Phases { |
| phase := sub.Phases[pi] |
| for itemKey, items := range phase.ItemsByKey { |
| for idx := range items { |
| item := items[idx] |
| if condition(item) { |
| relevantItems[itemKey] = append(relevantItems[itemKey], itemSpecWithCircularReferences{ |
| SubscriptionItemVersion: idx, |
| SubscriptionItemSpec: item, |
| SubscriptionPhaseSpec: phase, |
| SubscriptionSpec: &sub, |
| }) |
| } |
| } |
| } |
| } |
| } |
|
|
| return relevantItems |
| } |
|
|
| func (v featureLevelUniqueConstraintValidator) itemIsRelevant(item *SubscriptionItemSpec) bool { |
| if item == nil { |
| return false |
| } |
|
|
| return v.itemHasEntitlements(item) || v.itemIsBillable(item) |
| } |
|
|
| func (v featureLevelUniqueConstraintValidator) itemHasEntitlements(item *SubscriptionItemSpec) bool { |
| if item == nil { |
| return false |
| } |
|
|
| return item.RateCard.AsMeta().EntitlementTemplate != nil |
| } |
|
|
| func (v featureLevelUniqueConstraintValidator) itemIsBillable(item *SubscriptionItemSpec) bool { |
| if item == nil { |
| return false |
| } |
|
|
| return item.RateCard.AsMeta().IsBillable() |
| } |
|
|
| |
| type itemSpecWithCircularReferences struct { |
| SubscriptionItemVersion int |
| SubscriptionItemSpec *SubscriptionItemSpec |
| SubscriptionPhaseSpec *SubscriptionPhaseSpec |
| SubscriptionSpec *SubscriptionSpec |
| } |
|
|
| func (i itemSpecWithCircularReferences) GetSelectors() *models.FieldDescriptor { |
| return models.NewFieldSelectorGroup( |
| subscriptionSpecToFieldSelectors(lo.FromPtr(i.SubscriptionSpec)), |
| models.NewFieldSelector("phases").WithExpression(models.NewFieldAttrValue("key", i.SubscriptionPhaseSpec.PhaseKey)), |
| models.NewFieldSelector("items").WithExpression(models.NewFieldAttrValue("key", i.SubscriptionItemSpec.ItemKey)), |
| models.NewFieldSelector("idx").WithExpression(models.NewFieldArrIndex(i.SubscriptionItemVersion)), |
| ) |
| } |
|
|
| type validationTimelineEntry struct { |
| Item *itemSpecWithCircularReferences |
| Cadence models.CadencedModel |
| } |
|
|
| func (i validationTimelineEntry) GetCadence() models.CadencedModel { |
| return i.Cadence |
| } |
|
|
| func (i validationTimelineEntry) GetErrorAttributes(other validationTimelineEntry) models.Attributes { |
| return models.Attributes{ |
| ErrCodeOnlySingleSubscriptionItemAllowedAtATime: SubscriptionFeatureLevelUniqueConstraintErrorDetail{ |
| This: SubscriptionFeatureLevelUniqueConstraintErrorDetailSide{ |
| Item: lo.FromPtr(i.Item.SubscriptionItemSpec), |
| PlanRef: lo.FromPtr(i.Item.SubscriptionSpec.Plan), |
| Cadence: i.GetCadence(), |
| Selectors: i.Item.GetSelectors(), |
| }, |
| Other: SubscriptionFeatureLevelUniqueConstraintErrorDetailSide{ |
| Item: lo.FromPtr(other.Item.SubscriptionItemSpec), |
| PlanRef: lo.FromPtr(other.Item.SubscriptionSpec.Plan), |
| Cadence: other.GetCadence(), |
| Selectors: other.Item.GetSelectors(), |
| }, |
| }, |
| } |
| } |
|
|
| func subscriptionSpecToFieldSelectors(subscriptionSpec SubscriptionSpec) *models.FieldDescriptor { |
| selectors := []*models.FieldDescriptor{} |
|
|
| if subscriptionSpec.Plan != nil { |
| selectors = append(selectors, planRefToFieldSelector(subscriptionSpec.Plan)) |
| } else { |
| selectors = append(selectors, models.NewFieldSelector("plans")) |
| } |
|
|
| selectors = append(selectors, |
| models.NewFieldSelector("subscriptions").WithExpression(models.NewMultiFieldAttrValue( |
| func() []models.FieldAttrValue { |
| res := []models.FieldAttrValue{ |
| models.NewFieldAttrValue("customerId", subscriptionSpec.CustomerId), |
| models.NewFieldAttrValue("activeFrom", subscriptionSpec.ActiveFrom), |
| } |
|
|
| if subscriptionSpec.ActiveTo != nil { |
| res = append(res, models.NewFieldAttrValue("activeTo", subscriptionSpec.ActiveTo)) |
| } |
|
|
| return res |
| }()..., |
| ))) |
|
|
| return models.NewFieldSelectorGroup(selectors...) |
| } |
|
|
| func planRefToFieldSelector(planRef *PlanRef) *models.FieldDescriptor { |
| if planRef == nil { |
| return models.NewFieldSelector("plans") |
| } |
|
|
| return models.NewFieldSelector("plans").WithExpression(models.NewMultiFieldAttrValue( |
| func() []models.FieldAttrValue { |
| res := []models.FieldAttrValue{} |
|
|
| if planRef.Key != "" { |
| res = append(res, models.NewFieldAttrValue("key", planRef.Key)) |
| } |
|
|
| if planRef.Version != 0 { |
| res = append(res, models.NewFieldAttrValue("version", planRef.Version)) |
| } |
|
|
| if planRef.Id != "" { |
| res = append(res, models.NewFieldAttrValue("id", planRef.Id)) |
| } |
|
|
| return res |
| }()..., |
| )) |
| } |
|
|