| package service |
|
|
| import ( |
| "context" |
| "fmt" |
|
|
| "github.com/samber/lo" |
|
|
| "github.com/openmeterio/openmeter/openmeter/entitlement" |
| "github.com/openmeterio/openmeter/openmeter/subscription" |
| "github.com/openmeterio/openmeter/pkg/convert" |
| "github.com/openmeterio/openmeter/pkg/framework/transaction" |
| "github.com/openmeterio/openmeter/pkg/models" |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| func (s *service) sync(ctx context.Context, view subscription.SubscriptionView, newSpec subscription.SubscriptionSpec) (subscription.Subscription, error) { |
| return transaction.Run(ctx, s.TransactionManager, func(ctx context.Context) (subscription.Subscription, error) { |
| var def subscription.Subscription |
|
|
| |
| if view.Subscription.CustomerId != newSpec.CustomerId { |
| return def, fmt.Errorf("cannot change customer id") |
| } |
| if !view.Subscription.PlanRef.NilEqual(newSpec.Plan) { |
| return def, fmt.Errorf("cannot change plan") |
| } |
| if !view.Subscription.ActiveFrom.Equal(newSpec.ActiveFrom) { |
| return def, fmt.Errorf("cannot change subscription start") |
| } |
| if view.Subscription.SettlementMode != newSpec.SettlementMode { |
| return def, fmt.Errorf("cannot change settlement mode") |
| } |
|
|
| dirty := make(touched) |
|
|
| |
| if !view.Subscription.CadencedModel.Equal(models.CadencedModel{ActiveFrom: newSpec.ActiveFrom, ActiveTo: newSpec.ActiveTo}) { |
| _, err := s.SubscriptionRepo.SetEndOfCadence(ctx, view.Subscription.NamespacedID, newSpec.ActiveTo) |
| if err != nil { |
| return def, fmt.Errorf("failed to set end of cadence: %w", err) |
| } |
| } |
|
|
| |
| newSortedPhaseSpecs := newSpec.GetSortedPhases() |
| for _, currentPhaseView := range view.Phases { |
| |
| matchingPhaseFromNewSpec, found := lo.Find(newSortedPhaseSpecs, func(s *subscription.SubscriptionPhaseSpec) bool { |
| return s.PhaseKey == currentPhaseView.SubscriptionPhase.Key |
| }) |
|
|
| |
| if !found { |
| if err := s.deletePhase(ctx, currentPhaseView); err != nil { |
| return def, fmt.Errorf("failed to delete phase: %w", err) |
| } |
|
|
| dirty.mark(subscription.NewPhasePath(currentPhaseView.SubscriptionPhase.Key)) |
|
|
| |
| continue |
| } |
|
|
| |
| if matchingPhaseFromNewSpec == nil { |
| return def, fmt.Errorf("failed to find matching phase in new spec but no error was returned") |
| } |
|
|
| |
| cadenceOfCurrentPhaseBasedOnSpec, err := view.Spec.GetPhaseCadence(currentPhaseView.SubscriptionPhase.Key) |
| if err != nil { |
| return def, fmt.Errorf("failed to get cadence for current phase %s: %w", currentPhaseView.SubscriptionPhase.Key, err) |
| } |
|
|
| |
| cadenceOfNewPhaseBasedOnSpec, err := newSpec.GetPhaseCadence(matchingPhaseFromNewSpec.PhaseKey) |
| if err != nil { |
| return def, fmt.Errorf("failed to get cadence for new phase %s: %w", matchingPhaseFromNewSpec.PhaseKey, err) |
| } |
|
|
| |
| newPhaseStartTime, _ := matchingPhaseFromNewSpec.StartAfter.AddTo(view.Subscription.ActiveFrom) |
|
|
| curr := currentPhaseView.Spec.ToCreateSubscriptionPhaseEntityInput(view.Subscription, currentPhaseView.SubscriptionPhase.ActiveFrom) |
| new := matchingPhaseFromNewSpec.ToCreateSubscriptionPhaseEntityInput(view.Subscription, newPhaseStartTime) |
|
|
| |
| if !curr.Equal(new) { |
| |
| if err := s.deletePhase(ctx, currentPhaseView); err != nil { |
| return def, fmt.Errorf("failed to delete phase: %w", err) |
| } |
|
|
| dirty.mark(subscription.NewPhasePath(currentPhaseView.SubscriptionPhase.Key)) |
|
|
| |
| continue |
| } |
|
|
| |
| if dirty.isTouched(subscription.NewPhasePath(currentPhaseView.SubscriptionPhase.Key)) { |
| return def, fmt.Errorf("current phase is dirty but should not be") |
| } |
|
|
| |
| for currentItemViewsKey, currentItemViews := range currentPhaseView.ItemsByKey { |
| |
| |
| |
| matchingItemsByKeyFromNewSpec, found := matchingPhaseFromNewSpec.ItemsByKey[currentItemViewsKey] |
|
|
| |
| if !found || len(matchingItemsByKeyFromNewSpec) == 0 { |
| for _, currentItemView := range currentItemViews { |
| if err := s.deleteItem(ctx, currentItemView); err != nil { |
| return def, fmt.Errorf("failed to delete item: %w", err) |
| } |
|
|
| dirty.mark(subscription.NewItemPath(currentItemView.Spec.PhaseKey, currentItemView.Spec.ItemKey)) |
| } |
|
|
| |
| continue |
| } |
|
|
| for currentItemIdx, currentItemView := range currentItemViews { |
| |
| if currentItemIdx >= len(matchingItemsByKeyFromNewSpec) { |
| |
|
|
| if err := s.deleteItem(ctx, currentItemView); err != nil { |
| return def, fmt.Errorf("failed to delete item: %w", err) |
| } |
|
|
| dirty.mark(subscription.NewItemVersionPath(currentItemView.Spec.PhaseKey, currentItemView.Spec.ItemKey, currentItemIdx)) |
|
|
| |
| continue |
| } |
|
|
| matchingItemFromNewSpec := matchingItemsByKeyFromNewSpec[currentItemIdx] |
|
|
| |
| curr, err := currentItemView.Spec.ToCreateSubscriptionItemEntityInput( |
| currentPhaseView.SubscriptionPhase.NamespacedID, |
| cadenceOfCurrentPhaseBasedOnSpec, |
| convert.SafeDeRef(currentItemView.Entitlement, func(s subscription.SubscriptionEntitlement) *entitlement.Entitlement { |
| return &s.Entitlement.Entitlement |
| }), |
| ) |
| if err != nil { |
| return def, fmt.Errorf("failed to convert item to entity input: %w", err) |
| } |
|
|
| |
| |
|
|
| newPhaseID := currentPhaseView.SubscriptionPhase.NamespacedID |
| if dirty.isTouched(subscription.NewPhasePath(currentPhaseView.SubscriptionPhase.Key)) { |
| newPhaseID = impossibleNamespacedId |
| } |
|
|
| newOnlyForComparisonWithInvalidProperties, err := matchingItemFromNewSpec.ToCreateSubscriptionItemEntityInput( |
| newPhaseID, |
| cadenceOfNewPhaseBasedOnSpec, |
| |
| |
| |
| |
| convert.SafeDeRef(currentItemView.Entitlement, func(s subscription.SubscriptionEntitlement) *entitlement.Entitlement { |
| return &s.Entitlement.Entitlement |
| }), |
| ) |
| if err != nil { |
| return def, fmt.Errorf("failed to convert item to entity input: %w", err) |
| } |
|
|
| doesItemNeedToBeChanged := !curr.Equal(newOnlyForComparisonWithInvalidProperties) |
|
|
| if doesItemNeedToBeChanged { |
| |
| if err := s.deleteItem(ctx, currentItemView); err != nil { |
| return def, fmt.Errorf("failed to delete item: %w", err) |
| } |
|
|
| dirty.mark(subscription.NewItemVersionPath(currentItemView.Spec.PhaseKey, currentItemView.Spec.ItemKey, currentItemIdx)) |
|
|
| |
| continue |
| } |
| } |
| } |
| } |
|
|
| |
| for _, currentPhaseView := range view.Phases { |
| |
| matchingPhaseFromNewSpec, found := lo.Find(newSortedPhaseSpecs, func(s *subscription.SubscriptionPhaseSpec) bool { |
| return s.PhaseKey == currentPhaseView.SubscriptionPhase.Key |
| }) |
|
|
| if !found { |
| |
| continue |
| } |
|
|
| |
| if matchingPhaseFromNewSpec == nil { |
| return def, fmt.Errorf("failed to find matching phase in new spec but no error was returned") |
| } |
|
|
| newPhaseCadence, err := newSpec.GetPhaseCadence(matchingPhaseFromNewSpec.PhaseKey) |
| if err != nil { |
| return def, fmt.Errorf("failed to get cadence for phase %s: %w", matchingPhaseFromNewSpec.PhaseKey, err) |
| } |
|
|
| |
| if dirty.isTouched(subscription.NewPhasePath(currentPhaseView.SubscriptionPhase.Key)) { |
| if _, err := s.createPhase(ctx, view.Customer, *matchingPhaseFromNewSpec, view.Subscription, newPhaseCadence); err != nil { |
| return def, fmt.Errorf("failed to create phase: %w", err) |
| } |
|
|
| |
| continue |
| } |
|
|
| |
| for currentItemViewsKey, currentItemViews := range currentPhaseView.ItemsByKey { |
| |
| |
| |
| matchingItemsByKeyFromNewSpec, found := matchingPhaseFromNewSpec.ItemsByKey[currentItemViewsKey] |
| if !found { |
| |
| continue |
| } |
|
|
| for currentItemIdx, currentItemView := range currentItemViews { |
| |
| if currentItemIdx >= len(matchingItemsByKeyFromNewSpec) { |
| |
|
|
| break |
| } |
|
|
| matchingItemFromNewSpec := matchingItemsByKeyFromNewSpec[currentItemIdx] |
|
|
| |
| if dirty.isTouched(subscription.NewItemVersionPath(currentItemView.Spec.PhaseKey, currentItemView.Spec.ItemKey, currentItemIdx)) { |
| if _, err := s.createItem(ctx, createItemOptions{ |
| cust: view.Customer, |
| sub: view.Subscription, |
| phase: currentPhaseView.SubscriptionPhase, |
| phaseCadence: newPhaseCadence, |
| itemSpec: *matchingItemFromNewSpec, |
| }); err != nil { |
| return def, fmt.Errorf("failed to create item: %w", err) |
| } |
|
|
| |
| continue |
| } |
| } |
| } |
| } |
|
|
| |
| for _, phase := range newSpec.GetSortedPhases() { |
| |
| if phase == nil { |
| return def, fmt.Errorf("phase is nil") |
| } |
|
|
| |
| matchingPhaseInCurrentView, foundMatchingPhaseInCurrentView := lo.Find(view.Phases, func(p subscription.SubscriptionPhaseView) bool { |
| return p.SubscriptionPhase.Key == phase.PhaseKey |
| }) |
|
|
| if !foundMatchingPhaseInCurrentView { |
| phaseCadence, err := newSpec.GetPhaseCadence(phase.PhaseKey) |
| if err != nil { |
| return def, fmt.Errorf("failed to get cadence for phase %s: %w", phase.PhaseKey, err) |
| } |
|
|
| if _, err := s.createPhase(ctx, view.Customer, *phase, view.Subscription, phaseCadence); err != nil { |
| return def, fmt.Errorf("failed to create phase: %w", err) |
| } |
| continue |
| } |
|
|
| |
| for key, itemsByKey := range phase.ItemsByKey { |
| matchingItemsByKeyInCurrentView, foundMatchingItemsByKeyInCurrentView := matchingPhaseInCurrentView.ItemsByKey[key] |
|
|
| for itemIdx, item := range itemsByKey { |
| phaseCadence, err := newSpec.GetPhaseCadence(phase.PhaseKey) |
| if err != nil { |
| return def, fmt.Errorf("failed to get cadence for phase %s: %w", phase.PhaseKey, err) |
| } |
|
|
| |
| if !foundMatchingItemsByKeyInCurrentView { |
| if _, err := s.createItem(ctx, createItemOptions{ |
| cust: view.Customer, |
| sub: view.Subscription, |
| phase: matchingPhaseInCurrentView.SubscriptionPhase, |
| phaseCadence: phaseCadence, |
| itemSpec: *item, |
| }); err != nil { |
| return def, fmt.Errorf("failed to create item: %w", err) |
| } |
|
|
| |
| continue |
| } else if itemIdx >= len(matchingItemsByKeyInCurrentView) { |
| |
| |
|
|
| |
| if _, err := s.createItem(ctx, createItemOptions{ |
| cust: view.Customer, |
| sub: view.Subscription, |
| phase: matchingPhaseInCurrentView.SubscriptionPhase, |
| phaseCadence: phaseCadence, |
| itemSpec: *item, |
| }); err != nil { |
| return def, fmt.Errorf("failed to create item: %w", err) |
| } |
| } |
| } |
| } |
| } |
|
|
| |
| return s.Get(ctx, view.Subscription.NamespacedID) |
| }) |
| } |
|
|
| |
| type touched map[subscription.SpecPath]bool |
|
|
| |
| func (t touched) mark(key subscription.SpecPath) { |
| t[key] = true |
| } |
|
|
| |
| |
| func (t touched) isTouched(key subscription.SpecPath) bool { |
| for k := range t { |
| |
| if k.IsParentOf(key) { |
| return true |
| } |
| } |
| return false |
| } |
|
|
| |
| |
|
|
| |
| var impossibleNamespacedId = models.NamespacedID{ |
| ID: "impossible", |
| Namespace: "impossible", |
| } |
|
|