package subscription import ( "encoding/json" "fmt" "reflect" "slices" "github.com/samber/lo" "github.com/wI2L/jsondiff" "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/openmeter/entitlement" meteredentitlement "github.com/openmeterio/openmeter/openmeter/entitlement/metered" "github.com/openmeterio/openmeter/openmeter/productcatalog" "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" "github.com/openmeterio/openmeter/pkg/convert" "github.com/openmeterio/openmeter/pkg/datetime" "github.com/openmeterio/openmeter/pkg/models" ) type SubscriptionView struct { Subscription Subscription `json:"subscription"` Customer customer.Customer `json:"customer"` Spec SubscriptionSpec `json:"spec"` Phases []SubscriptionPhaseView `json:"phases"` } func (s SubscriptionView) AsSpec() SubscriptionSpec { return s.Spec } func (s SubscriptionView) GetPhaseByKey(key string) (*SubscriptionPhaseView, bool) { for _, phase := range s.Phases { if phase.SubscriptionPhase.Key == key { return &phase, true } } return nil, false } func (s *SubscriptionView) Validate(includePhases bool) error { spec := s.Spec if s.Subscription.BillingAnchor.Compare(spec.BillingAnchor) != 0 { return fmt.Errorf("subscription billing anchor %v does not match spec billing anchor %v", s.Subscription.BillingAnchor, spec.BillingAnchor) } if spec.ActiveFrom.Compare(s.Subscription.ActiveFrom) != 0 { return fmt.Errorf("subscription active from %v does not match spec active from %v", s.Subscription.ActiveFrom, spec.ActiveFrom) } if (spec.ActiveTo == nil && s.Subscription.ActiveTo != nil) || (spec.ActiveTo != nil && s.Subscription.ActiveTo == nil) || (spec.ActiveTo != nil && s.Subscription.ActiveTo != nil && spec.ActiveTo.Compare(*s.Subscription.ActiveTo) != 0) { return fmt.Errorf("subscription active to %v does not match spec active to %v", s.Subscription.ActiveTo, spec.ActiveTo) } if spec.CustomerId != s.Subscription.CustomerId { return fmt.Errorf("subscription customer id %s does not match spec customer id %s", s.Subscription.CustomerId, spec.CustomerId) } if spec.Currency != s.Subscription.Currency { return fmt.Errorf("subscription currency %s does not match spec currency %s", s.Subscription.Currency, spec.Currency) } if !spec.Plan.NilEqual(s.Subscription.PlanRef) { return fmt.Errorf("subscription plan %v does not match spec plan %v", s.Subscription.PlanRef, spec.Plan) } if includePhases { for _, phase := range s.Phases { if err := phase.Validate(true); err != nil { return fmt.Errorf("phase %s is invalid: %w", phase.Spec.PhaseKey, err) } } } if spec.SettlementMode != s.Subscription.SettlementMode { return fmt.Errorf("subscription settlement mode %s does not match spec settlement mode %s", s.Subscription.SettlementMode, spec.SettlementMode) } return nil } type SubscriptionPhaseView struct { SubscriptionPhase SubscriptionPhase `json:"subscriptionPhase"` Spec SubscriptionPhaseSpec `json:"spec"` ItemsByKey map[string][]SubscriptionItemView `json:"itemsByKey"` } func (s *SubscriptionPhaseView) AsSpec() SubscriptionPhaseSpec { return s.Spec } func (s *SubscriptionPhaseView) Validate(includeItems bool) error { if includeItems { for _, items := range s.ItemsByKey { for _, item := range items { if err := item.Validate(); err != nil { return fmt.Errorf("item %s in phase %s starting after %s is invalid: %w", item.Spec.ItemKey, item.Spec.ActiveFromOverrideRelativeToPhaseStart.ISOStringPtrOrNil(), s.Spec.PhaseKey, err) } } } } return nil } type SubscriptionItemView struct { SubscriptionItem SubscriptionItem `json:"subscriptionItem"` Spec SubscriptionItemSpec `json:"spec"` Entitlement *SubscriptionEntitlement `json:"entitlement,omitempty"` Feature *feature.Feature `json:"feature,omitempty"` } func (s *SubscriptionItemView) AsSpec() SubscriptionItemSpec { return s.Spec } func (s *SubscriptionItemView) Validate() error { // Let's validate that the RateCard contents match in Spec and SubscriptionItem if !s.Spec.RateCard.Equal(s.SubscriptionItem.RateCard) { return fmt.Errorf("item %s rate card %+v does not match spec rate card %+v", s.Spec.ItemKey, s.SubscriptionItem.RateCard, s.Spec.RateCard) } // Let's validate whether it should have an entitlement if (s.Entitlement == nil) != (s.SubscriptionItem.RateCard.AsMeta().EntitlementTemplate == nil) { return fmt.Errorf("item %s should have an entitlement: %v", s.Spec.ItemKey, s.SubscriptionItem.RateCard.AsMeta().EntitlementTemplate) } // Let's validate the Entitlement looks as it should if s.Entitlement != nil && s.SubscriptionItem.RateCard.AsMeta().EntitlementTemplate != nil { // First, lets validate the nested model if err := s.Entitlement.Validate(); err != nil { return fmt.Errorf("entitlement for item %s is invalid: %w", s.Spec.ItemKey, err) } // Second, let's validate the linking if !reflect.DeepEqual(&s.Entitlement.Entitlement.ID, s.SubscriptionItem.EntitlementID) { return fmt.Errorf("entitlement %s does not match item %s entitlement id", s.Entitlement.Entitlement.ID, s.Spec.ItemKey) } // Third, let's validate it looks according to the Template tpl := s.SubscriptionItem.RateCard.AsMeta().EntitlementTemplate ent := s.Entitlement.Entitlement switch tpl.Type() { case entitlement.EntitlementTypeBoolean: if ent.EntitlementType != entitlement.EntitlementTypeBoolean { return fmt.Errorf("entitlement %s is not boolean", s.Entitlement.Entitlement.ID) } case entitlement.EntitlementTypeStatic: if ent.EntitlementType != entitlement.EntitlementTypeStatic { return fmt.Errorf("entitlement %s is not static", s.Entitlement.Entitlement.ID) } e, err := tpl.AsStatic() if err != nil { return fmt.Errorf("entitlement template for Item %s is not static: %w", s.SubscriptionItem.Key, err) } var configJSON string err = json.Unmarshal(e.Config, &configJSON) if err != nil { return fmt.Errorf("entitlement template for Item %s has invalid JSON config: %w", s.SubscriptionItem.Key, err) } diff, err := jsondiff.CompareJSON([]byte(configJSON), []byte(lo.FromPtr(ent.Config))) if err != nil { return fmt.Errorf("failed to compare entitlement with template config for Item %s: %w", s.SubscriptionItem.Key, err) } if len(diff) > 0 { return fmt.Errorf("entitlement %s config does not match template config", s.Entitlement.Entitlement.ID) } case entitlement.EntitlementTypeMetered: mEnt, err := meteredentitlement.ParseFromGenericEntitlement(&ent.Entitlement) if err != nil { return fmt.Errorf("entitlement %s is not metered: %w", s.Entitlement.Entitlement.ID, err) } e, err := tpl.AsMetered() if err != nil { return fmt.Errorf("entitlement template for Item %s is not metered: %w", s.SubscriptionItem.Key, err) } if e.IsSoftLimit != mEnt.IsSoftLimit { return fmt.Errorf("entitlement %s isSoftLimit does not match template isSoftLimit", s.Entitlement.Entitlement.ID) } if !reflect.DeepEqual(e.IssueAfterReset, convert.SafeDeRef(mEnt.IssueAfterReset, func(m meteredentitlement.IssueAfterReset) *float64 { return &m.Amount })) { return fmt.Errorf("entitlement %s issueAfterReset does not match template issueAfterReset", s.Entitlement.Entitlement.ID) } if !reflect.DeepEqual(e.IssueAfterResetPriority, convert.SafeDeRef(mEnt.IssueAfterReset, func(m meteredentitlement.IssueAfterReset) *uint8 { return m.Priority })) { return fmt.Errorf("entitlement %s issueAfterResetPriority does not match template issueAfterResetPriority", s.Entitlement.Entitlement.ID) } // FIXME: instead of this defaulting behavior we should align the types so that MeteredEntitlementTemplate has the same required fields as MeteredEntitlement if !reflect.DeepEqual(lo.CoalesceOrEmpty(e.PreserveOverageAtReset, lo.ToPtr(false)), &mEnt.PreserveOverageAtReset) { return fmt.Errorf("entitlement %s preserveOverageAtReset does not match template preserveOverageAtReset", s.Entitlement.Entitlement.ID) } default: return fmt.Errorf("entitlement type %s is not supported", s.SubscriptionItem.RateCard.AsMeta().EntitlementTemplate.Type()) } } // Let's validate the Feature if s.Feature != nil { if s.SubscriptionItem.RateCard.AsMeta().FeatureKey == nil { return fmt.Errorf("item %s has a feature, but no feature key", s.Spec.ItemKey) } // If it has an entitlement lets compare to the ID, otherwise let's compare the key if s.Entitlement != nil { if s.Entitlement.Entitlement.FeatureID != s.Feature.ID { return fmt.Errorf("entitlement %s feature id %s does not match item %s feature id %s", s.Entitlement.Entitlement.ID, s.Entitlement.Entitlement.FeatureID, s.Spec.ItemKey, s.Feature.ID) } } else { if *s.SubscriptionItem.RateCard.AsMeta().FeatureKey != s.Feature.Key { return fmt.Errorf("item %s feature key %s does not match feature key %s", s.Spec.ItemKey, *s.SubscriptionItem.RateCard.AsMeta().FeatureKey, s.Feature.Key) } } } return nil } func NewSubscriptionView( sub Subscription, cust customer.Customer, phases []SubscriptionPhase, items []SubscriptionItem, ents []SubscriptionEntitlement, entFeats []feature.Feature, itemFeats []feature.Feature, ) (*SubscriptionView, error) { spec := SubscriptionSpec{ CreateSubscriptionPlanInput: CreateSubscriptionPlanInput{ Plan: sub.PlanRef, BillingCadence: sub.BillingCadence, ProRatingConfig: sub.ProRatingConfig, SettlementMode: sub.SettlementMode, }, CreateSubscriptionCustomerInput: CreateSubscriptionCustomerInput{ CustomerId: sub.CustomerId, Currency: sub.Currency, ActiveFrom: sub.ActiveFrom, ActiveTo: sub.ActiveTo, MetadataModel: sub.MetadataModel, Name: sub.Name, Description: sub.Description, BillingAnchor: sub.BillingAnchor, }, Phases: make(map[string]*SubscriptionPhaseSpec), } view := &SubscriptionView{ Subscription: sub, Customer: cust, } // Let's validate that all items are used unvisitedItems := make(map[string]struct{}) for _, item := range items { // And also that there are no duplicates if _, ok := unvisitedItems[item.ID]; ok { return nil, fmt.Errorf("item %s is duplicated", item.ID) } unvisitedItems[item.ID] = struct{}{} } // Lets validate that all ents are used unvisitedEnts := map[string]struct{}{} for _, ent := range ents { // While here, lets also validate that there are no duplicates if _, ok := unvisitedEnts[ent.Entitlement.ID]; ok { return nil, fmt.Errorf("entitlement %s is duplicated", ent.Entitlement.ID) } unvisitedEnts[ent.Entitlement.ID] = struct{}{} } // Let's sort the phases sortedPhases := make([]SubscriptionPhase, len(phases)) copy(sortedPhases, phases) slices.SortStableFunc(sortedPhases, func(i, j SubscriptionPhase) int { return i.ActiveFrom.Compare(j.ActiveFrom) }) itemsByPhase := lo.GroupBy(items, func(item SubscriptionItem) string { return item.PhaseId }) // Let's start with all the phases for _, phase := range sortedPhases { // Let's guard against duplicates if _, ok := spec.Phases[phase.Key]; ok { return nil, fmt.Errorf("phase %s is duplicated", phase.Key) } phaseStartAfter := datetime.ISODurationBetween(sub.ActiveFrom, phase.ActiveFrom) phaseSpec := SubscriptionPhaseSpec{ CreateSubscriptionPhasePlanInput: CreateSubscriptionPhasePlanInput{ PhaseKey: phase.Key, StartAfter: phaseStartAfter, Name: phase.Name, Description: phase.Description, SortHint: phase.SortHint, }, CreateSubscriptionPhaseCustomerInput: CreateSubscriptionPhaseCustomerInput{ MetadataModel: phase.MetadataModel, }, ItemsByKey: make(map[string][]*SubscriptionItemSpec), } phaseView := SubscriptionPhaseView{ SubscriptionPhase: phase, ItemsByKey: make(map[string][]SubscriptionItemView), } phaseItems, ok := itemsByPhase[phase.ID] if !ok { return nil, fmt.Errorf("items for phase %s not found", phase.Key) } // Let's group the items by key phaseItemsByKey := lo.GroupBy(phaseItems, func(item SubscriptionItem) string { return item.Key }) // Let's sort the items by start time for key := range phaseItemsByKey { // Any arbitrary time works as long as its consistent for the comparisons slices.SortStableFunc(phaseItemsByKey[key], func(i, j SubscriptionItem) int { iT, jT := phase.ActiveFrom, phase.ActiveFrom if i.ActiveFromOverrideRelativeToPhaseStart != nil { iT, _ = i.ActiveFromOverrideRelativeToPhaseStart.AddTo(phase.ActiveFrom) } if j.ActiveFromOverrideRelativeToPhaseStart != nil { jT, _ = j.ActiveFromOverrideRelativeToPhaseStart.AddTo(phase.ActiveFrom) } return int(iT.Sub(jT)) }) } for key, items := range phaseItemsByKey { for _, item := range items { // Sanity check if item.PhaseId != phase.ID { return nil, fmt.Errorf("item %s of phase %s is not in the correct phase", item.Key, phase.Key) } // Sanity check 2 if item.Key != key { return nil, fmt.Errorf("item %s of phase %s is not in the correct group", item.Key, phase.Key) } delete(unvisitedItems, item.ID) itemSpec := SubscriptionItemSpec{ CreateSubscriptionItemInput: CreateSubscriptionItemInput{ CreateSubscriptionItemPlanInput: CreateSubscriptionItemPlanInput{ PhaseKey: phase.Key, ItemKey: item.Key, RateCard: item.RateCard, }, CreateSubscriptionItemCustomerInput: CreateSubscriptionItemCustomerInput{ ActiveFromOverrideRelativeToPhaseStart: item.ActiveFromOverrideRelativeToPhaseStart, ActiveToOverrideRelativeToPhaseStart: item.ActiveToOverrideRelativeToPhaseStart, BillingBehaviorOverride: item.BillingBehaviorOverride, }, Annotations: item.Annotations, }, } // Let's find the entitlement var subEnt *SubscriptionEntitlement if ent, ok := lo.Find(ents, func(i SubscriptionEntitlement) bool { return reflect.DeepEqual(&i.Entitlement.ID, item.EntitlementID) }); ok { subEnt = &ent delete(unvisitedEnts, ent.Entitlement.ID) } var itemFeat *feature.Feature // If entitlement is present, we use the entitlement's feature, otherwise we use the item's feature if subEnt != nil { if feat, ok := lo.Find(entFeats, func(i feature.Feature) bool { return i.ID == subEnt.Entitlement.FeatureID }); ok { itemFeat = &feat } } else if item.RateCard.AsMeta().FeatureKey != nil { if feat, ok := lo.Find(itemFeats, func(i feature.Feature) bool { return i.Key == *item.RateCard.AsMeta().FeatureKey }); ok { itemFeat = &feat } } if itemFeat != nil { _ = item.RateCard.ChangeMeta(func(m productcatalog.RateCardMeta) (productcatalog.RateCardMeta, error) { m.FeatureID = lo.ToPtr(itemFeat.ID) return m, nil }) } itemView := SubscriptionItemView{ SubscriptionItem: item, Entitlement: subEnt, Feature: itemFeat, Spec: itemSpec, } phaseSpec.ItemsByKey[key] = append(phaseSpec.ItemsByKey[item.Key], &itemSpec) phaseView.ItemsByKey[key] = append(phaseView.ItemsByKey[key], itemView) } } spec.Phases[phase.Key] = &phaseSpec // Let's add spec to view phaseView.Spec = phaseSpec view.Phases = append(view.Phases, phaseView) } if len(unvisitedEnts) > 0 { return nil, fmt.Errorf("unvisited entitlements: %v", unvisitedEnts) } if len(unvisitedItems) > 0 { return nil, fmt.Errorf("unvisited items: %v", unvisitedItems) } if err := spec.Validate(); err != nil { return nil, models.ErrorWithComponent("subscriptionspec", err) } // Let's add spec to view view.Spec = spec if err := view.Validate(true); err != nil { return nil, fmt.Errorf("subscription view is invalid: %w", err) } return view, nil }