From 268510487aa69911ef5dfdc0dd05fdc4b92f1398 Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Sat, 9 Nov 2024 21:54:22 +0100 Subject: [PATCH 01/13] feat: add Feature validation to Plan API --- openmeter/productcatalog/plan/ratecard.go | 15 ++++- openmeter/productcatalog/plan/service/plan.go | 62 ++++++++++++++++++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/openmeter/productcatalog/plan/ratecard.go b/openmeter/productcatalog/plan/ratecard.go index d072483fe..b722ff8af 100644 --- a/openmeter/productcatalog/plan/ratecard.go +++ b/openmeter/productcatalog/plan/ratecard.go @@ -35,6 +35,8 @@ type rateCarder interface { AsMeta() (RateCardMeta, error) FromFlatFee(FlatFeeRateCard) FromUsageBased(UsageBasedRateCard) + + Feature() *feature.Feature } var _ rateCarder = (*RateCard)(nil) @@ -45,6 +47,17 @@ type RateCard struct { usageBased *UsageBasedRateCard } +func (r *RateCard) Feature() *feature.Feature { + switch r.t { + case FlatFeeRateCardType: + return r.flatFee.Feature + case UsageBasedRateCardType: + return r.usageBased.Feature + default: + return nil + } +} + func (r *RateCard) MarshalJSON() ([]byte, error) { var b []byte var err error @@ -61,7 +74,7 @@ func (r *RateCard) MarshalJSON() ([]byte, error) { return nil, fmt.Errorf("failed to json marshal UsageBasedRateCard: %w", err) } default: - return nil, fmt.Errorf("invalid entitlement type: %s", r.t) + return nil, fmt.Errorf("invalid type: %s", r.t) } return b, nil diff --git a/openmeter/productcatalog/plan/service/plan.go b/openmeter/productcatalog/plan/service/plan.go index 16991e43b..6e13a7f33 100644 --- a/openmeter/productcatalog/plan/service/plan.go +++ b/openmeter/productcatalog/plan/service/plan.go @@ -6,6 +6,7 @@ import ( "github.com/samber/lo" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" "github.com/openmeterio/openmeter/pkg/framework/transaction" "github.com/openmeterio/openmeter/pkg/models" @@ -24,6 +25,53 @@ func (s service) ListPlans(ctx context.Context, params plan.ListPlansInput) (pag return transaction.Run(ctx, s.adapter, fn) } +func (s service) expandFeatures(ctx context.Context, phases []plan.Phase) error { + if len(phases) > 0 { + return nil + } + + for _, phase := range phases { + rateCardFeatures := make(map[string]*feature.Feature) + + for _, rateCard := range phase.RateCards { + if rateCardFeature := rateCard.Feature(); rateCardFeature != nil { + rateCardFeatures[rateCardFeature.Key] = rateCardFeature + } + } + + featureList, err := s.feature.ListFeatures(ctx, feature.ListFeaturesParams{ + IDsOrKeys: lo.Keys(rateCardFeatures), + Namespace: phase.Namespace, + Page: pagination.Page{ + PageSize: len(rateCardFeatures), + PageNumber: 1, + }, + }) + if err != nil { + return fmt.Errorf("failed to list Features for RateCard: %w", err) + } + + // Update features in-place or return error if + visited := make(map[string]struct{}) + for _, feat := range featureList.Items { + if rcFeat, ok := rateCardFeatures[feat.Key]; ok { + *rcFeat = feat + + visited[feat.Key] = struct{}{} + } + } + + if len(rateCardFeatures) != len(visited) { + missing, r := lo.Difference(lo.Keys(rateCardFeatures), lo.Keys(visited)) + missing = append(missing, r...) + + return fmt.Errorf("non-existing Features: %+v", missing) + } + } + + return nil +} + func (s service) CreatePlan(ctx context.Context, params plan.CreatePlanInput) (*plan.Plan, error) { fn := func(ctx context.Context) (*plan.Plan, error) { if err := params.Validate(); err != nil { @@ -38,6 +86,10 @@ func (s service) CreatePlan(ctx context.Context, params plan.CreatePlanInput) (* logger.Debug("creating Plan") + if err := s.expandFeatures(ctx, params.Phases); err != nil { + return nil, fmt.Errorf("failed to get Feature for RateCards: %w", err) + } + p, err := s.adapter.CreatePlan(ctx, params) if err != nil { return nil, fmt.Errorf("failed to create Plan: %w", err) @@ -125,6 +177,12 @@ func (s service) UpdatePlan(ctx context.Context, params plan.UpdatePlanInput) (* logger.Debug("updating Plan") + if params.Phases != nil { + if err := s.expandFeatures(ctx, *params.Phases); err != nil { + return nil, fmt.Errorf("failed to get Feature for RateCards: %w", err) + } + } + p, err := s.adapter.GetPlan(ctx, plan.GetPlanInput{ NamespacedID: models.NamespacedID{ Namespace: params.Namespace, @@ -192,8 +250,8 @@ func (s service) PublishPlan(ctx context.Context, params plan.PublishPlanInput) } // TODO(chrisgacsal): in order to ensure that there are not time gaps where no active version of a Plan is available - // the EffectivePeriod must be validated/updated with the surrounding Plans(N-1, N+1) if their exist. - // If updating the EffectivePeriod for surrounding Plans violates constraints, return validation error, + // the EffectivePeriod must be validated/updated with the surrounding Plans(N-1, N+1) if they exist. + // If updating the EffectivePeriod for surrounding Plans violates constraints, return an validation error, // otherwise adjust their schedule accordingly. // IMPORTANT: this should be an optional action which must be only performed with the users consent as it has side-effects. // In other words, modify the surrounding Plans only if the user is allowed it otherwise return a validation error From 71aef48e09ab1f9dcb710ab68be1f913b5135ff9 Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Mon, 11 Nov 2024 06:34:25 +0100 Subject: [PATCH 02/13] fix: PlanPhase input cmp function --- openmeter/productcatalog/plan/adapter/plan.go | 2 +- openmeter/productcatalog/plan/service.go | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/openmeter/productcatalog/plan/adapter/plan.go b/openmeter/productcatalog/plan/adapter/plan.go index d516eadbb..225572090 100644 --- a/openmeter/productcatalog/plan/adapter/plan.go +++ b/openmeter/productcatalog/plan/adapter/plan.go @@ -437,7 +437,7 @@ func (a *adapter) UpdatePlan(ctx context.Context, params plan.UpdatePlanInput) ( phase, err := a.UpdatePhase(ctx, updateInput) if err != nil { - return nil, fmt.Errorf("failed to update PlanPhasee: %w", err) + return nil, fmt.Errorf("failed to update PlanPhase: %w", err) } phases = append(phases, *phase) diff --git a/openmeter/productcatalog/plan/service.go b/openmeter/productcatalog/plan/service.go index 739a1e170..d6dd511f3 100644 --- a/openmeter/productcatalog/plan/service.go +++ b/openmeter/productcatalog/plan/service.go @@ -522,7 +522,11 @@ func (i UpdatePhaseInput) Equal(p Phase) bool { return false } - if i.Name != nil && *i.Name == p.Name { + if i.Name != nil && *i.Name != p.Name { + return false + } + + if i.StartAfter != nil && *i.StartAfter != p.StartAfter { return false } From 76c5e0c87453585691ab8cb6b5c1ab52df79053c Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Mon, 11 Nov 2024 07:21:44 +0100 Subject: [PATCH 03/13] refactor: rm unused type attr for PriceTier types --- openmeter/billing/httpdriver/invoiceline.go | 4 +- .../productcatalog/plan/httpdriver/mapping.go | 6 --- openmeter/productcatalog/plan/price.go | 4 -- openmeter/productcatalog/plan/price_test.go | 48 ------------------- 4 files changed, 2 insertions(+), 60 deletions(-) diff --git a/openmeter/billing/httpdriver/invoiceline.go b/openmeter/billing/httpdriver/invoiceline.go index 521354119..3918a73d4 100644 --- a/openmeter/billing/httpdriver/invoiceline.go +++ b/openmeter/billing/httpdriver/invoiceline.go @@ -386,14 +386,14 @@ func mapTieredPriceToAPI(p plan.TieredPrice) (api.RateCardUsageBasedPrice, error if t.FlatPrice != nil { res.FlatPrice = &api.FlatPrice{ Amount: t.FlatPrice.Amount.String(), - Type: api.FlatPriceType(t.FlatPrice.Type), + Type: api.FlatPriceType(plan.FlatPriceType), } } if t.UnitPrice != nil { res.UnitPrice = &api.UnitPrice{ Amount: t.UnitPrice.Amount.String(), - Type: api.UnitPriceType(t.UnitPrice.Type), + Type: api.UnitPriceType(plan.UnitPriceType), } } return res diff --git a/openmeter/productcatalog/plan/httpdriver/mapping.go b/openmeter/productcatalog/plan/httpdriver/mapping.go index c214e5b45..1c4a6354a 100644 --- a/openmeter/productcatalog/plan/httpdriver/mapping.go +++ b/openmeter/productcatalog/plan/httpdriver/mapping.go @@ -677,9 +677,6 @@ func AsPriceTier(t api.PriceTier) (plan.PriceTier, error) { } tier.FlatPrice = &plan.PriceTierFlatPrice{ - PriceMeta: plan.PriceMeta{ - Type: plan.FlatPriceType, - }, Amount: amount, } } @@ -691,9 +688,6 @@ func AsPriceTier(t api.PriceTier) (plan.PriceTier, error) { } tier.UnitPrice = &plan.PriceTierUnitPrice{ - PriceMeta: plan.PriceMeta{ - Type: plan.UnitPriceType, - }, Amount: amount, } } diff --git a/openmeter/productcatalog/plan/price.go b/openmeter/productcatalog/plan/price.go index 3c739575c..5d9ba8e40 100644 --- a/openmeter/productcatalog/plan/price.go +++ b/openmeter/productcatalog/plan/price.go @@ -450,8 +450,6 @@ func (p PriceTier) Validate() error { var _ Validator = (*PriceTierFlatPrice)(nil) type PriceTierFlatPrice struct { - PriceMeta - // Amount of the flat price. Amount decimal.Decimal `json:"amount"` } @@ -467,8 +465,6 @@ func (f PriceTierFlatPrice) Validate() error { var _ Validator = (*PriceTierUnitPrice)(nil) type PriceTierUnitPrice struct { - PriceMeta - // Amount of the flat price. Amount decimal.Decimal `json:"amount"` } diff --git a/openmeter/productcatalog/plan/price_test.go b/openmeter/productcatalog/plan/price_test.go index 3b87269c6..dddda3cba 100644 --- a/openmeter/productcatalog/plan/price_test.go +++ b/openmeter/productcatalog/plan/price_test.go @@ -142,30 +142,18 @@ func TestTieredPrice(t *testing.T) { { UpToAmount: lo.ToPtr(decimal.NewFromInt(1000)), FlatPrice: &PriceTierFlatPrice{ - PriceMeta: PriceMeta{ - Type: FlatPriceType, - }, Amount: decimal.NewFromInt(5), }, UnitPrice: &PriceTierUnitPrice{ - PriceMeta: PriceMeta{ - Type: UnitPriceType, - }, Amount: decimal.NewFromInt(5), }, }, { UpToAmount: lo.ToPtr(decimal.NewFromInt(2500)), FlatPrice: &PriceTierFlatPrice{ - PriceMeta: PriceMeta{ - Type: FlatPriceType, - }, Amount: decimal.NewFromInt(3), }, UnitPrice: &PriceTierUnitPrice{ - PriceMeta: PriceMeta{ - Type: UnitPriceType, - }, Amount: decimal.NewFromInt(1), }, }, @@ -186,30 +174,18 @@ func TestTieredPrice(t *testing.T) { { UpToAmount: lo.ToPtr(decimal.NewFromInt(1000)), FlatPrice: &PriceTierFlatPrice{ - PriceMeta: PriceMeta{ - Type: FlatPriceType, - }, Amount: decimal.NewFromInt(5), }, UnitPrice: &PriceTierUnitPrice{ - PriceMeta: PriceMeta{ - Type: UnitPriceType, - }, Amount: decimal.NewFromInt(5), }, }, { UpToAmount: lo.ToPtr(decimal.NewFromInt(2500)), FlatPrice: &PriceTierFlatPrice{ - PriceMeta: PriceMeta{ - Type: FlatPriceType, - }, Amount: decimal.NewFromInt(3), }, UnitPrice: &PriceTierUnitPrice{ - PriceMeta: PriceMeta{ - Type: UnitPriceType, - }, Amount: decimal.NewFromInt(1), }, }, @@ -230,30 +206,18 @@ func TestTieredPrice(t *testing.T) { { UpToAmount: lo.ToPtr(decimal.NewFromInt(1000)), FlatPrice: &PriceTierFlatPrice{ - PriceMeta: PriceMeta{ - Type: FlatPriceType, - }, Amount: decimal.NewFromInt(5), }, UnitPrice: &PriceTierUnitPrice{ - PriceMeta: PriceMeta{ - Type: UnitPriceType, - }, Amount: decimal.NewFromInt(5), }, }, { UpToAmount: lo.ToPtr(decimal.NewFromInt(2500)), FlatPrice: &PriceTierFlatPrice{ - PriceMeta: PriceMeta{ - Type: FlatPriceType, - }, Amount: decimal.NewFromInt(3), }, UnitPrice: &PriceTierUnitPrice{ - PriceMeta: PriceMeta{ - Type: UnitPriceType, - }, Amount: decimal.NewFromInt(1), }, }, @@ -274,30 +238,18 @@ func TestTieredPrice(t *testing.T) { { UpToAmount: lo.ToPtr(decimal.NewFromInt(-1000)), FlatPrice: &PriceTierFlatPrice{ - PriceMeta: PriceMeta{ - Type: FlatPriceType, - }, Amount: decimal.NewFromInt(-5), }, UnitPrice: &PriceTierUnitPrice{ - PriceMeta: PriceMeta{ - Type: UnitPriceType, - }, Amount: decimal.NewFromInt(-5), }, }, { UpToAmount: lo.ToPtr(decimal.NewFromInt(-1000)), FlatPrice: &PriceTierFlatPrice{ - PriceMeta: PriceMeta{ - Type: FlatPriceType, - }, Amount: decimal.NewFromInt(-3), }, UnitPrice: &PriceTierUnitPrice{ - PriceMeta: PriceMeta{ - Type: UnitPriceType, - }, Amount: decimal.NewFromInt(-1), }, }, From 46b9f93eae87a7e7f97df7bfe8159431ff02782b Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Mon, 11 Nov 2024 07:30:55 +0100 Subject: [PATCH 04/13] fix: status check on Plan update --- openmeter/productcatalog/plan/service/plan.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openmeter/productcatalog/plan/service/plan.go b/openmeter/productcatalog/plan/service/plan.go index 6e13a7f33..22df74f10 100644 --- a/openmeter/productcatalog/plan/service/plan.go +++ b/openmeter/productcatalog/plan/service/plan.go @@ -195,15 +195,14 @@ func (s service) UpdatePlan(ctx context.Context, params plan.UpdatePlanInput) (* allowedPlanStatuses := []plan.PlanStatus{plan.DraftStatus, plan.ScheduledStatus} planStatus := p.Status() - if lo.Contains(allowedPlanStatuses, p.Status()) { + if !lo.Contains(allowedPlanStatuses, p.Status()) { return nil, fmt.Errorf("only Plans in %+v can be updated, but it has %s state", allowedPlanStatuses, planStatus) } logger.Debug("updating plan") - // NOTE(chrisgacsal): we only allow updating the state of the Plan via Publish/Archive - // however UpdatePlanInput is shared across Update/Publish/Archive endpoints - // therefore the EffectivePeriod of its attribute must be zerod before updating the Plan. + // NOTE(chrisgacsal): we only allow updating the state of the Plan via Publish/Archive, + // therefore the EffectivePeriod attribute must be zeroed before updating the Plan. params.EffectivePeriod = nil p, err = s.adapter.UpdatePlan(ctx, params) From 702c13eb1380d201e74a41970db5fd36ac2d9faa Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Mon, 11 Nov 2024 07:44:27 +0100 Subject: [PATCH 05/13] fix: updating StartAfter for PlanPhase --- openmeter/productcatalog/plan/adapter/phase.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openmeter/productcatalog/plan/adapter/phase.go b/openmeter/productcatalog/plan/adapter/phase.go index d65d3d96f..98e7d7af3 100644 --- a/openmeter/productcatalog/plan/adapter/phase.go +++ b/openmeter/productcatalog/plan/adapter/phase.go @@ -350,7 +350,7 @@ func (a *adapter) UpdatePhase(ctx context.Context, params plan.UpdatePhaseInput) PlanID: params.PlanID, }) if err != nil { - return nil, fmt.Errorf("failed to get Plan: %w", err) + return nil, fmt.Errorf("failed to get PlanPhase: %w", err) } if !params.Equal(*p) { @@ -371,7 +371,7 @@ func (a *adapter) UpdatePhase(ctx context.Context, params plan.UpdatePhaseInput) p.Metadata = *params.Metadata } - if params.Metadata != nil { + if params.StartAfter != nil { query = query.SetStartAfter(params.StartAfter.ISOString()) p.StartAfter = *params.StartAfter } From 046e25b91b8cab2b7ef53b9f4e9d13bfd4054f91 Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Mon, 11 Nov 2024 09:33:19 +0100 Subject: [PATCH 06/13] fix: add missing attrs to RateCard cmp func --- openmeter/productcatalog/plan/adapter/mapping.go | 3 +++ openmeter/productcatalog/plan/adapter/phase.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/openmeter/productcatalog/plan/adapter/mapping.go b/openmeter/productcatalog/plan/adapter/mapping.go index 17e5072d1..a8b2be72e 100644 --- a/openmeter/productcatalog/plan/adapter/mapping.go +++ b/openmeter/productcatalog/plan/adapter/mapping.go @@ -194,6 +194,9 @@ func asPlanRateCardRow(r plan.RateCard) (entdb.PlanRateCard, error) { } ratecard := entdb.PlanRateCard{ + Namespace: meta.Namespace, + ID: meta.ID, + PhaseID: meta.PhaseID, Key: meta.Key, Metadata: meta.Metadata, Name: meta.Name, diff --git a/openmeter/productcatalog/plan/adapter/phase.go b/openmeter/productcatalog/plan/adapter/phase.go index 98e7d7af3..7415ec7c6 100644 --- a/openmeter/productcatalog/plan/adapter/phase.go +++ b/openmeter/productcatalog/plan/adapter/phase.go @@ -529,6 +529,9 @@ func rateCardsDiff(inputs, rateCards []plan.RateCard) (rateCardsDiffResult, erro } if !match { + input.Namespace = rateCard.Namespace + input.ID = rateCard.ID + input.PhaseID = rateCard.PhaseID result.Update = append(result.Update, input) rateCardsVisited[rateCardKey] = struct{}{} From a6e13eb3634e8752e5677ad8f0a1a18ee77a466a Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Mon, 11 Nov 2024 10:34:53 +0100 Subject: [PATCH 07/13] fix: not setting EffectivePeriod for Plan --- .../plan/adapter/adapter_test.go | 2 +- openmeter/productcatalog/plan/adapter/plan.go | 29 +++++++++++-------- openmeter/productcatalog/plan/service.go | 10 +++---- openmeter/productcatalog/plan/service/plan.go | 26 ++++++++++------- 4 files changed, 38 insertions(+), 29 deletions(-) diff --git a/openmeter/productcatalog/plan/adapter/adapter_test.go b/openmeter/productcatalog/plan/adapter/adapter_test.go index a83318a7b..7610d44cf 100644 --- a/openmeter/productcatalog/plan/adapter/adapter_test.go +++ b/openmeter/productcatalog/plan/adapter/adapter_test.go @@ -264,7 +264,7 @@ func TestPostgresAdapter(t *testing.T) { Namespace: namespace, ID: planV1.ID, }, - EffectivePeriod: &plan.EffectivePeriod{ + EffectivePeriod: plan.EffectivePeriod{ EffectiveFrom: lo.ToPtr(now.UTC()), EffectiveTo: lo.ToPtr(now.Add(30 * 24 * time.Hour).UTC()), }, diff --git a/openmeter/productcatalog/plan/adapter/plan.go b/openmeter/productcatalog/plan/adapter/plan.go index 225572090..b5df2d376 100644 --- a/openmeter/productcatalog/plan/adapter/plan.go +++ b/openmeter/productcatalog/plan/adapter/plan.go @@ -381,27 +381,32 @@ func (a *adapter) UpdatePlan(ctx context.Context, params plan.UpdatePlanInput) ( } if !params.Equal(*p) { - query := a.db.Plan.UpdateOneID(p.ID).Where(plandb.Namespace(params.Namespace)) - - if params.Name != nil { - query = query.SetName(*params.Name) - p.Name = *params.Name - } - - if params.Description != nil { - query = query.SetDescription(*params.Description) - p.Description = params.Description - } + query := a.db.Plan.UpdateOneID(p.ID). + Where(plandb.Namespace(params.Namespace)). + SetNillableName(params.Name). + SetNillableDescription(params.Description). + SetNillableEffectiveFrom(params.EffectiveFrom). + SetNillableEffectiveTo(params.EffectiveTo) if params.Metadata != nil { query = query.SetMetadata(*params.Metadata) - p.Metadata = *params.Metadata } err = query.Exec(ctx) if err != nil { return nil, fmt.Errorf("failed to update Plan: %w", err) } + + // Plan needs to be refetched after updated in order to populate all subresources + p, err = a.GetPlan(ctx, plan.GetPlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: params.Namespace, + ID: params.ID, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to get updated Plan: %w", err) + } } if params.Phases != nil { diff --git a/openmeter/productcatalog/plan/service.go b/openmeter/productcatalog/plan/service.go index d6dd511f3..f07940040 100644 --- a/openmeter/productcatalog/plan/service.go +++ b/openmeter/productcatalog/plan/service.go @@ -143,7 +143,7 @@ type UpdatePlanInput struct { models.NamespacedID // EffectivePeriod - *EffectivePeriod + EffectivePeriod // Name Name *string `json:"name"` @@ -171,10 +171,8 @@ func (i UpdatePlanInput) Equal(p Plan) bool { return false } - if i.EffectivePeriod != nil { - if i.EffectivePeriod.Status() != p.EffectivePeriod.Status() { - return false - } + if i.EffectivePeriod.Status() != p.EffectivePeriod.Status() { + return false } if i.Name != nil && *i.Name != p.Name { @@ -203,7 +201,7 @@ func (i UpdatePlanInput) Validate() error { return errors.New("invalid Name: must not be empty") } - if i.EffectivePeriod != nil { + if i.EffectiveFrom != nil || i.EffectiveTo != nil { if err := i.EffectivePeriod.Validate(); err != nil { errs = append(errs, fmt.Errorf("invalid EffectivePeriod: %w", err)) } diff --git a/openmeter/productcatalog/plan/service/plan.go b/openmeter/productcatalog/plan/service/plan.go index 22df74f10..eba54d0a2 100644 --- a/openmeter/productcatalog/plan/service/plan.go +++ b/openmeter/productcatalog/plan/service/plan.go @@ -203,7 +203,7 @@ func (s service) UpdatePlan(ctx context.Context, params plan.UpdatePlanInput) (* // NOTE(chrisgacsal): we only allow updating the state of the Plan via Publish/Archive, // therefore the EffectivePeriod attribute must be zeroed before updating the Plan. - params.EffectivePeriod = nil + params.EffectivePeriod = plan.EffectivePeriod{} p, err = s.adapter.UpdatePlan(ctx, params) if err != nil { @@ -244,7 +244,7 @@ func (s service) PublishPlan(ctx context.Context, params plan.PublishPlanInput) allowedPlanStatuses := []plan.PlanStatus{plan.DraftStatus, plan.ScheduledStatus} planStatus := p.Status() - if lo.Contains(allowedPlanStatuses, p.Status()) { + if !lo.Contains(allowedPlanStatuses, p.Status()) { return nil, fmt.Errorf("only Plans in %+v can be published/rescheduled, but it has %s state", allowedPlanStatuses, planStatus) } @@ -256,16 +256,22 @@ func (s service) PublishPlan(ctx context.Context, params plan.PublishPlanInput) // In other words, modify the surrounding Plans only if the user is allowed it otherwise return a validation error // in case the lifecycle of the Plan is not continuous (there are time gaps between versions). - p, err = s.adapter.UpdatePlan(ctx, plan.UpdatePlanInput{ + input := plan.UpdatePlanInput{ NamespacedID: models.NamespacedID{ Namespace: p.Namespace, ID: p.ID, }, - EffectivePeriod: &plan.EffectivePeriod{ - EffectiveFrom: lo.ToPtr(params.EffectiveFrom.UTC()), - EffectiveTo: lo.ToPtr(params.EffectiveTo.UTC()), - }, - }) + } + + if params.EffectiveFrom != nil { + input.EffectiveFrom = lo.ToPtr(params.EffectiveFrom.UTC()) + } + + if params.EffectiveTo != nil { + input.EffectiveTo = lo.ToPtr(params.EffectiveTo.UTC()) + } + + p, err = s.adapter.UpdatePlan(ctx, input) if err != nil { return nil, fmt.Errorf("failed to publish Plan: %w", err) } @@ -304,7 +310,7 @@ func (s service) ArchivePlan(ctx context.Context, params plan.ArchivePlanInput) activeStatuses := []plan.PlanStatus{plan.ActiveStatus} status := p.Status() - if lo.Contains(activeStatuses, status) { + if !lo.Contains(activeStatuses, status) { return nil, fmt.Errorf("only Plans in %+v can be archived, but it is in %s state", activeStatuses, status) } @@ -321,7 +327,7 @@ func (s service) ArchivePlan(ctx context.Context, params plan.ArchivePlanInput) Namespace: p.Namespace, ID: p.ID, }, - EffectivePeriod: &plan.EffectivePeriod{ + EffectivePeriod: plan.EffectivePeriod{ EffectiveTo: lo.ToPtr(params.EffectiveTo.UTC()), }, }) From 8f407245a30f54b88849f3983fb45c7b02495456 Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Mon, 11 Nov 2024 13:46:41 +0100 Subject: [PATCH 08/13] fix: creating next Plan version --- openmeter/productcatalog/plan/adapter/plan.go | 6 +- openmeter/productcatalog/plan/service.go | 7 ++- openmeter/productcatalog/plan/service/plan.go | 58 ++++++++++++++++--- .../productcatalog/plan/service/service.go | 1 + 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/openmeter/productcatalog/plan/adapter/plan.go b/openmeter/productcatalog/plan/adapter/plan.go index b5df2d376..d870f0f78 100644 --- a/openmeter/productcatalog/plan/adapter/plan.go +++ b/openmeter/productcatalog/plan/adapter/plan.go @@ -121,6 +121,10 @@ func (a *adapter) CreatePlan(ctx context.Context, params plan.CreatePlanInput) ( // Create plan + if params.Version == 0 { + params.Version = 1 + } + planRow, err := a.db.Plan.Create(). SetKey(params.Key). SetNamespace(params.Namespace). @@ -128,7 +132,7 @@ func (a *adapter) CreatePlan(ctx context.Context, params plan.CreatePlanInput) ( SetNillableDescription(params.Description). SetCurrency(params.Currency.String()). SetMetadata(params.Metadata). - SetVersion(1). + SetVersion(params.Version). Save(ctx) if err != nil { return nil, fmt.Errorf("failed to create Plan: %w", err) diff --git a/openmeter/productcatalog/plan/service.go b/openmeter/productcatalog/plan/service.go index f07940040..f5e4cc862 100644 --- a/openmeter/productcatalog/plan/service.go +++ b/openmeter/productcatalog/plan/service.go @@ -86,6 +86,9 @@ type CreatePlanInput struct { // Key is the unique key for Plan. Key string `json:"key"` + // Version + Version int `json:"version"` + // Name Name string `json:"name"` @@ -332,8 +335,8 @@ func (i NextPlanInput) Validate() error { errs = append(errs, errors.New("invalid Namespace: must not be empty")) } - if i.ID == "" && (i.Key == "" || i.Version == 0) { - errs = append(errs, errors.New("invalid: either ID or Key/Version pair must be provided")) + if i.ID == "" && i.Key == "" { + errs = append(errs, errors.New("invalid: either ID or Key pair must be provided")) } if len(errs) > 0 { diff --git a/openmeter/productcatalog/plan/service/plan.go b/openmeter/productcatalog/plan/service/plan.go index eba54d0a2..a28cc7aa2 100644 --- a/openmeter/productcatalog/plan/service/plan.go +++ b/openmeter/productcatalog/plan/service/plan.go @@ -359,16 +359,59 @@ func (s service) NextPlan(ctx context.Context, params plan.NextPlanInput) (*plan logger.Debug("creating new version of a Plan") - sourcePlan, err := s.adapter.GetPlan(ctx, plan.GetPlanInput{ - NamespacedID: models.NamespacedID{ - Namespace: params.Namespace, - ID: params.ID, + // Fetch all version of a plan to find the one to be used as source and also to calculate the next version number. + allVersions, err := s.adapter.ListPlans(ctx, plan.ListPlansInput{ + Page: pagination.Page{ + PageSize: 1000, + PageNumber: 1, }, - Key: params.Key, - Version: params.Version, + OrderBy: plan.OrderByVersion, + Order: plan.OrderAsc, + Namespaces: []string{params.Namespace}, + Keys: []string{params.Key}, + IncludeDeleted: true, }) if err != nil { - return nil, fmt.Errorf("failed to get source Plan: %w", err) + return nil, fmt.Errorf("failed to list all versions of the Plan: %w", err) + } + + if len(allVersions.Items) == 0 { + return nil, fmt.Errorf("no versions available for this plan") + } + + // Generate source plan filter from input parameters + planFilter := func() func(plan plan.Plan) bool { + switch { + case params.ID != "": + return func(p plan.Plan) bool { + return p.Namespace == params.Namespace && p.ID == params.ID + } + case params.Key != "" && params.Version == 0: + return func(p plan.Plan) bool { + return p.Namespace == params.Namespace && p.Key == params.Key && p.Status() == plan.ActiveStatus + } + default: + return func(p plan.Plan) bool { + return p.Namespace == params.Namespace && p.Key == params.Key && p.Version == params.Version + } + } + }() + + var sourcePlan *plan.Plan + + nextVersion := 1 + for _, p := range allVersions.Items { + if sourcePlan == nil && planFilter(p) { + sourcePlan = &p + } + + if p.Version >= nextVersion { + nextVersion = p.Version + 1 + } + } + + if sourcePlan == nil { + return nil, fmt.Errorf("no versions available for plan to use as source for next draft version") } nextPlan, err := s.adapter.CreatePlan(ctx, plan.CreatePlanInput{ @@ -376,6 +419,7 @@ func (s service) NextPlan(ctx context.Context, params plan.NextPlanInput) (*plan Namespace: sourcePlan.Namespace, }, Key: sourcePlan.Key, + Version: nextVersion, Name: sourcePlan.Name, Description: sourcePlan.Description, Metadata: sourcePlan.Metadata, diff --git a/openmeter/productcatalog/plan/service/service.go b/openmeter/productcatalog/plan/service/service.go index 4a33dc7b7..4420feda6 100644 --- a/openmeter/productcatalog/plan/service/service.go +++ b/openmeter/productcatalog/plan/service/service.go @@ -36,6 +36,7 @@ func New(config Config) (plan.Service, error) { } // TODO(chrisgacsal): use transactional client for adapter operations +// FIXME: handling Discounts var _ plan.Service = (*service)(nil) From 9bd8257cc68c1b4cdebd6a415f18fad42c300ac5 Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Mon, 11 Nov 2024 14:38:36 +0100 Subject: [PATCH 09/13] fix: returning deleted Plan if only Key provided --- openmeter/productcatalog/plan/adapter/plan.go | 1 + 1 file changed, 1 insertion(+) diff --git a/openmeter/productcatalog/plan/adapter/plan.go b/openmeter/productcatalog/plan/adapter/plan.go index d870f0f78..7c636e644 100644 --- a/openmeter/productcatalog/plan/adapter/plan.go +++ b/openmeter/productcatalog/plan/adapter/plan.go @@ -321,6 +321,7 @@ func (a *adapter) GetPlan(ctx context.Context, params plan.GetPlanInput) (*plan. plandb.EffectiveToGT(now), plandb.EffectiveToIsNil(), ), + plandb.DeletedAtIsNil(), )) } } else { // get Plan by Key and Version From f631b861967fefe8d5ebc2bad981cb82d5877daa Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Mon, 11 Nov 2024 14:40:28 +0100 Subject: [PATCH 10/13] fix: deleting Plan with active status --- openmeter/productcatalog/plan/service/plan.go | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/openmeter/productcatalog/plan/service/plan.go b/openmeter/productcatalog/plan/service/plan.go index a28cc7aa2..45596bf3e 100644 --- a/openmeter/productcatalog/plan/service/plan.go +++ b/openmeter/productcatalog/plan/service/plan.go @@ -115,11 +115,25 @@ func (s service) DeletePlan(ctx context.Context, params plan.DeletePlanInput) er "plan.id", params.ID, ) - // TODO(chrisgacsal): add check which makes sure that Plans with active Subscriptions are not deleted. - logger.Debug("deleting Plan") - err := s.adapter.DeletePlan(ctx, params) + p, err := s.adapter.GetPlan(ctx, plan.GetPlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: params.Namespace, + ID: params.ID, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to get Plan: %w", err) + } + + allowedPlanStatuses := []plan.PlanStatus{plan.ArchivedStatus, plan.ScheduledStatus} + planStatus := p.Status() + if !lo.Contains(allowedPlanStatuses, p.Status()) { + return nil, fmt.Errorf("only Plans in %+v can be deleted, but it has %s state", allowedPlanStatuses, planStatus) + } + + err = s.adapter.DeletePlan(ctx, params) if err != nil { return nil, fmt.Errorf("failed to delete Plan: %w", err) } From 2d536b3a8438991c62dbddbc6c968a8229c5f6d5 Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Mon, 11 Nov 2024 16:13:43 +0100 Subject: [PATCH 11/13] fix: publishing Plan --- openmeter/productcatalog/plan/errors.go | 9 ++++ openmeter/productcatalog/plan/service.go | 36 ++++++++++++-- openmeter/productcatalog/plan/service/plan.go | 47 ++++++++++++++++--- 3 files changed, 80 insertions(+), 12 deletions(-) diff --git a/openmeter/productcatalog/plan/errors.go b/openmeter/productcatalog/plan/errors.go index d436a979c..d075f1731 100644 --- a/openmeter/productcatalog/plan/errors.go +++ b/openmeter/productcatalog/plan/errors.go @@ -1,6 +1,7 @@ package plan import ( + "errors" "fmt" "github.com/openmeterio/openmeter/pkg/models" @@ -16,6 +17,14 @@ func (e NotFoundError) Error() string { return fmt.Sprintf("resource not found in %s namespace", e.Namespace) } +func IsNotFound(err error) bool { + if err == nil { + return false + } + var e NotFoundError + return errors.As(err, &e) +} + type genericError struct { Err error } diff --git a/openmeter/productcatalog/plan/service.go b/openmeter/productcatalog/plan/service.go index f5e4cc862..82a67edad 100644 --- a/openmeter/productcatalog/plan/service.go +++ b/openmeter/productcatalog/plan/service.go @@ -15,6 +15,8 @@ import ( "github.com/openmeterio/openmeter/pkg/sortx" ) +const timeJitter = 30 * time.Second + const ( OrderAsc = sortx.OrderAsc OrderDesc = sortx.OrderDesc @@ -281,16 +283,40 @@ type PublishPlanInput struct { } func (i PublishPlanInput) Validate() error { + var errs []error + if err := i.NamespacedID.Validate(); err != nil { - return err + errs = append(errs, err) + } + + now := time.Now() + + from := lo.FromPtrOr(i.EffectiveFrom, time.Time{}) + + if from.IsZero() { + errs = append(errs, errors.New("invalid EffectiveFrom: must not be empty")) + } + + if !from.IsZero() && from.Before(now.Add(-timeJitter)) { + errs = append(errs, errors.New("invalid EffectiveFrom: period start must not be in the past")) + } + + to := lo.FromPtrOr(i.EffectiveTo, time.Time{}) + + if !to.IsZero() && from.IsZero() { + errs = append(errs, errors.New("invalid EffectiveFrom: must not be empty if EffectiveTo is also set")) } - if lo.FromPtrOr(i.EffectiveFrom, time.Time{}).IsZero() { - return errors.New("invalid EffectiveFrom: must not be empty") + if !to.IsZero() && to.Before(now.Add(timeJitter)) { + errs = append(errs, errors.New("invalid EffectiveTo: period end must not be in the past")) } - if !lo.FromPtrOr(i.EffectiveTo, time.Time{}).IsZero() && lo.FromPtrOr(i.EffectiveFrom, time.Time{}).IsZero() { - return errors.New("invalid EffectiveFrom: must not be empty if EffectiveTo is also set") + if !from.IsZero() && !to.IsZero() && from.After(to) { + errs = append(errs, errors.New("invalid EffectivePeriod: period start must not be later than period end")) + } + + if len(errs) > 0 { + return errors.Join(errs...) } return nil diff --git a/openmeter/productcatalog/plan/service/plan.go b/openmeter/productcatalog/plan/service/plan.go index 45596bf3e..86fbf1e64 100644 --- a/openmeter/productcatalog/plan/service/plan.go +++ b/openmeter/productcatalog/plan/service/plan.go @@ -232,6 +232,15 @@ func (s service) UpdatePlan(ctx context.Context, params plan.UpdatePlanInput) (* return transaction.Run(ctx, s.adapter, fn) } +// PublishPlan +// TODO(chrisgacsal): add support for scheduling Plan versions in the future. +// In order to ensure that there are not time gaps where no active version of a Plan is available +// the EffectivePeriod must be validated/updated with the surrounding Plans(N-1, N+1) if they exist. +// If updating the EffectivePeriod for surrounding Plans violates constraints, return an validation error, +// otherwise adjust their schedule accordingly. +// IMPORTANT: this might need to be an optional action which must be only performed with the users consent as it has side-effects. +// In other words, modify the surrounding Plans only if the user is allowed it otherwise return a validation error +// in case the lifecycle of the Plan is not continuous (there are time gaps between versions). func (s service) PublishPlan(ctx context.Context, params plan.PublishPlanInput) (*plan.Plan, error) { fn := func(ctx context.Context) (*plan.Plan, error) { if err := params.Validate(); err != nil { @@ -262,13 +271,37 @@ func (s service) PublishPlan(ctx context.Context, params plan.PublishPlanInput) return nil, fmt.Errorf("only Plans in %+v can be published/rescheduled, but it has %s state", allowedPlanStatuses, planStatus) } - // TODO(chrisgacsal): in order to ensure that there are not time gaps where no active version of a Plan is available - // the EffectivePeriod must be validated/updated with the surrounding Plans(N-1, N+1) if they exist. - // If updating the EffectivePeriod for surrounding Plans violates constraints, return an validation error, - // otherwise adjust their schedule accordingly. - // IMPORTANT: this should be an optional action which must be only performed with the users consent as it has side-effects. - // In other words, modify the surrounding Plans only if the user is allowed it otherwise return a validation error - // in case the lifecycle of the Plan is not continuous (there are time gaps between versions). + // Find and archive Plan version with plan.ActiveStatus if there is one. Only perform lookup if + // the Plan to be published has higher version then 1 meaning that it has previous versions, + // otherwise skip this step. + if p.Version > 1 { + activePlan, err := s.adapter.GetPlan(ctx, plan.GetPlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: params.Namespace, + }, + Key: p.Key, + }) + if err != nil { + if !plan.IsNotFound(err) { + return nil, fmt.Errorf("failed to get Plan with active status: %w", err) + } + } + + if activePlan != nil && params.EffectiveFrom != nil { + _, err = s.ArchivePlan(ctx, plan.ArchivePlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: activePlan.Namespace, + ID: activePlan.ID, + }, + EffectiveTo: lo.FromPtr(params.EffectiveFrom), + }) + if err != nil { + return nil, fmt.Errorf("failed to archive plan with active status: %w", err) + } + } + } + + // Publish new Plan version input := plan.UpdatePlanInput{ NamespacedID: models.NamespacedID{ From 51727c434f092167e345c68975addd5c7198b16f Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Mon, 11 Nov 2024 17:02:10 +0100 Subject: [PATCH 12/13] fix: archiving Plan --- openmeter/productcatalog/plan/plan.go | 12 ++++++++---- openmeter/productcatalog/plan/plan_test.go | 12 ++++++++++-- openmeter/productcatalog/plan/service.go | 16 ++++++++++++++-- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/openmeter/productcatalog/plan/plan.go b/openmeter/productcatalog/plan/plan.go index a0207dec4..b1a4058b1 100644 --- a/openmeter/productcatalog/plan/plan.go +++ b/openmeter/productcatalog/plan/plan.go @@ -6,6 +6,7 @@ import ( "time" "github.com/invopop/gobl/currency" + "github.com/samber/lo" "github.com/openmeterio/openmeter/pkg/datex" "github.com/openmeterio/openmeter/pkg/models" @@ -120,25 +121,28 @@ func (p EffectivePeriod) Status() PlanStatus { // StatusAt returns the plan status relative to time t. func (p EffectivePeriod) StatusAt(t time.Time) PlanStatus { + from := lo.FromPtrOr(p.EffectiveFrom, time.Time{}) + to := lo.FromPtrOr(p.EffectiveTo, time.Time{}) + // Plan has DraftStatus if neither the EffectiveFrom nor EffectiveTo are set - if p.EffectiveFrom == nil && p.EffectiveTo == nil { + if from.IsZero() && to.IsZero() { return DraftStatus } // Plan has ArchivedStatus if EffectiveTo is in the past relative to time t. - if p.EffectiveFrom != nil && p.EffectiveFrom.Before(t) && p.EffectiveTo != nil && p.EffectiveTo.Before(t) && p.EffectiveFrom.Before(*p.EffectiveTo) { + if from.Before(t) && (to.Before(t) && from.Before(to)) { return ArchivedStatus } // Plan has ActiveStatus if EffectiveFrom is set in the past relative to time t and EffectiveTo is not set // or in the future relative to time t. - if p.EffectiveFrom != nil && p.EffectiveFrom.Before(t) && (p.EffectiveTo == nil || p.EffectiveTo.After(t)) { + if from.Before(t) && (to.IsZero() || to.After(t)) { return ActiveStatus } // Plan is ScheduledForActiveStatus if EffectiveFrom is set in the future relative to time t and EffectiveTo is not set // or in the future relative to time t. - if p.EffectiveFrom != nil && p.EffectiveFrom.After(t) && (p.EffectiveTo == nil || p.EffectiveTo.After(t)) { + if from.After(t) && (to.IsZero() || to.After(from)) { return ScheduledStatus } diff --git a/openmeter/productcatalog/plan/plan_test.go b/openmeter/productcatalog/plan/plan_test.go index 7cb1db454..abb801613 100644 --- a/openmeter/productcatalog/plan/plan_test.go +++ b/openmeter/productcatalog/plan/plan_test.go @@ -74,12 +74,20 @@ func TestPlanStatus(t *testing.T) { Expected: InvalidStatus, }, { - Name: "Invalid with missing start", + Name: "Invalid with no start with end in the past", Effective: EffectivePeriod{ EffectiveFrom: nil, EffectiveTo: lo.ToPtr(now.Add(-24 * time.Hour)), }, - Expected: InvalidStatus, + Expected: ArchivedStatus, + }, + { + Name: "Invalid with no start with end in the future", + Effective: EffectivePeriod{ + EffectiveFrom: nil, + EffectiveTo: lo.ToPtr(now.Add(24 * time.Hour)), + }, + Expected: ActiveStatus, }, } diff --git a/openmeter/productcatalog/plan/service.go b/openmeter/productcatalog/plan/service.go index 82a67edad..1bfe0822a 100644 --- a/openmeter/productcatalog/plan/service.go +++ b/openmeter/productcatalog/plan/service.go @@ -331,12 +331,24 @@ type ArchivePlanInput struct { } func (i ArchivePlanInput) Validate() error { + var errs []error + if err := i.NamespacedID.Validate(); err != nil { - return fmt.Errorf("invalid Namespace: %w", err) + errs = append(errs, err) } if i.EffectiveTo.IsZero() { - return errors.New("invalid EffectiveTo: must not be empty") + errs = append(errs, errors.New("invalid EffectiveTo: must not be empty")) + } + + now := time.Now() + + if i.EffectiveTo.Before(now.Add(-timeJitter)) { + errs = append(errs, errors.New("invalid EffectiveTo: period end must not be in the past")) + } + + if len(errs) > 0 { + return errors.Join(errs...) } return nil From 64960006bf57243986b15d9232026e73e566f8dc Mon Sep 17 00:00:00 2001 From: Krisztian Gacsal Date: Mon, 11 Nov 2024 17:02:36 +0100 Subject: [PATCH 13/13] test: add tests for Plan API --- .../plan/service/service_test.go | 641 ++++++++++++++++++ 1 file changed, 641 insertions(+) create mode 100644 openmeter/productcatalog/plan/service/service_test.go diff --git a/openmeter/productcatalog/plan/service/service_test.go b/openmeter/productcatalog/plan/service/service_test.go new file mode 100644 index 000000000..07661b461 --- /dev/null +++ b/openmeter/productcatalog/plan/service/service_test.go @@ -0,0 +1,641 @@ +package service + +import ( + "context" + "crypto/rand" + "slices" + "sync" + "testing" + "time" + + decimal "github.com/alpacahq/alpacadecimal" + "github.com/invopop/gobl/currency" + "github.com/oklog/ulid/v2" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + entdb "github.com/openmeterio/openmeter/openmeter/ent/db" + "github.com/openmeterio/openmeter/openmeter/meter" + productcatalogadapter "github.com/openmeterio/openmeter/openmeter/productcatalog/adapter" + "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan/adapter" + "github.com/openmeterio/openmeter/openmeter/testutils" + "github.com/openmeterio/openmeter/pkg/datex" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/tools/migrate" +) + +func TestPlanService(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Setup test environment + env := newTestEnv(t) + defer env.Close(t) + + // Run database migrations + env.DBSchemaMigrate(t) + + // Get new namespace ID + namespace := NewTestNamespace(t) + + // Setup meter repository + env.Meter.ReplaceMeters(ctx, NewTestMeters(t, namespace)) + + meters, err := env.Meter.ListMeters(ctx, namespace) + require.NoErrorf(t, err, "listing Meters must not fail") + require.NotEmptyf(t, meters, "list of Meters must not be empty") + + // Set Feature for each Meter + features := make(map[string]feature.Feature, len(meters)) + for _, m := range meters { + input := feature.CreateFeatureInputs{ + Name: m.Slug, + Key: m.Slug, + Namespace: namespace, + MeterSlug: lo.ToPtr(m.Slug), + MeterGroupByFilters: m.GroupBy, + Metadata: map[string]string{}, + } + + feat, err := env.Feature.CreateFeature(ctx, input) + require.NoErrorf(t, err, "creating Feature must not fail") + require.NotNil(t, feat, "Feature must not be empty") + + features[feat.Key] = feat + } + + t.Run("Plan", func(t *testing.T) { + t.Run("Create", func(t *testing.T) { + planInput := NewProPlan(t, namespace) + + draftPlan, err := env.Plan.CreatePlan(ctx, planInput) + require.NoErrorf(t, err, "creating Plan must not fail") + require.NotNil(t, draftPlan, "Plan must not be empty") + + plan.AssertPlanCreateInputEqual(t, planInput, *draftPlan) + assert.Equalf(t, plan.DraftStatus, draftPlan.Status(), "Plan Status mismatch: expected=%s, actual=%s", plan.DraftStatus, draftPlan.Status()) + + t.Run("Get draft plan", func(t *testing.T) { + getPlan, err := env.Plan.GetPlan(ctx, plan.GetPlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: planInput.Namespace, + }, + Key: planInput.Key, + IncludeLatest: true, + }) + require.NoErrorf(t, err, "getting draft Plan must not fail") + require.NotNil(t, getPlan, "draft Plan must not be empty") + + assert.Equalf(t, draftPlan.ID, getPlan.ID, "Plan ID mismatch: %s = %s", draftPlan.ID, getPlan.ID) + assert.Equalf(t, draftPlan.Key, getPlan.Key, "Plan Key mismatch: %s = %s", draftPlan.Key, getPlan.Key) + assert.Equalf(t, draftPlan.Version, getPlan.Version, "Plan Version mismatch: %d = %d", draftPlan.Version, getPlan.Version) + assert.Equalf(t, plan.DraftStatus, getPlan.Status(), "Plan Status mismatch: expected=%s, actual=%s", plan.DraftStatus, getPlan.Status()) + }) + + t.Run("New phase", func(t *testing.T) { + updatedPhases := slices.Clone(draftPlan.Phases) + updatedPhases = append(updatedPhases, plan.Phase{ + NamespacedID: models.NamespacedID{ + Namespace: namespace, + }, + Key: "pro-2", + Name: "Pro-2", + Description: lo.ToPtr("Pro-2 phase"), + Metadata: map[string]string{"name": "pro-2"}, + StartAfter: ThreeMonthPeriod, + PlanID: draftPlan.ID, + RateCards: []plan.RateCard{ + plan.NewRateCardFrom(plan.UsageBasedRateCard{ + RateCardMeta: plan.RateCardMeta{ + NamespacedID: models.NamespacedID{ + Namespace: namespace, + }, + Key: "pro-2-ratecard-1", + Type: plan.UsageBasedRateCardType, + Name: "Pro-2 RateCard 1", + Description: lo.ToPtr("Pro-2 RateCard 1"), + Metadata: map[string]string{"name": "pro-ratecard-1"}, + Feature: nil, + EntitlementTemplate: nil, + TaxConfig: &plan.TaxConfig{ + Stripe: &plan.StripeTaxConfig{ + Code: "txcd_10000000", + }, + }, + }, + BillingCadence: MonthPeriod, + Price: lo.ToPtr(plan.NewPriceFrom(plan.TieredPrice{ + PriceMeta: plan.PriceMeta{ + Type: plan.TieredPriceType, + }, + Mode: plan.VolumeTieredPrice, + Tiers: []plan.PriceTier{ + { + UpToAmount: lo.ToPtr(decimal.NewFromInt(1000)), + FlatPrice: &plan.PriceTierFlatPrice{ + Amount: decimal.NewFromInt(100), + }, + UnitPrice: &plan.PriceTierUnitPrice{ + Amount: decimal.NewFromInt(50), + }, + }, + }, + MinimumAmount: lo.ToPtr(decimal.NewFromInt(1000)), + MaximumAmount: nil, + })), + }), + }, + }) + + updateInput := plan.UpdatePlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: planInput.Namespace, + ID: draftPlan.ID, + }, + Phases: lo.ToPtr(updatedPhases), + } + + updatedPlan, err := env.Plan.UpdatePlan(ctx, updateInput) + require.NoErrorf(t, err, "updating draft Plan must not fail") + require.NotNil(t, updatedPlan, "updated draft Plan must not be empty") + require.NotNil(t, updatedPlan, "updated draft Plan must not be empty") + + assert.Equalf(t, plan.DraftStatus, updatedPlan.Status(), "Plan Status mismatch: expected=%s, actual=%s", plan.DraftStatus, updatedPlan.Status()) + + plan.AssertPlanPhasesEqual(t, updatedPhases, updatedPlan.Phases) + }) + + t.Run("Update phase", func(t *testing.T) { + updatedPhases := slices.Clone(draftPlan.Phases) + updatedPhases = append(updatedPhases, plan.Phase{ + NamespacedID: models.NamespacedID{ + Namespace: namespace, + }, + Key: "pro-2", + Name: "Pro-2", + Description: lo.ToPtr("Pro-2 phase"), + Metadata: map[string]string{"name": "pro-2"}, + StartAfter: SixMonthPeriod, + PlanID: draftPlan.ID, + RateCards: []plan.RateCard{ + plan.NewRateCardFrom(plan.UsageBasedRateCard{ + RateCardMeta: plan.RateCardMeta{ + NamespacedID: models.NamespacedID{ + Namespace: namespace, + }, + Key: "pro-2-ratecard-1", + Type: plan.UsageBasedRateCardType, + Name: "Pro-2 RateCard 1", + Description: lo.ToPtr("Pro-2 RateCard 1"), + Metadata: map[string]string{"name": "pro-ratecard-1"}, + Feature: nil, + EntitlementTemplate: nil, + TaxConfig: &plan.TaxConfig{ + Stripe: &plan.StripeTaxConfig{ + Code: "txcd_10000000", + }, + }, + }, + BillingCadence: MonthPeriod, + Price: lo.ToPtr(plan.NewPriceFrom(plan.TieredPrice{ + PriceMeta: plan.PriceMeta{ + Type: plan.TieredPriceType, + }, + Mode: plan.VolumeTieredPrice, + Tiers: []plan.PriceTier{ + { + UpToAmount: lo.ToPtr(decimal.NewFromInt(1000)), + FlatPrice: &plan.PriceTierFlatPrice{ + Amount: decimal.NewFromInt(50), + }, + UnitPrice: &plan.PriceTierUnitPrice{ + Amount: decimal.NewFromInt(25), + }, + }, + }, + MinimumAmount: lo.ToPtr(decimal.NewFromInt(1000)), + MaximumAmount: nil, + })), + }), + }, + }) + + updateInput := plan.UpdatePlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: planInput.Namespace, + ID: draftPlan.ID, + }, + Phases: lo.ToPtr(updatedPhases), + } + + updatedPlan, err := env.Plan.UpdatePlan(ctx, updateInput) + require.NoErrorf(t, err, "updating draft Plan must not fail") + require.NotNil(t, updatedPlan, "updated draft Plan must not be empty") + + plan.AssertPlanPhasesEqual(t, updatedPhases, updatedPlan.Phases) + }) + + t.Run("Remove phase", func(t *testing.T) { + updateInput := plan.UpdatePlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: planInput.Namespace, + ID: draftPlan.ID, + }, + Phases: lo.ToPtr(draftPlan.Phases), + } + + updatedPlan, err := env.Plan.UpdatePlan(ctx, updateInput) + require.NoErrorf(t, err, "updating draft Plan must not fail") + require.NotNil(t, updatedPlan, "updated draft Plan must not be empty") + + plan.AssertPlanEqual(t, *updatedPlan, *draftPlan) + }) + + var publishedPlan *plan.Plan + t.Run("Publish", func(t *testing.T) { + publishAt := time.Now().Truncate(time.Microsecond) + + publishInput := plan.PublishPlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: draftPlan.Namespace, + ID: draftPlan.ID, + }, + EffectivePeriod: plan.EffectivePeriod{ + EffectiveFrom: &publishAt, + EffectiveTo: nil, + }, + } + + publishedPlan, err = env.Plan.PublishPlan(ctx, publishInput) + require.NoErrorf(t, err, "publishing draft Plan must not fail") + require.NotNil(t, publishedPlan, "published Plan must not be empty") + require.NotNil(t, publishedPlan.EffectiveFrom, "EffectiveFrom for published Plan must not be empty") + + assert.Equalf(t, publishAt, *publishedPlan.EffectiveFrom, "EffectiveFrom for published Plan mismatch: expected=%s, actual=%s", publishAt, *publishedPlan.EffectiveFrom) + assert.Equalf(t, plan.ActiveStatus, publishedPlan.Status(), "Plan Status mismatch: expected=%s, actual=%s", plan.ActiveStatus, publishedPlan.Status()) + }) + + t.Run("Update after publish", func(t *testing.T) { + updateInput := plan.UpdatePlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: draftPlan.Namespace, + ID: draftPlan.ID, + }, + Name: lo.ToPtr("Invalid Update"), + } + + _, err = env.Plan.UpdatePlan(ctx, updateInput) + require.Errorf(t, err, "updating active Plan must fail") + }) + + var nextPlan *plan.Plan + t.Run("New version", func(t *testing.T) { + nextInput := plan.NextPlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: publishedPlan.Namespace, + }, + Key: publishedPlan.Key, + } + + nextPlan, err = env.Plan.NextPlan(ctx, nextInput) + require.NoErrorf(t, err, "creating a new draft Plan from active must not fail") + require.NotNil(t, nextPlan, "new draft Plan must not be empty") + + assert.Equalf(t, publishedPlan.Version+1, nextPlan.Version, "new draft Plan must have higher version number") + assert.Equalf(t, plan.DraftStatus, nextPlan.Status(), "Plan Status mismatch: expected=%s, actual=%s", plan.DraftStatus, nextPlan.Status()) + }) + + var publishedNextPlan *plan.Plan + t.Run("Publish next", func(t *testing.T) { + publishAt := time.Now().Truncate(time.Microsecond) + + publishInput := plan.PublishPlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: nextPlan.Namespace, + ID: nextPlan.ID, + }, + EffectivePeriod: plan.EffectivePeriod{ + EffectiveFrom: &publishAt, + EffectiveTo: nil, + }, + } + + publishedNextPlan, err = env.Plan.PublishPlan(ctx, publishInput) + require.NoErrorf(t, err, "publishing draft Plan must not fail") + require.NotNil(t, publishedNextPlan, "published Plan must not be empty") + require.NotNil(t, publishedNextPlan.EffectiveFrom, "EffectiveFrom for published Plan must not be empty") + + assert.Equalf(t, publishAt, *publishedNextPlan.EffectiveFrom, "EffectiveFrom for published Plan mismatch: expected=%s, actual=%s", publishAt, *publishedPlan.EffectiveFrom) + assert.Equalf(t, plan.ActiveStatus, publishedNextPlan.Status(), "Plan Status mismatch: expected=%s, actual=%s", plan.ActiveStatus, publishedNextPlan.Status()) + + prevPlan, err := env.Plan.GetPlan(ctx, plan.GetPlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: publishedPlan.Namespace, + ID: publishedPlan.ID, + }, + }) + require.NoErrorf(t, err, "getting previous Plan version must not fail") + require.NotNil(t, prevPlan, "previous Plan version must not be empty") + + assert.Equalf(t, plan.ArchivedStatus, prevPlan.Status(), "Plan Status mismatch: expected=%s, actual=%s", plan.ArchivedStatus, prevPlan.Status()) + }) + + var archivedPlan *plan.Plan + t.Run("Archive next", func(t *testing.T) { + archiveAt := time.Now().Truncate(time.Microsecond) + + archiveInput := plan.ArchivePlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: nextPlan.Namespace, + ID: nextPlan.ID, + }, + EffectiveTo: archiveAt, + } + + archivedPlan, err = env.Plan.ArchivePlan(ctx, archiveInput) + require.NoErrorf(t, err, "archiving Plan must not fail") + require.NotNil(t, archivedPlan, "archived Plan must not be empty") + require.NotNil(t, archivedPlan.EffectiveTo, "EffectiveFrom for archived Plan must not be empty") + + assert.Equalf(t, archiveAt, *archivedPlan.EffectiveTo, "EffectiveTo for published Plan mismatch: expected=%s, actual=%s", archiveAt, *archivedPlan.EffectiveTo) + assert.Equalf(t, plan.ArchivedStatus, archivedPlan.Status(), "Status mismatch for archived Plan: expected=%s, actual=%s", plan.ArchivedStatus, archivedPlan.Status()) + }) + + t.Run("Delete next", func(t *testing.T) { + deleteInput := plan.DeletePlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: archivedPlan.Namespace, + ID: archivedPlan.ID, + }, + } + + err = env.Plan.DeletePlan(ctx, deleteInput) + require.NoErrorf(t, err, "deleting Plan must not fail") + require.NotNil(t, archivedPlan, "archived Plan must not be empty") + require.NotNil(t, archivedPlan.EffectiveTo, "EffectiveFrom for archived Plan must not be empty") + + deletedPlan, err := env.Plan.GetPlan(ctx, plan.GetPlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: archivedPlan.Namespace, + ID: archivedPlan.ID, + }, + }) + require.NoErrorf(t, err, "getting deleted Plan version must not fail") + require.NotNil(t, deletedPlan, "deleted Plan version must not be empty") + + assert.NotNilf(t, deletedPlan.DeletedAt, "deletedAt must not be empty") + }) + }) + }) +} + +var ( + MonthPeriod = datex.FromDuration(30 * 24 * time.Hour) + TwoMonthPeriod = datex.FromDuration(60 * 24 * time.Hour) + ThreeMonthPeriod = datex.FromDuration(90 * 24 * time.Hour) + SixMonthPeriod = datex.FromDuration(180 * 24 * time.Hour) +) + +func NewProPlan(t *testing.T, namespace string) plan.CreatePlanInput { + t.Helper() + + return plan.CreatePlanInput{ + NamespacedModel: models.NamespacedModel{ + Namespace: namespace, + }, + Key: "pro", + Name: "Pro", + Description: lo.ToPtr("Pro plan v1"), + Metadata: map[string]string{"name": "pro"}, + Currency: currency.USD, + Phases: []plan.Phase{ + { + NamespacedID: models.NamespacedID{ + Namespace: namespace, + }, + Key: "trial", + Name: "Trial", + Description: lo.ToPtr("Trial phase"), + Metadata: map[string]string{"name": "trial"}, + StartAfter: MonthPeriod, + RateCards: []plan.RateCard{ + plan.NewRateCardFrom(plan.FlatFeeRateCard{ + RateCardMeta: plan.RateCardMeta{ + NamespacedID: models.NamespacedID{ + Namespace: namespace, + }, + Key: "trial-ratecard-1", + Type: plan.FlatFeeRateCardType, + Name: "Trial RateCard 1", + Description: lo.ToPtr("Trial RateCard 1"), + Metadata: map[string]string{"name": "trial-ratecard-1"}, + Feature: nil, + EntitlementTemplate: nil, + TaxConfig: &plan.TaxConfig{ + Stripe: &plan.StripeTaxConfig{ + Code: "txcd_10000000", + }, + }, + }, + BillingCadence: &MonthPeriod, + Price: plan.NewPriceFrom(plan.FlatPrice{ + PriceMeta: plan.PriceMeta{ + Type: plan.FlatPriceType, + }, + Amount: decimal.NewFromInt(0), + PaymentTerm: plan.InArrearsPaymentTerm, + }), + }), + }, + }, + { + NamespacedID: models.NamespacedID{ + Namespace: namespace, + }, + Key: "pro", + Name: "Pro", + Description: lo.ToPtr("Pro phase"), + Metadata: map[string]string{"name": "pro"}, + StartAfter: TwoMonthPeriod, + RateCards: []plan.RateCard{ + plan.NewRateCardFrom(plan.UsageBasedRateCard{ + RateCardMeta: plan.RateCardMeta{ + NamespacedID: models.NamespacedID{ + Namespace: namespace, + }, + Key: "pro-ratecard-1", + Type: plan.UsageBasedRateCardType, + Name: "Pro RateCard 1", + Description: lo.ToPtr("Pro RateCard 1"), + Metadata: map[string]string{"name": "pro-ratecard-1"}, + Feature: nil, + EntitlementTemplate: nil, + TaxConfig: &plan.TaxConfig{ + Stripe: &plan.StripeTaxConfig{ + Code: "txcd_10000000", + }, + }, + }, + BillingCadence: MonthPeriod, + Price: lo.ToPtr(plan.NewPriceFrom(plan.TieredPrice{ + PriceMeta: plan.PriceMeta{ + Type: plan.TieredPriceType, + }, + Mode: plan.VolumeTieredPrice, + Tiers: []plan.PriceTier{ + { + UpToAmount: lo.ToPtr(decimal.NewFromInt(1000)), + FlatPrice: &plan.PriceTierFlatPrice{ + Amount: decimal.NewFromInt(100), + }, + UnitPrice: &plan.PriceTierUnitPrice{ + Amount: decimal.NewFromInt(50), + }, + }, + }, + MinimumAmount: lo.ToPtr(decimal.NewFromInt(1000)), + MaximumAmount: nil, + })), + }), + }, + }, + }, + } +} + +func NewTestULID(t *testing.T) string { + t.Helper() + + return ulid.MustNew(ulid.Timestamp(time.Now().UTC()), rand.Reader).String() +} + +var NewTestNamespace = NewTestULID + +func NewTestMeters(t *testing.T, namespace string) []models.Meter { + t.Helper() + + return []models.Meter{ + { + Namespace: namespace, + ID: NewTestULID(t), + Slug: "api_requests_total", + Aggregation: models.MeterAggregationCount, + EventType: "request", + GroupBy: map[string]string{ + "method": "$.method", + "path": "$.path", + }, + WindowSize: "MINUTE", + }, + { + Namespace: namespace, + ID: NewTestULID(t), + Slug: "tokens_total", + Aggregation: models.MeterAggregationSum, + EventType: "prompt", + ValueProperty: "$.tokens", + GroupBy: map[string]string{ + "model": "$.model", + "type": "$.type", + }, + WindowSize: "MINUTE", + }, + { + Namespace: namespace, + ID: NewTestULID(t), + Slug: "workload_runtime_duration_seconds", + Aggregation: models.MeterAggregationSum, + EventType: "workload", + ValueProperty: "$.duration_seconds", + GroupBy: map[string]string{ + "region": "$.region", + "zone": "$.zone", + "instance_type": "$.instance_type", + }, + WindowSize: "MINUTE", + }, + } +} + +type testEnv struct { + Meter *meter.InMemoryRepository + Feature feature.FeatureConnector + Plan plan.Service + + db *testutils.TestDB + client *entdb.Client + + close sync.Once +} + +func (e *testEnv) DBSchemaMigrate(t *testing.T) { + require.NotNilf(t, e.db, "database must be initialized") + + err := migrate.Up(e.db.URL) + require.NoErrorf(t, err, "schema migration must not fail") +} + +func (e *testEnv) Close(t *testing.T) { + t.Helper() + + e.close.Do(func() { + if e.db != nil { + if err := e.db.EntDriver.Close(); err != nil { + t.Errorf("failed to close ent driver: %v", err) + } + + if err := e.db.PGDriver.Close(); err != nil { + t.Errorf("failed to postgres driver: %v", err) + } + } + + if e.client != nil { + if err := e.client.Close(); err != nil { + t.Errorf("failed to close ent client: %v", err) + } + } + }) +} + +func newTestEnv(t *testing.T) *testEnv { + t.Helper() + + logger := testutils.NewLogger(t) + + db := testutils.InitPostgresDB(t) + client := db.EntDriver.Client() + + meterRepository := meter.NewInMemoryRepository(nil) + + featureAdapter := productcatalogadapter.NewPostgresFeatureRepo(client, logger) + featureService := feature.NewFeatureConnector(featureAdapter, meterRepository) + + planAdapter, err := adapter.New(adapter.Config{ + Client: client, + Logger: logger, + }) + require.NoErrorf(t, err, "initializing Plan adapter must not fail") + require.NotNilf(t, planAdapter, "Plan adapter must not be nil") + + config := Config{ + Feature: featureService, + Adapter: planAdapter, + Logger: logger, + } + + planService, err := New(config) + require.NoErrorf(t, err, "initializing Plan service must not fail") + require.NotNilf(t, planService, "Plan service must not be nil") + + return &testEnv{ + Meter: meterRepository, + Feature: featureService, + Plan: planService, + db: db, + client: client, + close: sync.Once{}, + } +}