| 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 { |
| |
| 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) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| if s.Entitlement != nil && s.SubscriptionItem.RateCard.AsMeta().EntitlementTemplate != nil { |
| |
| if err := s.Entitlement.Validate(); err != nil { |
| return fmt.Errorf("entitlement for item %s is invalid: %w", s.Spec.ItemKey, err) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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) |
| } |
|
|
| |
| 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()) |
| } |
| } |
|
|
| |
| 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 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, |
| } |
|
|
| |
| unvisitedItems := make(map[string]struct{}) |
| for _, item := range items { |
| |
| if _, ok := unvisitedItems[item.ID]; ok { |
| return nil, fmt.Errorf("item %s is duplicated", item.ID) |
| } |
|
|
| unvisitedItems[item.ID] = struct{}{} |
| } |
|
|
| |
| unvisitedEnts := map[string]struct{}{} |
| for _, ent := range ents { |
| |
| if _, ok := unvisitedEnts[ent.Entitlement.ID]; ok { |
| return nil, fmt.Errorf("entitlement %s is duplicated", ent.Entitlement.ID) |
| } |
|
|
| unvisitedEnts[ent.Entitlement.ID] = struct{}{} |
| } |
|
|
| |
| 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 |
| }) |
|
|
| |
| for _, phase := range sortedPhases { |
| |
| 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) |
| } |
|
|
| |
| phaseItemsByKey := lo.GroupBy(phaseItems, func(item SubscriptionItem) string { |
| return item.Key |
| }) |
|
|
| |
| for key := range phaseItemsByKey { |
| |
| 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 { |
| |
| if item.PhaseId != phase.ID { |
| return nil, fmt.Errorf("item %s of phase %s is not in the correct phase", item.Key, phase.Key) |
| } |
|
|
| |
| 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, |
| }, |
| } |
|
|
| |
|
|
| 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 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 |
| |
|
|
| 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) |
| } |
|
|
| |
| view.Spec = spec |
|
|
| if err := view.Validate(true); err != nil { |
| return nil, fmt.Errorf("subscription view is invalid: %w", err) |
| } |
|
|
| return view, nil |
| } |
|
|