From d8d8562d7943c3493a23a7530d847a10bd864904 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Sun, 25 May 2025 04:42:22 +0200 Subject: [PATCH 1/3] feat(backend): consolidate subscription creation by persisting custom plans to the database --- e2e/productcatalog_test.go | 79 ++++++++- openmeter/productcatalog/plan/adapter/plan.go | 6 + openmeter/productcatalog/plan/plan.go | 7 + .../subscription/http/change.go | 124 +++++++------- .../subscription/http/create.go | 158 +++++++++++------- .../subscription/http/handler.go | 2 + .../subscription/http/mapping.go | 50 +++--- openmeter/productcatalog/subscription/plan.go | 16 +- .../subscription/service/change.go | 59 +++---- .../subscription/service/create.go | 60 +++---- .../subscription/service/plan.go | 36 ---- .../subscription/testutils/adapter.go | 8 - openmeter/server/router/router.go | 1 + openmeter/subscription/aligment_test.go | 33 ++-- pkg/framework/commonhttp/decoder.go | 3 +- test/customer/customer.go | 120 +++++++------ test/customer/testenv.go | 31 +++- 17 files changed, 443 insertions(+), 350 deletions(-) diff --git a/e2e/productcatalog_test.go b/e2e/productcatalog_test.go index c08708db95..391270e419 100644 --- a/e2e/productcatalog_test.go +++ b/e2e/productcatalog_test.go @@ -4,7 +4,6 @@ import ( "net/http" "slices" "testing" - "time" "github.com/samber/lo" "github.com/stretchr/testify/assert" @@ -12,6 +11,7 @@ import ( "golang.org/x/net/context" api "github.com/openmeterio/openmeter/api/client/go" + "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/models" ) @@ -397,7 +397,7 @@ func TestPlan(t *testing.T) { assert.Equal(t, 200, publishRes.StatusCode(), "received the following body: %s", publishRes.Body) }) - startTime := time.Now() + startTime := clock.Now() var subscriptionId string var customSubscriptionId string @@ -426,7 +426,9 @@ func TestPlan(t *testing.T) { require.NotNil(t, subscription) require.NotNil(t, subscription.Id) assert.Equal(t, api.SubscriptionStatusActive, subscription.Status) - assert.Nil(t, subscription.Plan) + // Custom subscriptions now have a plan reference since we create and publish the custom plan + assert.NotNil(t, subscription.Plan) + assert.Contains(t, subscription.Plan.Key, "custom_plan_") customSubscriptionId = subscription.Id }) @@ -776,4 +778,75 @@ func TestPlan(t *testing.T) { require.NotNil(t, res.JSON200.Entitlements[PlanFeatureKey]) require.True(t, res.JSON200.Entitlements[PlanFeatureKey].HasAccess) }) + + t.Run("Should filter out custom plans from list", func(t *testing.T) { + // Create a new customer for this test to avoid conflicts + customer4APIRes, err := client.CreateCustomerWithResponse(ctx, api.CreateCustomerJSONRequestBody{ + Name: "Test Customer 4", + Currency: lo.ToPtr(api.CurrencyCode("USD")), + Description: lo.ToPtr("Test Customer Description"), + PrimaryEmail: lo.ToPtr("customer4@mail.com"), + BillingAddress: &api.Address{ + City: lo.ToPtr("City"), + Country: lo.ToPtr("US"), + Line1: lo.ToPtr("Line 1"), + Line2: lo.ToPtr("Line 2"), + State: lo.ToPtr("State"), + PhoneNumber: lo.ToPtr("1234567890"), + PostalCode: lo.ToPtr("12345"), + }, + UsageAttribution: api.CustomerUsageAttribution{ + SubjectKeys: []string{"test_customer_subject_4"}, + }, + }) + require.Nil(t, err) + require.Equal(t, 201, customer4APIRes.StatusCode(), "received the following body: %s", customer4APIRes.Body) + customer4 := customer4APIRes.JSON201 + require.NotNil(t, customer4) + + body := api.CreateSubscriptionJSONRequestBody{} + + ct := &api.SubscriptionTiming{} + require.NoError(t, ct.FromSubscriptionTiming1(startTime)) + + body.FromCustomSubscriptionCreate(api.CustomSubscriptionCreate{ + CustomerId: lo.ToPtr(customer4.Id), + CustomPlan: customPlanInput, + Timing: ct, + }) + // Create a subscription with a custom plan + customSubAPIRes, err := client.CreateSubscriptionWithResponse(ctx, body) + require.Nil(t, err) + require.Equal(t, 201, customSubAPIRes.StatusCode(), "received the following body: %s", customSubAPIRes.Body) + + // List all plans - custom plans should be filtered out + listPlansAPIRes, err := client.ListPlansWithResponse(ctx, &api.ListPlansParams{}) + require.Nil(t, err) + require.Equal(t, 200, listPlansAPIRes.StatusCode(), "received the following body: %s", listPlansAPIRes.Body) + + plans := listPlansAPIRes.JSON200 + require.NotNil(t, plans) + require.NotNil(t, plans.Items) + + // Verify that all returned plans are not custom plans + for _, plan := range plans.Items { + if plan.Metadata != nil { + metadata := lo.FromPtrOr(plan.Metadata, map[string]string{}) + customPlanValue, exists := metadata["openmeter.custom_plan"] + assert.False(t, exists && customPlanValue == "true", + "Plan %s should not be a custom plan in the list response", plan.Id) + } + } + + // Verify that non-custom plans are still in the list + planFound := false + for _, plan := range plans.Items { + t.Logf("Found plan: key=%s, version=%d, status=%s", plan.Key, plan.Version, plan.Status) + if plan.Key == PlanKey { + planFound = true + break + } + } + assert.True(t, planFound, "Non-custom plans should still be in the list") + }) } diff --git a/openmeter/productcatalog/plan/adapter/plan.go b/openmeter/productcatalog/plan/adapter/plan.go index 1eca928b89..ed9694b683 100644 --- a/openmeter/productcatalog/plan/adapter/plan.go +++ b/openmeter/productcatalog/plan/adapter/plan.go @@ -6,6 +6,7 @@ import ( "slices" "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqljson" entdb "github.com/openmeterio/openmeter/openmeter/ent/db" plandb "github.com/openmeterio/openmeter/openmeter/ent/db/plan" @@ -59,6 +60,11 @@ func (a *adapter) ListPlans(ctx context.Context, params plan.ListPlansInput) (pa query = query.Where(plandb.DeletedAtIsNil()) } + // Filter out custom plans from the list (plans with custom plan metadata) + query = query.Where(func(s *sql.Selector) { + s.Where(sql.Not(sqljson.HasKey(plandb.FieldMetadata, sqljson.Path(plan.MetadataKeyCustomPlan)))) + }) + if len(params.Status) > 0 { var predicates []predicate.Plan diff --git a/openmeter/productcatalog/plan/plan.go b/openmeter/productcatalog/plan/plan.go index 9aa3f6bdb6..7006fe090e 100644 --- a/openmeter/productcatalog/plan/plan.go +++ b/openmeter/productcatalog/plan/plan.go @@ -55,3 +55,10 @@ func (p Plan) AsProductCatalogPlan() productcatalog.Plan { Phases: lo.Map(p.Phases, func(phase Phase, _ int) productcatalog.Phase { return phase.AsProductCatalogPhase() }), } } + +const ( + MetadataKeyCustomPlan = "openmeter.custom_plan" + // TODO(tothandras): add base plan key and version that was customized + // MetadataKeyCustomPlanBasePlanKey = "openmeter.custom_plan.base_plan_key" + // MetadataKeyCustomPlanBasePlanVersion = "openmeter.custom_plan.base_plan_version" +) diff --git a/openmeter/productcatalog/subscription/http/change.go b/openmeter/productcatalog/subscription/http/change.go index bb0e858192..9dde45eeb7 100644 --- a/openmeter/productcatalog/subscription/http/change.go +++ b/openmeter/productcatalog/subscription/http/change.go @@ -2,15 +2,17 @@ package httpdriver import ( "context" - "encoding/json" "fmt" "net/http" "github.com/samber/lo" "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" subscriptionworkflow "github.com/openmeterio/openmeter/openmeter/subscription/workflow" + "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/convert" "github.com/openmeterio/openmeter/pkg/framework/commonhttp" "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" @@ -37,90 +39,94 @@ func (h *handler) ChangeSubscription() ChangeSubscriptionHandler { ns, err := h.resolveNamespace(ctx) if err != nil { - return ChangeSubscriptionRequest{}, err - } - - // Any transformation function generated by the API will succeed if the body is serializable, so we have to check for the presence of - // fields to determine what body type we're dealing with - type testForCustomPlan struct { - CustomPlan any `json:"customPlan"` + return ChangeSubscriptionRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) } - var t testForCustomPlan + workflowInput := subscriptionworkflow.ChangeSubscriptionWorkflowInput{} - bodyBytes, err := json.Marshal(body) - if err != nil { - return ChangeSubscriptionRequest{}, fmt.Errorf("failed to marshal request body: %w", err) - } + var planInput plansubscription.PlanInput + var startingPhase *string - if err := json.Unmarshal(bodyBytes, &t); err != nil { - return ChangeSubscriptionRequest{}, fmt.Errorf("failed to unmarshal request body: %w", err) - } + // Try to parse as custom subscription change + if b, err := body.AsCustomSubscriptionChange(); err == nil { + // Convert API input to plan creation input using the mapping function + createPlanInput, err := AsCustomPlanCreateInput(b.CustomPlan, ns) + if err != nil { + return ChangeSubscriptionRequest{}, fmt.Errorf("failed to convert custom plan: %w", err) + } - if t.CustomPlan != nil { - // Changing to a custom Plan - parsedBody, err := body.AsCustomSubscriptionChange() + // Create the custom plan and set the reference to it in the plan input + customPlan, err := h.PlanService.CreatePlan(ctx, createPlanInput) if err != nil { - return ChangeSubscriptionRequest{}, fmt.Errorf("failed to parse custom plan: %w", err) + return ChangeSubscriptionRequest{}, fmt.Errorf("failed to create custom plan: %w", err) } - req, err := CustomPlanToCreatePlanRequest(parsedBody.CustomPlan, ns) + // Publish the custom plan to make it active + effectiveFrom := createPlanInput.EffectiveFrom + if effectiveFrom == nil { + effectiveFrom = lo.ToPtr(clock.Now()) + } + customPlan, err = h.PlanService.PublishPlan(ctx, plan.PublishPlanInput{ + NamespacedID: customPlan.NamespacedID, + EffectivePeriod: productcatalog.EffectivePeriod{ + EffectiveFrom: effectiveFrom, + EffectiveTo: createPlanInput.EffectiveTo, + }, + }) if err != nil { - return ChangeSubscriptionRequest{}, fmt.Errorf("failed to create plan request: %w", err) + return ChangeSubscriptionRequest{}, fmt.Errorf("failed to publish custom plan: %w", err) } - planInp := plansubscription.PlanInput{} - planInp.FromInput(&req) + planInput.FromRef(&plansubscription.PlanRefInput{ + Key: customPlan.Key, + Version: &customPlan.Version, + }) - timing, err := MapAPITimingToTiming(parsedBody.Timing) + subscriptionTiming, err := MapAPITimingToTiming(b.Timing) if err != nil { return ChangeSubscriptionRequest{}, fmt.Errorf("failed to map timing: %w", err) } - return ChangeSubscriptionRequest{ - ID: models.NamespacedID{Namespace: ns, ID: params.ID}, - PlanInput: planInp, - WorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{ - Timing: timing, - Name: req.Name, - Description: req.Description, - MetadataModel: models.MetadataModel{ - Metadata: req.Metadata, - }, + workflowInput = subscriptionworkflow.ChangeSubscriptionWorkflowInput{ + Timing: subscriptionTiming, + Name: b.CustomPlan.Name, + Description: b.CustomPlan.Description, + MetadataModel: models.MetadataModel{ + Metadata: convert.DerefHeaderPtr[string](b.CustomPlan.Metadata), }, - }, nil - } else { - // Changing to a Plan - parsedBody, err := body.AsPlanSubscriptionChange() - if err != nil { - return ChangeSubscriptionRequest{}, fmt.Errorf("failed to parse plan: %w", err) } - - planInp := plansubscription.PlanInput{} - planInp.FromRef(&plansubscription.PlanRefInput{ - Key: parsedBody.Plan.Key, - Version: parsedBody.Plan.Version, + // Try to parse as plan subscription change + } else if b, err := body.AsPlanSubscriptionChange(); err == nil { + planInput.FromRef(&plansubscription.PlanRefInput{ + Key: b.Plan.Key, + Version: b.Plan.Version, }) - timing, err := MapAPITimingToTiming(parsedBody.Timing) + subscriptionTiming, err := MapAPITimingToTiming(b.Timing) if err != nil { return ChangeSubscriptionRequest{}, fmt.Errorf("failed to map timing: %w", err) } - return ChangeSubscriptionRequest{ - ID: models.NamespacedID{Namespace: ns, ID: params.ID}, - PlanInput: planInp, - WorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{ - Timing: timing, - MetadataModel: models.MetadataModel{ - Metadata: convert.DerefHeaderPtr[string](parsedBody.Metadata), - }, - Name: lo.FromPtr(parsedBody.Name), - Description: parsedBody.Description, + startingPhase = b.StartingPhase + + workflowInput = subscriptionworkflow.ChangeSubscriptionWorkflowInput{ + Timing: subscriptionTiming, + Name: lo.FromPtr(b.Name), + Description: b.Description, + MetadataModel: models.MetadataModel{ + Metadata: convert.DerefHeaderPtr[string](b.Metadata), }, - StartingPhase: parsedBody.StartingPhase, - }, nil + } + } else { + return ChangeSubscriptionRequest{}, models.NewGenericValidationError(fmt.Errorf("invalid request body")) } + + return ChangeSubscriptionRequest{ + ID: models.NamespacedID{Namespace: ns, ID: params.ID}, + PlanInput: planInput, + WorkflowInput: workflowInput, + StartingPhase: startingPhase, + }, nil }, func(ctx context.Context, request ChangeSubscriptionRequest) (ChangeSubscriptionResponse, error) { res, err := h.PlanSubscriptionService.Change(ctx, request) diff --git a/openmeter/productcatalog/subscription/http/create.go b/openmeter/productcatalog/subscription/http/create.go index 8780953194..89e988f65a 100644 --- a/openmeter/productcatalog/subscription/http/create.go +++ b/openmeter/productcatalog/subscription/http/create.go @@ -10,10 +10,13 @@ import ( "github.com/openmeterio/openmeter/api" "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" "github.com/openmeterio/openmeter/openmeter/subscription" subscriptionworkflow "github.com/openmeterio/openmeter/openmeter/subscription/workflow" - "github.com/openmeterio/openmeter/pkg/convert" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/defaultx" "github.com/openmeterio/openmeter/pkg/framework/commonhttp" "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" "github.com/openmeterio/openmeter/pkg/models" @@ -40,6 +43,15 @@ func (h *handler) CreateSubscription() CreateSubscriptionHandler { return CreateSubscriptionRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) } + workflowInput := subscriptionworkflow.CreateSubscriptionWorkflowInput{ + Namespace: ns, + } + + planInput := plansubscription.PlanInput{} + var customerID *string + var customerKey *string + var startingPhase *string + // Any transformation function generated by the API will succeed if the body is serializable, so we have to check for the presence of // fields to determine what body type we're dealing with type testForCustomPlan struct { @@ -58,99 +70,125 @@ func (h *handler) CreateSubscription() CreateSubscriptionHandler { } if t.CustomPlan != nil { - // Custom subscription creation - parsedBody, err := body.AsCustomSubscriptionCreate() + // Process as custom subscription + b, err := body.AsCustomSubscriptionCreate() if err != nil { - return CreateSubscriptionRequest{}, fmt.Errorf("failed to decode request body: %w", err) + return CreateSubscriptionRequest{}, models.NewGenericValidationError(fmt.Errorf("invalid request body: %w", err)) } - req, err := CustomPlanToCreatePlanRequest(parsedBody.CustomPlan, ns) + // Convert API input to plan creation input using the mapping function + createPlanInput, err := AsCustomPlanCreateInput(b.CustomPlan, ns) if err != nil { - return CreateSubscriptionRequest{}, fmt.Errorf("failed to create plan request: %w", err) + return CreateSubscriptionRequest{}, models.NewGenericValidationError(err) } - // Let's force alignment in http handler - req.Alignment.BillablesMustAlign = true + // Create the custom plan and set the reference to it in the plan input + customPlan, err := h.PlanService.CreatePlan(ctx, createPlanInput) + if err != nil { + return CreateSubscriptionRequest{}, fmt.Errorf("failed to create custom plan: %w", err) + } - plan := plansubscription.PlanInput{} - plan.FromInput(&req) + // Publish the custom plan to make it active + effectiveFrom := createPlanInput.EffectiveFrom + if effectiveFrom == nil { + effectiveFrom = lo.ToPtr(clock.Now()) + } + customPlan, err = h.PlanService.PublishPlan(ctx, plan.PublishPlanInput{ + NamespacedID: customPlan.NamespacedID, + EffectivePeriod: productcatalog.EffectivePeriod{ + EffectiveFrom: effectiveFrom, + EffectiveTo: createPlanInput.EffectiveTo, + }, + }) + if err != nil { + return CreateSubscriptionRequest{}, fmt.Errorf("failed to publish custom plan: %w", err) + } + + planInput.FromRef(&plansubscription.PlanRefInput{ + Key: customPlan.Key, + Version: &customPlan.Version, + }) timing := subscription.Timing{ Enum: lo.ToPtr(subscription.TimingImmediate), } - if parsedBody.Timing != nil { - timing, err = MapAPITimingToTiming(*parsedBody.Timing) + if b.Timing != nil { + timing, err = MapAPITimingToTiming(*b.Timing) if err != nil { - return CreateSubscriptionRequest{}, fmt.Errorf("failed to map timing: %w", err) + return CreateSubscriptionRequest{}, err } } - // Get the customer - customer, err := h.getCustomer(ctx, ns, parsedBody.CustomerId, parsedBody.CustomerKey) + customerID = b.CustomerId + customerKey = b.CustomerKey + + workflowInput.ChangeSubscriptionWorkflowInput = subscriptionworkflow.ChangeSubscriptionWorkflowInput{ + Timing: timing, + Name: defaultx.IfZero(b.CustomPlan.Name, customPlan.Name), + Description: b.CustomPlan.Description, + MetadataModel: models.MetadataModel{ + Metadata: lo.FromPtrOr(b.CustomPlan.Metadata, make(map[string]string)), + }, + } + } else { + // Process as plan subscription + b, err := body.AsPlanSubscriptionCreate() if err != nil { - return CreateSubscriptionRequest{}, fmt.Errorf("failed to get customer: %w", err) + return CreateSubscriptionRequest{}, models.NewGenericValidationError(fmt.Errorf("invalid request body: %w", err)) } - return CreateSubscriptionRequest{ - WorkflowInput: subscriptionworkflow.CreateSubscriptionWorkflowInput{ - ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{ - Timing: timing, - Name: req.Name, // We map the plan name to the subscription name - Description: req.Description, // We map the plan description to the subscription description - MetadataModel: models.MetadataModel{ - Metadata: req.Metadata, // We map the plan metadata to the subscription metadata - }, - }, - Namespace: ns, - CustomerID: customer.ID, + p, err := h.PlanService.GetPlan(ctx, plan.GetPlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: ns, }, - PlanInput: plan, - }, nil - } else { - // Plan subscription creation - parsedBody, err := body.AsPlanSubscriptionCreate() + Key: b.Plan.Key, + Version: lo.FromPtrOr(b.Plan.Version, 0), + }) if err != nil { - return CreateSubscriptionRequest{}, fmt.Errorf("failed to decode request body: %w", err) + return CreateSubscriptionRequest{}, err } - plan := plansubscription.PlanInput{} - plan.FromRef(&plansubscription.PlanRefInput{ - Key: parsedBody.Plan.Key, + // Handle as plan subscription (works for both original plan refs and modified custom plans) + planInput.FromRef(&plansubscription.PlanRefInput{ + Key: b.Plan.Key, + Version: b.Plan.Version, }) timing := subscription.Timing{ Enum: lo.ToPtr(subscription.TimingImmediate), } - if parsedBody.Timing != nil { - timing, err = MapAPITimingToTiming(*parsedBody.Timing) + if b.Timing != nil { + timing, err = MapAPITimingToTiming(*b.Timing) if err != nil { - return CreateSubscriptionRequest{}, fmt.Errorf("failed to map timing: %w", err) + return CreateSubscriptionRequest{}, err } } - // Get the customer - customer, err := h.getCustomer(ctx, ns, parsedBody.CustomerId, parsedBody.CustomerKey) - if err != nil { - return CreateSubscriptionRequest{}, fmt.Errorf("failed to get customer: %w", err) - } + customerID = b.CustomerId + customerKey = b.CustomerKey - return CreateSubscriptionRequest{ - WorkflowInput: subscriptionworkflow.CreateSubscriptionWorkflowInput{ - ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{ - Timing: timing, - Name: lo.FromPtr(parsedBody.Name), - Description: parsedBody.Description, - MetadataModel: models.MetadataModel{ - Metadata: convert.DerefHeaderPtr[string](parsedBody.Metadata), - }, - }, - Namespace: ns, - CustomerID: customer.ID, + workflowInput.ChangeSubscriptionWorkflowInput = subscriptionworkflow.ChangeSubscriptionWorkflowInput{ + Timing: timing, + Name: lo.FromPtrOr(b.Name, p.Name), + Description: b.Description, + MetadataModel: models.MetadataModel{ + Metadata: lo.FromPtrOr(b.Metadata, make(map[string]string)), }, - PlanInput: plan, - StartingPhase: parsedBody.StartingPhase, - }, nil + } + } + + // Get the customer + customer, err := h.getCustomer(ctx, ns, customerID, customerKey) + if err != nil { + return CreateSubscriptionRequest{}, err } + workflowInput.CustomerID = customer.ID + + return CreateSubscriptionRequest{ + WorkflowInput: workflowInput, + PlanInput: planInput, + StartingPhase: startingPhase, + }, nil }, func(ctx context.Context, request CreateSubscriptionRequest) (CreateSubscriptionResponse, error) { res, err := h.PlanSubscriptionService.Create(ctx, request) diff --git a/openmeter/productcatalog/subscription/http/handler.go b/openmeter/productcatalog/subscription/http/handler.go index 96ab998ef3..52cb380b1e 100644 --- a/openmeter/productcatalog/subscription/http/handler.go +++ b/openmeter/productcatalog/subscription/http/handler.go @@ -8,6 +8,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" "github.com/openmeterio/openmeter/openmeter/subscription" subscriptionworkflow "github.com/openmeterio/openmeter/openmeter/subscription/workflow" @@ -33,6 +34,7 @@ type HandlerConfig struct { SubscriptionService subscription.Service CustomerService customer.Service PlanSubscriptionService plansubscription.PlanSubscriptionService + PlanService plan.Service NamespaceDecoder namespacedriver.NamespaceDecoder Logger *slog.Logger } diff --git a/openmeter/productcatalog/subscription/http/mapping.go b/openmeter/productcatalog/subscription/http/mapping.go index 3593bfc9c8..8bcdafe25e 100644 --- a/openmeter/productcatalog/subscription/http/mapping.go +++ b/openmeter/productcatalog/subscription/http/mapping.go @@ -6,6 +6,7 @@ import ( "time" "github.com/invopop/gobl/currency" + "github.com/oklog/ulid/v2" "github.com/samber/lo" "github.com/openmeterio/openmeter/api" @@ -13,11 +14,13 @@ import ( "github.com/openmeterio/openmeter/openmeter/productcatalog" productcatalogdriver "github.com/openmeterio/openmeter/openmeter/productcatalog/driver" productcataloghttp "github.com/openmeterio/openmeter/openmeter/productcatalog/http" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" plandriver "github.com/openmeterio/openmeter/openmeter/productcatalog/plan/httpdriver" plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" "github.com/openmeterio/openmeter/openmeter/subscription" "github.com/openmeterio/openmeter/openmeter/subscription/patch" "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/defaultx" "github.com/openmeterio/openmeter/pkg/isodate" "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/slicesx" @@ -260,7 +263,7 @@ func MapAPITimingToTiming(apiTiming api.SubscriptionTiming) (subscription.Timing if err != nil { e, err := apiTiming.AsSubscriptionTimingEnum() if err != nil { - return res, fmt.Errorf("failed to cast to SubscriptionChangeTiming: %w", err) + return res, models.NewGenericValidationError(fmt.Errorf("failed to cast to SubscriptionChangeTiming: %w", err)) } res.Enum = lo.ToPtr(subscription.TimingEnum(e)) @@ -439,46 +442,53 @@ func MapSubscriptionViewToAPI(view subscription.SubscriptionView) (api.Subscript return base, nil } -func CustomPlanToCreatePlanRequest(a api.CustomPlanInput, namespace string) (plandriver.CreatePlanRequest, error) { - var err error +// AsCustomPlanCreateInput converts API custom plan input to plan creation input +func AsCustomPlanCreateInput(customPlan api.CustomPlanInput, namespace string) (plan.CreatePlanInput, error) { + metadata := lo.FromPtrOr(customPlan.Metadata, make(map[string]string)) + // TODO(tothandras): use annotations instead + metadata[plan.MetadataKeyCustomPlan] = "true" - req := plandriver.CreatePlanRequest{ + planInput := plan.CreatePlanInput{ NamespacedModel: models.NamespacedModel{ Namespace: namespace, }, Plan: productcatalog.Plan{ PlanMeta: productcatalog.PlanMeta{ - Name: a.Name, - Description: a.Description, - Metadata: lo.FromPtr(a.Metadata), + Key: fmt.Sprintf("custom_plan_%s", ulid.Make().String()), + Version: 1, + Name: defaultx.IfZero(customPlan.Name, "Custom Plan"), + Description: customPlan.Description, + Metadata: metadata, }, - Phases: nil, }, } - if a.Alignment != nil && a.Alignment.BillablesMustAlign != nil { - req.Plan.PlanMeta.Alignment = productcatalog.Alignment{ - BillablesMustAlign: *a.Alignment.BillablesMustAlign, + // Set alignment if provided + if customPlan.Alignment != nil && customPlan.Alignment.BillablesMustAlign != nil { + planInput.Plan.PlanMeta.Alignment = productcatalog.Alignment{ + BillablesMustAlign: *customPlan.Alignment.BillablesMustAlign, } } - req.Currency = currency.Code(a.Currency) - if err = req.Currency.Validate(); err != nil { - return req, fmt.Errorf("invalid CurrencyCode: %w", err) + // Set currency + planInput.Currency = currency.Code(customPlan.Currency) + if err := planInput.Currency.Validate(); err != nil { + return planInput, fmt.Errorf("invalid currency code: %w", err) } - if len(a.Phases) > 0 { - req.Phases = make([]productcatalog.Phase, 0, len(a.Phases)) + // Convert phases + if len(customPlan.Phases) > 0 { + planInput.Phases = make([]productcatalog.Phase, 0, len(customPlan.Phases)) - for _, phase := range a.Phases { + for _, phase := range customPlan.Phases { planPhase, err := plandriver.AsPlanPhase(phase) if err != nil { - return req, fmt.Errorf("failed to cast PlanPhase: %w", err) + return planInput, fmt.Errorf("failed to convert plan phase: %w", err) } - req.Phases = append(req.Phases, planPhase) + planInput.Phases = append(planInput.Phases, planPhase) } } - return req, nil + return planInput, nil } diff --git a/openmeter/productcatalog/subscription/plan.go b/openmeter/productcatalog/subscription/plan.go index 10b4e276ab..19ca4e67f8 100644 --- a/openmeter/productcatalog/subscription/plan.go +++ b/openmeter/productcatalog/subscription/plan.go @@ -6,7 +6,6 @@ import ( "github.com/samber/lo" "github.com/openmeterio/openmeter/openmeter/productcatalog" - "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" "github.com/openmeterio/openmeter/openmeter/subscription" "github.com/openmeterio/openmeter/pkg/currencyx" "github.com/openmeterio/openmeter/pkg/isodate" @@ -14,13 +13,12 @@ import ( ) type PlanInput struct { - ref *PlanRefInput - plan *plan.CreatePlanInput + ref *PlanRefInput } func (p *PlanInput) Validate() error { - if p.ref == nil && p.plan == nil { - return fmt.Errorf("plan or plan reference must be provided") + if p.ref == nil { + return fmt.Errorf("plan reference must be provided") } return nil @@ -30,14 +28,6 @@ func (p *PlanInput) AsRef() *PlanRefInput { return p.ref } -func (p *PlanInput) AsInput() *plan.CreatePlanInput { - return p.plan -} - -func (p *PlanInput) FromInput(pi *plan.CreatePlanInput) { - p.plan = pi -} - func (p *PlanInput) FromRef(pr *PlanRefInput) { p.ref = pr } diff --git a/openmeter/productcatalog/subscription/service/change.go b/openmeter/productcatalog/subscription/service/change.go index d1bdb61ce0..9aa135f8f1 100644 --- a/openmeter/productcatalog/subscription/service/change.go +++ b/openmeter/productcatalog/subscription/service/change.go @@ -6,58 +6,51 @@ import ( "github.com/openmeterio/openmeter/openmeter/productcatalog" plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" - "github.com/openmeterio/openmeter/openmeter/subscription" "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/models" ) func (s *service) Change(ctx context.Context, request plansubscription.ChangeSubscriptionRequest) (plansubscription.SubscriptionChangeResponse, error) { var def plansubscription.SubscriptionChangeResponse - var plan subscription.Plan if err := request.PlanInput.Validate(); err != nil { return def, models.NewGenericValidationError(err) } - if request.PlanInput.AsInput() != nil { - p, err := PlanFromPlanInput(*request.PlanInput.AsInput()) - if err != nil { - return def, err - } + // Get the plan by reference + planRef := request.PlanInput.AsRef() + if planRef == nil { + return def, fmt.Errorf("plan reference must be provided") + } - plan = p - } else if request.PlanInput.AsRef() != nil { - p, err := s.getPlanByVersion(ctx, request.ID.Namespace, *request.PlanInput.AsRef()) - if err != nil { - return def, err - } + p, err := s.getPlanByVersion(ctx, request.ID.Namespace, *planRef) + if err != nil { + return def, err + } - now := clock.Now() + now := clock.Now() - pStatus := p.StatusAt(now) - if pStatus != productcatalog.PlanStatusActive { - return def, models.NewGenericValidationError(fmt.Errorf("plan %s@%d is not active at %s", p.Key, p.Version, now)) - } + pStatus := p.StatusAt(now) + if pStatus != productcatalog.PlanStatusActive { + return def, models.NewGenericValidationError(fmt.Errorf("plan %s@%d is not active at %s", p.Key, p.Version, now)) + } - if p.DeletedAt != nil && !now.Before(*p.DeletedAt) { - return def, models.NewGenericValidationError( - fmt.Errorf("plan is deleted [namespace=%s, key=%s, version=%d, deleted_at=%s]", p.Namespace, p.Key, p.Version, p.DeletedAt), - ) - } + if p.DeletedAt != nil && !now.Before(*p.DeletedAt) { + return def, models.NewGenericValidationError( + fmt.Errorf("plan is deleted [namespace=%s, key=%s, version=%d, deleted_at=%s]", p.Namespace, p.Key, p.Version, p.DeletedAt), + ) + } - // Let's find the starting phase - if request.StartingPhase != nil { - if err := s.removePhasesBeforeStartingPhase(p, *request.StartingPhase); err != nil { - return def, err - } + // Let's find the starting phase + if request.StartingPhase != nil { + if err := s.removePhasesBeforeStartingPhase(p, *request.StartingPhase); err != nil { + return def, err } - - plan = PlanFromPlan(*p) - } else { - return def, fmt.Errorf("plan or plan reference must be provided, input should already be validated") } - // Then let's create the subscription from the plan + plan := PlanFromPlan(*p) + + // Change the subscription to the new plan curr, new, err := s.WorkflowService.ChangeToPlan(ctx, request.ID, request.WorkflowInput, plan) if err != nil { return def, err diff --git a/openmeter/productcatalog/subscription/service/create.go b/openmeter/productcatalog/subscription/service/create.go index e87d153a91..5f23ebb0c4 100644 --- a/openmeter/productcatalog/subscription/service/create.go +++ b/openmeter/productcatalog/subscription/service/create.go @@ -14,52 +14,44 @@ import ( func (s *service) Create(ctx context.Context, request plansubscription.CreateSubscriptionRequest) (subscription.Subscription, error) { var def subscription.Subscription - // Let's build the plan input - var plan subscription.Plan - if err := request.PlanInput.Validate(); err != nil { return def, models.NewGenericValidationError(err) } - if request.PlanInput.AsInput() != nil { - p, err := PlanFromPlanInput(*request.PlanInput.AsInput()) - if err != nil { - return def, err - } + // Get the plan by reference + planRef := request.PlanInput.AsRef() + if planRef == nil { + return def, fmt.Errorf("plan reference must be provided") + } - plan = p - } else if request.PlanInput.AsRef() != nil { - p, err := s.getPlanByVersion(ctx, request.WorkflowInput.Namespace, *request.PlanInput.AsRef()) - if err != nil { - return def, err - } + p, err := s.getPlanByVersion(ctx, request.WorkflowInput.Namespace, *planRef) + if err != nil { + return def, err + } - now := clock.Now() + now := clock.Now() - if p.DeletedAt != nil && !now.Before(*p.DeletedAt) { - return def, models.NewGenericValidationError( - fmt.Errorf("plan is deleted [namespace=%s, key=%s, version=%d, deleted_at=%s]", p.Namespace, p.Key, p.Version, p.DeletedAt), - ) - } + if p.DeletedAt != nil && !now.Before(*p.DeletedAt) { + return def, models.NewGenericValidationError( + fmt.Errorf("plan is deleted [namespace=%s, key=%s, version=%d, deleted_at=%s]", p.Namespace, p.Key, p.Version, p.DeletedAt), + ) + } - if p.StatusAt(now) != productcatalog.PlanStatusActive { - return def, models.NewGenericValidationError( - fmt.Errorf("plan %s@%d is not active at %s", p.Key, p.Version, now), - ) - } + if p.StatusAt(now) != productcatalog.PlanStatusActive { + return def, models.NewGenericValidationError( + fmt.Errorf("plan %s@%d is not active at %s", p.Key, p.Version, now), + ) + } - if request.StartingPhase != nil { - if err := s.removePhasesBeforeStartingPhase(p, *request.StartingPhase); err != nil { - return def, err - } + if request.StartingPhase != nil { + if err := s.removePhasesBeforeStartingPhase(p, *request.StartingPhase); err != nil { + return def, err } - - plan = PlanFromPlan(*p) - } else { - return def, fmt.Errorf("plan or plan reference must be provided, should have validated already") } - // Then let's create the subscription form the plan + plan := PlanFromPlan(*p) + + // Create the subscription from the plan subView, err := s.WorkflowService.CreateFromPlan(ctx, request.WorkflowInput, plan) if err != nil { return def, err diff --git a/openmeter/productcatalog/subscription/service/plan.go b/openmeter/productcatalog/subscription/service/plan.go index 037fd2ed5d..dfdbfa8749 100644 --- a/openmeter/productcatalog/subscription/service/plan.go +++ b/openmeter/productcatalog/subscription/service/plan.go @@ -2,9 +2,6 @@ package service import ( "context" - "fmt" - - "github.com/samber/lo" "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" @@ -39,39 +36,6 @@ func (s *service) getPlanByVersion(ctx context.Context, namespace string, ref pl return p, nil } -// TODO: we can get rid of this if plan implements subscription.Plan or if we just use plain productcatalog.Plan -func PlanFromPlanInput(input plan.CreatePlanInput) (subscription.Plan, error) { - p := input.Plan - - // We need to cheat a bit as plan validation fails without key and reference - // There isn't a meaningful type to what we're using here - // TODO: we could either - // 1. redifine this partial type in `productcatalog` (though its only meaningful here) - // 2. define the type here and figure out how to reuse the validations etc... - // 3. accept the fact that we're cheating and remove this comment - - if !lo.IsEmpty(p.Key) || !lo.IsEmpty(p.Version) { - // Let's safeguard ourselves - return nil, fmt.Errorf("plan key and version must be empty") - } - - // Let's set the fields for the validation to pass - p.Key = "cheat" - p.Version = 1 - - if err := p.ValidForCreatingSubscriptions(); err != nil { - return nil, models.NewGenericValidationError(fmt.Errorf("invalid plan: %v", err)) - } - - // Let's unset the fields - p.Key = "" - p.Version = 0 - - return &plansubscription.Plan{ - Plan: p, - }, nil -} - func PlanFromPlan(p plan.Plan) subscription.Plan { return &plansubscription.Plan{ Plan: p.AsProductCatalogPlan(), diff --git a/openmeter/productcatalog/subscription/testutils/adapter.go b/openmeter/productcatalog/subscription/testutils/adapter.go index 735e07b1e2..b200d7dda6 100644 --- a/openmeter/productcatalog/subscription/testutils/adapter.go +++ b/openmeter/productcatalog/subscription/testutils/adapter.go @@ -7,7 +7,6 @@ import ( "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" - "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription/service" "github.com/openmeterio/openmeter/openmeter/subscription" "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/defaultx" @@ -20,9 +19,6 @@ type PlanSubscriptionAdapter interface { // // If the Plan is Not Found, it should return a PlanNotFoundError. GetVersion(ctx context.Context, namespace string, ref plansubscription.PlanRefInput) (subscription.Plan, error) - - // Converts a plan.CreatePlanInput to a subscription.Plan. - FromInput(ctx context.Context, namespace string, input plan.CreatePlanInput) (subscription.Plan, error) } type PlanSubscriptionAdapterConfig struct { @@ -71,7 +67,3 @@ func (a *adapter) GetVersion(ctx context.Context, namespace string, ref plansubs Ref: &p.NamespacedID, }, nil } - -func (a *adapter) FromInput(ctx context.Context, namespace string, input plan.CreatePlanInput) (subscription.Plan, error) { - return service.PlanFromPlanInput(input) -} diff --git a/openmeter/server/router/router.go b/openmeter/server/router/router.go index 93d063df45..90e7338f93 100644 --- a/openmeter/server/router/router.go +++ b/openmeter/server/router/router.go @@ -386,6 +386,7 @@ func NewRouter(config Config) (*Router, error) { SubscriptionWorkflowService: config.SubscriptionWorkflowService, SubscriptionService: config.SubscriptionService, PlanSubscriptionService: config.PlanSubscriptionService, + PlanService: config.Plan, NamespaceDecoder: staticNamespaceDecoder, CustomerService: config.Customer, Logger: config.Logger, diff --git a/openmeter/subscription/aligment_test.go b/openmeter/subscription/aligment_test.go index eb4e7e4cc6..bd024ea44c 100644 --- a/openmeter/subscription/aligment_test.go +++ b/openmeter/subscription/aligment_test.go @@ -16,26 +16,20 @@ import ( "github.com/openmeterio/openmeter/openmeter/testutils" "github.com/openmeterio/openmeter/pkg/currencyx" "github.com/openmeterio/openmeter/pkg/isodate" - "github.com/openmeterio/openmeter/pkg/models" ) func TestAlignedBillingPeriodCalculation(t *testing.T) { - ns := subscriptiontestutils.ExampleNamespace - - planInp := plan.CreatePlanInput{ - NamespacedModel: models.NamespacedModel{ - Namespace: ns, - }, - Plan: productcatalog.Plan{ - PlanMeta: productcatalog.PlanMeta{ - Name: "Test Plan", - Currency: currency.USD, - Alignment: productcatalog.Alignment{ - BillablesMustAlign: true, - }, + p := plan.Plan{ + PlanMeta: productcatalog.PlanMeta{ + Name: "Test Plan", + Currency: currency.USD, + Alignment: productcatalog.Alignment{ + BillablesMustAlign: true, }, - Phases: []productcatalog.Phase{ - { + }, + Phases: []plan.Phase{ + { + Phase: productcatalog.Phase{ PhaseMeta: productcatalog.PhaseMeta{ Name: "trial", Key: "trial", @@ -55,7 +49,9 @@ func TestAlignedBillingPeriodCalculation(t *testing.T) { }, }, }, - { + }, + { + Phase: productcatalog.Phase{ PhaseMeta: productcatalog.PhaseMeta{ Name: "default", Key: "default", @@ -81,8 +77,7 @@ func TestAlignedBillingPeriodCalculation(t *testing.T) { }, } - subPlan, err := pcsubscriptionservice.PlanFromPlanInput(planInp) - require.NoError(t, err) + subPlan := pcsubscriptionservice.PlanFromPlan(p) t.Run("Should error if the subscription is canceled or inactive", func(t *testing.T) { spec, err := subscription.NewSpecFromPlan(subPlan, subscription.CreateSubscriptionCustomerInput{ diff --git a/pkg/framework/commonhttp/decoder.go b/pkg/framework/commonhttp/decoder.go index 96401175ff..ab72bd44bc 100644 --- a/pkg/framework/commonhttp/decoder.go +++ b/pkg/framework/commonhttp/decoder.go @@ -5,11 +5,12 @@ import ( "net/http" "github.com/go-chi/render" + "github.com/openmeterio/openmeter/pkg/models" ) func JSONRequestBodyDecoder(r *http.Request, out any) error { if err := render.DecodeJSON(r.Body, out); err != nil { - return NewHTTPError(http.StatusBadRequest, fmt.Errorf("decode json: %w", err)) + return models.NewGenericValidationError(fmt.Errorf("invalid request body: %w", err)) } return nil } diff --git a/test/customer/customer.go b/test/customer/customer.go index 9f2300f215..bb134e483e 100644 --- a/test/customer/customer.go +++ b/test/customer/customer.go @@ -216,37 +216,7 @@ func (s *CustomerHandlerTestSuite) TestUpdateWithSubscriptionPresent(ctx context require.Equal(t, TestName, originalCustomer.Name, "Customer name must match") require.Equal(t, TestSubjectKeys, originalCustomer.UsageAttribution.SubjectKeys, "Customer usage attribution subject keys must match") - emptyExamplePlan := plan.CreatePlanInput{ - NamespacedModel: models.NamespacedModel{ - Namespace: s.namespace, - }, - Plan: productcatalog.Plan{ - PlanMeta: productcatalog.PlanMeta{ - Name: "Empty Plan", - Currency: currency.Code("USD"), - }, - Phases: []productcatalog.Phase{ - { - PhaseMeta: productcatalog.PhaseMeta{ - Key: "empty-phase", - Name: "Empty Phase", - }, - RateCards: []productcatalog.RateCard{ - &productcatalog.FlatFeeRateCard{ - RateCardMeta: productcatalog.RateCardMeta{ - Key: "empty-rate-card", - Name: "Empty Rate Card", - }, - }, - }, - }, - }, - }, - } - - // Let's create a subscription for the customer - p, err := plansubscriptionservice.PlanFromPlanInput(emptyExamplePlan) - require.Nil(t, err) + p := s.createTestPlan(ctx, t) spec, err := subscription.NewSpecFromPlan(p, subscription.CreateSubscriptionCustomerInput{ CustomerId: originalCustomer.ID, @@ -545,37 +515,7 @@ func (s *CustomerHandlerTestSuite) TestDelete(ctx context.Context, t *testing.T) ID: originalCustomer.ID, } - // Let's create a subscription for the customer - emptyExamplePlan := plan.CreatePlanInput{ - NamespacedModel: models.NamespacedModel{ - Namespace: s.namespace, - }, - Plan: productcatalog.Plan{ - PlanMeta: productcatalog.PlanMeta{ - Name: "Empty Plan", - Currency: currency.Code("USD"), - }, - Phases: []productcatalog.Phase{ - { - PhaseMeta: productcatalog.PhaseMeta{ - Key: "empty-phase", - Name: "Empty Phase", - }, - RateCards: []productcatalog.RateCard{ - &productcatalog.FlatFeeRateCard{ - RateCardMeta: productcatalog.RateCardMeta{ - Key: "empty-rate-card", - Name: "Empty Rate Card", - }, - }, - }, - }, - }, - }, - } - - p, err := plansubscriptionservice.PlanFromPlanInput(emptyExamplePlan) - require.Nil(t, err) + p := s.createTestPlan(ctx, t) spec, err := subscription.NewSpecFromPlan(p, subscription.CreateSubscriptionCustomerInput{ CustomerId: originalCustomer.ID, @@ -589,7 +529,6 @@ func (s *CustomerHandlerTestSuite) TestDelete(ctx context.Context, t *testing.T) sub, err := subService.Create(ctx, s.namespace, spec) require.Nil(t, err) - // Delete the customer require.Equal(t, sub.CustomerId, customerId.ID, "Subscription customer ID must match") err = custService.DeleteCustomer(ctx, customer.DeleteCustomerInput(customerId)) @@ -626,3 +565,58 @@ func (s *CustomerHandlerTestSuite) TestDelete(ctx context.Context, t *testing.T) _, err = custService.CreateCustomer(ctx, input) require.NoError(t, err, "Creating a customer with the same subject keys must not return error") } + +// createTestPlan creates and publishes a plan that can be used in tests +func (s *CustomerHandlerTestSuite) createTestPlan(ctx context.Context, t *testing.T) subscription.Plan { + t.Helper() + + planService := s.Env.Plan() + + input := plan.CreatePlanInput{ + NamespacedModel: models.NamespacedModel{ + Namespace: s.namespace, + }, + Plan: productcatalog.Plan{ + PlanMeta: productcatalog.PlanMeta{ + Key: "empty-plan", + Name: "Empty Plan", + Version: 1, + Currency: currency.Code("USD"), + }, + Phases: []productcatalog.Phase{ + { + PhaseMeta: productcatalog.PhaseMeta{ + Key: "empty-phase", + Name: "Empty Phase", + }, + RateCards: []productcatalog.RateCard{ + &productcatalog.FlatFeeRateCard{ + RateCardMeta: productcatalog.RateCardMeta{ + Key: "empty-rate-card", + Name: "Empty Rate Card", + }, + }, + }, + }, + }, + }, + } + + // Create the plan + p, err := planService.CreatePlan(ctx, input) + require.NoError(t, err, "Creating plan must not return error") + require.NotNil(t, p, "Plan must not be nil") + + // Publish the plan + p, err = planService.PublishPlan(ctx, plan.PublishPlanInput{ + NamespacedID: p.NamespacedID, + EffectivePeriod: productcatalog.EffectivePeriod{ + EffectiveFrom: lo.ToPtr(clock.Now()), + EffectiveTo: lo.ToPtr(clock.Now().Add(365 * 24 * time.Hour)), // 1 year from now + }, + }) + require.NoError(t, err, "Publishing plan must not return error") + require.NotNil(t, p, "Published plan must not be nil") + + return plansubscriptionservice.PlanFromPlan(*p) +} diff --git a/test/customer/testenv.go b/test/customer/testenv.go index be88b9a89a..cf47414760 100644 --- a/test/customer/testenv.go +++ b/test/customer/testenv.go @@ -13,6 +13,9 @@ import ( entcustomervalidator "github.com/openmeterio/openmeter/openmeter/entitlement/validators/customer" "github.com/openmeterio/openmeter/openmeter/meter" meteradapter "github.com/openmeterio/openmeter/openmeter/meter/mockadapter" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + planrepo "github.com/openmeterio/openmeter/openmeter/productcatalog/plan/adapter" + planservice "github.com/openmeterio/openmeter/openmeter/productcatalog/plan/service" registrybuilder "github.com/openmeterio/openmeter/openmeter/registry/builder" streamingtestutils "github.com/openmeterio/openmeter/openmeter/streaming/testutils" "github.com/openmeterio/openmeter/openmeter/subscription" @@ -29,6 +32,7 @@ const ( type TestEnv interface { Customer() customer.Service Subscription() subscription.Service + Plan() plan.Service Close() error } @@ -38,6 +42,7 @@ var _ TestEnv = (*testEnv)(nil) type testEnv struct { customer customer.Service subscription subscription.Service + plan plan.Service closerFunc func() error } @@ -54,6 +59,10 @@ func (n testEnv) Subscription() subscription.Service { return n.subscription } +func (n testEnv) Plan() plan.Service { + return n.plan +} + const ( DefaultPostgresHost = "127.0.0.1" ) @@ -85,6 +94,25 @@ func NewTestEnv(t *testing.T, ctx context.Context) (TestEnv, error) { }, }) + // Plan + planRepo, err := planrepo.New(planrepo.Config{ + Client: dbDeps.DBClient, + Logger: logger, + }) + if err != nil { + return nil, fmt.Errorf("failed to create plan adapter: %w", err) + } + + planService, err := planservice.New(planservice.Config{ + Feature: entitlementRegistry.Feature, + Logger: logger, + Adapter: planRepo, + Publisher: eventbus.NewMock(t), + }) + if err != nil { + return nil, fmt.Errorf("failed to create plan service: %w", err) + } + // Customer customerAdapter, err := customeradapter.New(customeradapter.Config{ Client: dbDeps.DBClient, @@ -126,7 +154,8 @@ func NewTestEnv(t *testing.T, ctx context.Context) (TestEnv, error) { return &testEnv{ customer: customerService, - closerFunc: closerFunc, subscription: subsDeps.SubscriptionService, + plan: planService, + closerFunc: closerFunc, }, nil } From 83612ceb7f3166e3e2450645791e2454a029fd6f Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Mon, 26 May 2025 12:41:51 +0200 Subject: [PATCH 2/3] chore: fix linter --- e2e/productcatalog_test.go | 3 ++- openmeter/productcatalog/subscription/http/change.go | 2 +- pkg/framework/commonhttp/decoder.go | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/e2e/productcatalog_test.go b/e2e/productcatalog_test.go index 391270e419..545d000970 100644 --- a/e2e/productcatalog_test.go +++ b/e2e/productcatalog_test.go @@ -809,11 +809,12 @@ func TestPlan(t *testing.T) { ct := &api.SubscriptionTiming{} require.NoError(t, ct.FromSubscriptionTiming1(startTime)) - body.FromCustomSubscriptionCreate(api.CustomSubscriptionCreate{ + err = body.FromCustomSubscriptionCreate(api.CustomSubscriptionCreate{ CustomerId: lo.ToPtr(customer4.Id), CustomPlan: customPlanInput, Timing: ct, }) + require.Nil(t, err) // Create a subscription with a custom plan customSubAPIRes, err := client.CreateSubscriptionWithResponse(ctx, body) require.Nil(t, err) diff --git a/openmeter/productcatalog/subscription/http/change.go b/openmeter/productcatalog/subscription/http/change.go index 9dde45eeb7..bcbb42188d 100644 --- a/openmeter/productcatalog/subscription/http/change.go +++ b/openmeter/productcatalog/subscription/http/change.go @@ -42,7 +42,7 @@ func (h *handler) ChangeSubscription() ChangeSubscriptionHandler { return ChangeSubscriptionRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) } - workflowInput := subscriptionworkflow.ChangeSubscriptionWorkflowInput{} + var workflowInput subscriptionworkflow.ChangeSubscriptionWorkflowInput var planInput plansubscription.PlanInput var startingPhase *string diff --git a/pkg/framework/commonhttp/decoder.go b/pkg/framework/commonhttp/decoder.go index ab72bd44bc..2ee8fd1398 100644 --- a/pkg/framework/commonhttp/decoder.go +++ b/pkg/framework/commonhttp/decoder.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/go-chi/render" + "github.com/openmeterio/openmeter/pkg/models" ) From f79776adbec404e90fbe15b9d115be092b015040 Mon Sep 17 00:00:00 2001 From: Andras Toth <4157749+tothandras@users.noreply.github.com> Date: Mon, 26 May 2025 13:25:11 +0200 Subject: [PATCH 3/3] feat(backend): add custom flag to plan database schema --- e2e/productcatalog_test.go | 11 +--- openmeter/ent/db/migrate/schema.go | 1 + openmeter/ent/db/mutation.go | 56 +++++++++++++++- openmeter/ent/db/plan.go | 13 +++- openmeter/ent/db/plan/plan.go | 10 +++ openmeter/ent/db/plan/where.go | 15 +++++ openmeter/ent/db/plan_create.go | 65 +++++++++++++++++++ openmeter/ent/db/plan_update.go | 34 ++++++++++ openmeter/ent/db/runtime.go | 4 ++ openmeter/ent/schema/productcatalog.go | 3 + openmeter/productcatalog/plan/adapter/plan.go | 8 +-- openmeter/productcatalog/plan/plan.go | 11 ++-- openmeter/productcatalog/plan/service.go | 3 + .../subscription/http/mapping.go | 3 +- .../20250526111543_plan-is-custom.down.sql | 2 + .../20250526111543_plan-is-custom.up.sql | 2 + tools/migrate/migrations/atlas.sum | 3 +- 17 files changed, 218 insertions(+), 26 deletions(-) create mode 100644 tools/migrate/migrations/20250526111543_plan-is-custom.down.sql create mode 100644 tools/migrate/migrations/20250526111543_plan-is-custom.up.sql diff --git a/e2e/productcatalog_test.go b/e2e/productcatalog_test.go index 545d000970..348677c565 100644 --- a/e2e/productcatalog_test.go +++ b/e2e/productcatalog_test.go @@ -828,16 +828,7 @@ func TestPlan(t *testing.T) { plans := listPlansAPIRes.JSON200 require.NotNil(t, plans) require.NotNil(t, plans.Items) - - // Verify that all returned plans are not custom plans - for _, plan := range plans.Items { - if plan.Metadata != nil { - metadata := lo.FromPtrOr(plan.Metadata, map[string]string{}) - customPlanValue, exists := metadata["openmeter.custom_plan"] - assert.False(t, exists && customPlanValue == "true", - "Plan %s should not be a custom plan in the list response", plan.Id) - } - } + require.Len(t, plans.Items, 1) // Verify that non-custom plans are still in the list planFound := false diff --git a/openmeter/ent/db/migrate/schema.go b/openmeter/ent/db/migrate/schema.go index 10de736bc3..95ebe21fee 100644 --- a/openmeter/ent/db/migrate/schema.go +++ b/openmeter/ent/db/migrate/schema.go @@ -1729,6 +1729,7 @@ var ( {Name: "currency", Type: field.TypeString, Default: "USD"}, {Name: "effective_from", Type: field.TypeTime, Nullable: true}, {Name: "effective_to", Type: field.TypeTime, Nullable: true}, + {Name: "is_custom", Type: field.TypeBool, Default: false}, } // PlansTable holds the schema information for the "plans" table. PlansTable = &schema.Table{ diff --git a/openmeter/ent/db/mutation.go b/openmeter/ent/db/mutation.go index 3acece2b4b..b8f087636b 100644 --- a/openmeter/ent/db/mutation.go +++ b/openmeter/ent/db/mutation.go @@ -38652,6 +38652,7 @@ type PlanMutation struct { currency *string effective_from *time.Time effective_to *time.Time + is_custom *bool clearedFields map[string]struct{} phases map[string]struct{} removedphases map[string]struct{} @@ -39324,6 +39325,42 @@ func (m *PlanMutation) ResetEffectiveTo() { delete(m.clearedFields, plan.FieldEffectiveTo) } +// SetIsCustom sets the "is_custom" field. +func (m *PlanMutation) SetIsCustom(b bool) { + m.is_custom = &b +} + +// IsCustom returns the value of the "is_custom" field in the mutation. +func (m *PlanMutation) IsCustom() (r bool, exists bool) { + v := m.is_custom + if v == nil { + return + } + return *v, true +} + +// OldIsCustom returns the old "is_custom" field's value of the Plan entity. +// If the Plan object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *PlanMutation) OldIsCustom(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldIsCustom is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldIsCustom requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldIsCustom: %w", err) + } + return oldValue.IsCustom, nil +} + +// ResetIsCustom resets all changes to the "is_custom" field. +func (m *PlanMutation) ResetIsCustom() { + m.is_custom = nil +} + // AddPhaseIDs adds the "phases" edge to the PlanPhase entity by ids. func (m *PlanMutation) AddPhaseIDs(ids ...string) { if m.phases == nil { @@ -39520,7 +39557,7 @@ func (m *PlanMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *PlanMutation) Fields() []string { - fields := make([]string, 0, 13) + fields := make([]string, 0, 14) if m.namespace != nil { fields = append(fields, plan.FieldNamespace) } @@ -39560,6 +39597,9 @@ func (m *PlanMutation) Fields() []string { if m.effective_to != nil { fields = append(fields, plan.FieldEffectiveTo) } + if m.is_custom != nil { + fields = append(fields, plan.FieldIsCustom) + } return fields } @@ -39594,6 +39634,8 @@ func (m *PlanMutation) Field(name string) (ent.Value, bool) { return m.EffectiveFrom() case plan.FieldEffectiveTo: return m.EffectiveTo() + case plan.FieldIsCustom: + return m.IsCustom() } return nil, false } @@ -39629,6 +39671,8 @@ func (m *PlanMutation) OldField(ctx context.Context, name string) (ent.Value, er return m.OldEffectiveFrom(ctx) case plan.FieldEffectiveTo: return m.OldEffectiveTo(ctx) + case plan.FieldIsCustom: + return m.OldIsCustom(ctx) } return nil, fmt.Errorf("unknown Plan field %s", name) } @@ -39729,6 +39773,13 @@ func (m *PlanMutation) SetField(name string, value ent.Value) error { } m.SetEffectiveTo(v) return nil + case plan.FieldIsCustom: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetIsCustom(v) + return nil } return fmt.Errorf("unknown Plan field %s", name) } @@ -39865,6 +39916,9 @@ func (m *PlanMutation) ResetField(name string) error { case plan.FieldEffectiveTo: m.ResetEffectiveTo() return nil + case plan.FieldIsCustom: + m.ResetIsCustom() + return nil } return fmt.Errorf("unknown Plan field %s", name) } diff --git a/openmeter/ent/db/plan.go b/openmeter/ent/db/plan.go index 861442f36b..f20061eaad 100644 --- a/openmeter/ent/db/plan.go +++ b/openmeter/ent/db/plan.go @@ -44,6 +44,8 @@ type Plan struct { EffectiveFrom *time.Time `json:"effective_from,omitempty"` // EffectiveTo holds the value of the "effective_to" field. EffectiveTo *time.Time `json:"effective_to,omitempty"` + // Whether this is a custom plan created for a specific customer. + IsCustom bool `json:"is_custom,omitempty"` // Edges holds the relations/edges for other nodes in the graph. // The values are being populated by the PlanQuery when eager-loading is set. Edges PlanEdges `json:"edges"` @@ -97,7 +99,7 @@ func (*Plan) scanValues(columns []string) ([]any, error) { switch columns[i] { case plan.FieldMetadata: values[i] = new([]byte) - case plan.FieldBillablesMustAlign: + case plan.FieldBillablesMustAlign, plan.FieldIsCustom: values[i] = new(sql.NullBool) case plan.FieldVersion: values[i] = new(sql.NullInt64) @@ -210,6 +212,12 @@ func (_m *Plan) assignValues(columns []string, values []any) error { _m.EffectiveTo = new(time.Time) *_m.EffectiveTo = value.Time } + case plan.FieldIsCustom: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field is_custom", values[i]) + } else if value.Valid { + _m.IsCustom = value.Bool + } default: _m.selectValues.Set(columns[i], values[i]) } @@ -307,6 +315,9 @@ func (_m *Plan) String() string { builder.WriteString("effective_to=") builder.WriteString(v.Format(time.ANSIC)) } + builder.WriteString(", ") + builder.WriteString("is_custom=") + builder.WriteString(fmt.Sprintf("%v", _m.IsCustom)) builder.WriteByte(')') return builder.String() } diff --git a/openmeter/ent/db/plan/plan.go b/openmeter/ent/db/plan/plan.go index a893edfc26..aef18fbe49 100644 --- a/openmeter/ent/db/plan/plan.go +++ b/openmeter/ent/db/plan/plan.go @@ -40,6 +40,8 @@ const ( FieldEffectiveFrom = "effective_from" // FieldEffectiveTo holds the string denoting the effective_to field in the database. FieldEffectiveTo = "effective_to" + // FieldIsCustom holds the string denoting the is_custom field in the database. + FieldIsCustom = "is_custom" // EdgePhases holds the string denoting the phases edge name in mutations. EdgePhases = "phases" // EdgeAddons holds the string denoting the addons edge name in mutations. @@ -87,6 +89,7 @@ var Columns = []string{ FieldCurrency, FieldEffectiveFrom, FieldEffectiveTo, + FieldIsCustom, } // ValidColumn reports if the column name is valid (part of the table columns). @@ -118,6 +121,8 @@ var ( DefaultCurrency string // CurrencyValidator is a validator for the "currency" field. It is called by the builders before save. CurrencyValidator func(string) error + // DefaultIsCustom holds the default value on creation for the "is_custom" field. + DefaultIsCustom bool // DefaultID holds the default value on creation for the "id" field. DefaultID func() string ) @@ -190,6 +195,11 @@ func ByEffectiveTo(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldEffectiveTo, opts...).ToFunc() } +// ByIsCustom orders the results by the is_custom field. +func ByIsCustom(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldIsCustom, opts...).ToFunc() +} + // ByPhasesCount orders the results by phases count. func ByPhasesCount(opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { diff --git a/openmeter/ent/db/plan/where.go b/openmeter/ent/db/plan/where.go index e6c6967668..590f1710c6 100644 --- a/openmeter/ent/db/plan/where.go +++ b/openmeter/ent/db/plan/where.go @@ -125,6 +125,11 @@ func EffectiveTo(v time.Time) predicate.Plan { return predicate.Plan(sql.FieldEQ(FieldEffectiveTo, v)) } +// IsCustom applies equality check predicate on the "is_custom" field. It's identical to IsCustomEQ. +func IsCustom(v bool) predicate.Plan { + return predicate.Plan(sql.FieldEQ(FieldIsCustom, v)) +} + // NamespaceEQ applies the EQ predicate on the "namespace" field. func NamespaceEQ(v string) predicate.Plan { return predicate.Plan(sql.FieldEQ(FieldNamespace, v)) @@ -750,6 +755,16 @@ func EffectiveToNotNil() predicate.Plan { return predicate.Plan(sql.FieldNotNull(FieldEffectiveTo)) } +// IsCustomEQ applies the EQ predicate on the "is_custom" field. +func IsCustomEQ(v bool) predicate.Plan { + return predicate.Plan(sql.FieldEQ(FieldIsCustom, v)) +} + +// IsCustomNEQ applies the NEQ predicate on the "is_custom" field. +func IsCustomNEQ(v bool) predicate.Plan { + return predicate.Plan(sql.FieldNEQ(FieldIsCustom, v)) +} + // HasPhases applies the HasEdge predicate on the "phases" edge. func HasPhases() predicate.Plan { return predicate.Plan(func(s *sql.Selector) { diff --git a/openmeter/ent/db/plan_create.go b/openmeter/ent/db/plan_create.go index 3ee5f4656a..3fcc139d86 100644 --- a/openmeter/ent/db/plan_create.go +++ b/openmeter/ent/db/plan_create.go @@ -168,6 +168,20 @@ func (_c *PlanCreate) SetNillableEffectiveTo(v *time.Time) *PlanCreate { return _c } +// SetIsCustom sets the "is_custom" field. +func (_c *PlanCreate) SetIsCustom(v bool) *PlanCreate { + _c.mutation.SetIsCustom(v) + return _c +} + +// SetNillableIsCustom sets the "is_custom" field if the given value is not nil. +func (_c *PlanCreate) SetNillableIsCustom(v *bool) *PlanCreate { + if v != nil { + _c.SetIsCustom(*v) + } + return _c +} + // SetID sets the "id" field. func (_c *PlanCreate) SetID(v string) *PlanCreate { _c.mutation.SetID(v) @@ -278,6 +292,10 @@ func (_c *PlanCreate) defaults() { v := plan.DefaultCurrency _c.mutation.SetCurrency(v) } + if _, ok := _c.mutation.IsCustom(); !ok { + v := plan.DefaultIsCustom + _c.mutation.SetIsCustom(v) + } if _, ok := _c.mutation.ID(); !ok { v := plan.DefaultID() _c.mutation.SetID(v) @@ -330,6 +348,9 @@ func (_c *PlanCreate) check() error { return &ValidationError{Name: "currency", err: fmt.Errorf(`db: validator failed for field "Plan.currency": %w`, err)} } } + if _, ok := _c.mutation.IsCustom(); !ok { + return &ValidationError{Name: "is_custom", err: errors.New(`db: missing required field "Plan.is_custom"`)} + } return nil } @@ -418,6 +439,10 @@ func (_c *PlanCreate) createSpec() (*Plan, *sqlgraph.CreateSpec) { _spec.SetField(plan.FieldEffectiveTo, field.TypeTime, value) _node.EffectiveTo = &value } + if value, ok := _c.mutation.IsCustom(); ok { + _spec.SetField(plan.FieldIsCustom, field.TypeBool, value) + _node.IsCustom = value + } if nodes := _c.mutation.PhasesIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -662,6 +687,18 @@ func (u *PlanUpsert) ClearEffectiveTo() *PlanUpsert { return u } +// SetIsCustom sets the "is_custom" field. +func (u *PlanUpsert) SetIsCustom(v bool) *PlanUpsert { + u.Set(plan.FieldIsCustom, v) + return u +} + +// UpdateIsCustom sets the "is_custom" field to the value that was provided on create. +func (u *PlanUpsert) UpdateIsCustom() *PlanUpsert { + u.SetExcluded(plan.FieldIsCustom) + return u +} + // UpdateNewValues updates the mutable fields using the new values that were set on create except the ID field. // Using this option is equivalent to using: // @@ -890,6 +927,20 @@ func (u *PlanUpsertOne) ClearEffectiveTo() *PlanUpsertOne { }) } +// SetIsCustom sets the "is_custom" field. +func (u *PlanUpsertOne) SetIsCustom(v bool) *PlanUpsertOne { + return u.Update(func(s *PlanUpsert) { + s.SetIsCustom(v) + }) +} + +// UpdateIsCustom sets the "is_custom" field to the value that was provided on create. +func (u *PlanUpsertOne) UpdateIsCustom() *PlanUpsertOne { + return u.Update(func(s *PlanUpsert) { + s.UpdateIsCustom() + }) +} + // Exec executes the query. func (u *PlanUpsertOne) Exec(ctx context.Context) error { if len(u.create.conflict) == 0 { @@ -1285,6 +1336,20 @@ func (u *PlanUpsertBulk) ClearEffectiveTo() *PlanUpsertBulk { }) } +// SetIsCustom sets the "is_custom" field. +func (u *PlanUpsertBulk) SetIsCustom(v bool) *PlanUpsertBulk { + return u.Update(func(s *PlanUpsert) { + s.SetIsCustom(v) + }) +} + +// UpdateIsCustom sets the "is_custom" field to the value that was provided on create. +func (u *PlanUpsertBulk) UpdateIsCustom() *PlanUpsertBulk { + return u.Update(func(s *PlanUpsert) { + s.UpdateIsCustom() + }) +} + // Exec executes the query. func (u *PlanUpsertBulk) Exec(ctx context.Context) error { if u.create.err != nil { diff --git a/openmeter/ent/db/plan_update.go b/openmeter/ent/db/plan_update.go index b2852bb1de..2f9ec88ac8 100644 --- a/openmeter/ent/db/plan_update.go +++ b/openmeter/ent/db/plan_update.go @@ -178,6 +178,20 @@ func (_u *PlanUpdate) ClearEffectiveTo() *PlanUpdate { return _u } +// SetIsCustom sets the "is_custom" field. +func (_u *PlanUpdate) SetIsCustom(v bool) *PlanUpdate { + _u.mutation.SetIsCustom(v) + return _u +} + +// SetNillableIsCustom sets the "is_custom" field if the given value is not nil. +func (_u *PlanUpdate) SetNillableIsCustom(v *bool) *PlanUpdate { + if v != nil { + _u.SetIsCustom(*v) + } + return _u +} + // AddPhaseIDs adds the "phases" edge to the PlanPhase entity by IDs. func (_u *PlanUpdate) AddPhaseIDs(ids ...string) *PlanUpdate { _u.mutation.AddPhaseIDs(ids...) @@ -394,6 +408,9 @@ func (_u *PlanUpdate) sqlSave(ctx context.Context) (_node int, err error) { if _u.mutation.EffectiveToCleared() { _spec.ClearField(plan.FieldEffectiveTo, field.TypeTime) } + if value, ok := _u.mutation.IsCustom(); ok { + _spec.SetField(plan.FieldIsCustom, field.TypeBool, value) + } if _u.mutation.PhasesCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -696,6 +713,20 @@ func (_u *PlanUpdateOne) ClearEffectiveTo() *PlanUpdateOne { return _u } +// SetIsCustom sets the "is_custom" field. +func (_u *PlanUpdateOne) SetIsCustom(v bool) *PlanUpdateOne { + _u.mutation.SetIsCustom(v) + return _u +} + +// SetNillableIsCustom sets the "is_custom" field if the given value is not nil. +func (_u *PlanUpdateOne) SetNillableIsCustom(v *bool) *PlanUpdateOne { + if v != nil { + _u.SetIsCustom(*v) + } + return _u +} + // AddPhaseIDs adds the "phases" edge to the PlanPhase entity by IDs. func (_u *PlanUpdateOne) AddPhaseIDs(ids ...string) *PlanUpdateOne { _u.mutation.AddPhaseIDs(ids...) @@ -942,6 +973,9 @@ func (_u *PlanUpdateOne) sqlSave(ctx context.Context) (_node *Plan, err error) { if _u.mutation.EffectiveToCleared() { _spec.ClearField(plan.FieldEffectiveTo, field.TypeTime) } + if value, ok := _u.mutation.IsCustom(); ok { + _spec.SetField(plan.FieldIsCustom, field.TypeBool, value) + } if _u.mutation.PhasesCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, diff --git a/openmeter/ent/db/runtime.go b/openmeter/ent/db/runtime.go index c0afc09b1a..4f26d47a72 100644 --- a/openmeter/ent/db/runtime.go +++ b/openmeter/ent/db/runtime.go @@ -1169,6 +1169,10 @@ func init() { plan.DefaultCurrency = planDescCurrency.Default.(string) // plan.CurrencyValidator is a validator for the "currency" field. It is called by the builders before save. plan.CurrencyValidator = planDescCurrency.Validators[0].(func(string) error) + // planDescIsCustom is the schema descriptor for is_custom field. + planDescIsCustom := planFields[4].Descriptor() + // plan.DefaultIsCustom holds the default value on creation for the is_custom field. + plan.DefaultIsCustom = planDescIsCustom.Default.(bool) // planDescID is the schema descriptor for id field. planDescID := planMixinFields0[0].Descriptor() // plan.DefaultID holds the default value on creation for the id field. diff --git a/openmeter/ent/schema/productcatalog.go b/openmeter/ent/schema/productcatalog.go index ab1223aa49..b92f033743 100644 --- a/openmeter/ent/schema/productcatalog.go +++ b/openmeter/ent/schema/productcatalog.go @@ -38,6 +38,9 @@ func (Plan) Fields() []ent.Field { field.Time("effective_to"). Optional(). Nillable(), + field.Bool("is_custom"). + Default(false). + Comment("Whether this is a custom plan created for a specific customer."), } } diff --git a/openmeter/productcatalog/plan/adapter/plan.go b/openmeter/productcatalog/plan/adapter/plan.go index ed9694b683..47af696a02 100644 --- a/openmeter/productcatalog/plan/adapter/plan.go +++ b/openmeter/productcatalog/plan/adapter/plan.go @@ -6,7 +6,6 @@ import ( "slices" "entgo.io/ent/dialect/sql" - "entgo.io/ent/dialect/sql/sqljson" entdb "github.com/openmeterio/openmeter/openmeter/ent/db" plandb "github.com/openmeterio/openmeter/openmeter/ent/db/plan" @@ -60,10 +59,8 @@ func (a *adapter) ListPlans(ctx context.Context, params plan.ListPlansInput) (pa query = query.Where(plandb.DeletedAtIsNil()) } - // Filter out custom plans from the list (plans with custom plan metadata) - query = query.Where(func(s *sql.Selector) { - s.Where(sql.Not(sqljson.HasKey(plandb.FieldMetadata, sqljson.Path(plan.MetadataKeyCustomPlan)))) - }) + // Filter out custom plans from the list + query = query.Where(plandb.IsCustom(false)) if len(params.Status) > 0 { var predicates []predicate.Plan @@ -188,6 +185,7 @@ func (a *adapter) CreatePlan(ctx context.Context, params plan.CreatePlanInput) ( SetBillablesMustAlign(params.BillablesMustAlign). SetMetadata(params.Metadata). SetVersion(params.Version). + SetIsCustom(params.IsCustom). Save(ctx) if err != nil { return nil, fmt.Errorf("failed to create Plan: %w", err) diff --git a/openmeter/productcatalog/plan/plan.go b/openmeter/productcatalog/plan/plan.go index 7006fe090e..206cbedc46 100644 --- a/openmeter/productcatalog/plan/plan.go +++ b/openmeter/productcatalog/plan/plan.go @@ -56,9 +56,8 @@ func (p Plan) AsProductCatalogPlan() productcatalog.Plan { } } -const ( - MetadataKeyCustomPlan = "openmeter.custom_plan" - // TODO(tothandras): add base plan key and version that was customized - // MetadataKeyCustomPlanBasePlanKey = "openmeter.custom_plan.base_plan_key" - // MetadataKeyCustomPlanBasePlanVersion = "openmeter.custom_plan.base_plan_version" -) +// TODO(tothandras): add base plan key and version that was customized +// const ( +// MetadataKeyCustomPlanBasePlanKey = "openmeter.custom_plan.base_plan_key" +// MetadataKeyCustomPlanBasePlanVersion = "openmeter.custom_plan.base_plan_version" +// ) diff --git a/openmeter/productcatalog/plan/service.go b/openmeter/productcatalog/plan/service.go index bbc8ab403b..536e67bb72 100644 --- a/openmeter/productcatalog/plan/service.go +++ b/openmeter/productcatalog/plan/service.go @@ -98,6 +98,9 @@ var _ models.Validator = (*CreatePlanInput)(nil) type CreatePlanInput struct { models.NamespacedModel productcatalog.Plan + + // IsCustom indicates whether this is a custom plan created for a specific customer + IsCustom bool } func (i CreatePlanInput) Validate() error { diff --git a/openmeter/productcatalog/subscription/http/mapping.go b/openmeter/productcatalog/subscription/http/mapping.go index 8bcdafe25e..831b026992 100644 --- a/openmeter/productcatalog/subscription/http/mapping.go +++ b/openmeter/productcatalog/subscription/http/mapping.go @@ -445,8 +445,6 @@ func MapSubscriptionViewToAPI(view subscription.SubscriptionView) (api.Subscript // AsCustomPlanCreateInput converts API custom plan input to plan creation input func AsCustomPlanCreateInput(customPlan api.CustomPlanInput, namespace string) (plan.CreatePlanInput, error) { metadata := lo.FromPtrOr(customPlan.Metadata, make(map[string]string)) - // TODO(tothandras): use annotations instead - metadata[plan.MetadataKeyCustomPlan] = "true" planInput := plan.CreatePlanInput{ NamespacedModel: models.NamespacedModel{ @@ -461,6 +459,7 @@ func AsCustomPlanCreateInput(customPlan api.CustomPlanInput, namespace string) ( Metadata: metadata, }, }, + IsCustom: true, } // Set alignment if provided diff --git a/tools/migrate/migrations/20250526111543_plan-is-custom.down.sql b/tools/migrate/migrations/20250526111543_plan-is-custom.down.sql new file mode 100644 index 0000000000..f39c9ee1b0 --- /dev/null +++ b/tools/migrate/migrations/20250526111543_plan-is-custom.down.sql @@ -0,0 +1,2 @@ +-- reverse: modify "plans" table +ALTER TABLE "plans" DROP COLUMN "is_custom"; diff --git a/tools/migrate/migrations/20250526111543_plan-is-custom.up.sql b/tools/migrate/migrations/20250526111543_plan-is-custom.up.sql new file mode 100644 index 0000000000..6253cd0c4e --- /dev/null +++ b/tools/migrate/migrations/20250526111543_plan-is-custom.up.sql @@ -0,0 +1,2 @@ +-- modify "plans" table +ALTER TABLE "plans" ADD COLUMN "is_custom" boolean NOT NULL DEFAULT false; diff --git a/tools/migrate/migrations/atlas.sum b/tools/migrate/migrations/atlas.sum index 867511c891..ea8047d895 100644 --- a/tools/migrate/migrations/atlas.sum +++ b/tools/migrate/migrations/atlas.sum @@ -1,4 +1,4 @@ -h1:OHFtggoL5mkmeqgXZuWgj7C3qWmVM35zGB0am6ggGSE= +h1:fntad3cdJtxTy+rasaWLeS9ShQ9NLWBQ38obwWl2r64= 20240826120919_init.up.sql h1:tc1V91/smlmaeJGQ8h+MzTEeFjjnrrFDbDAjOYJK91o= 20240903155435_entitlement-expired-index.up.sql h1:Hp8u5uckmLXc1cRvWU0AtVnnK8ShlpzZNp8pbiJLhac= 20240917172257_billing-entities.up.sql h1:Q1dAMo0Vjiit76OybClNfYPGC5nmvov2/M2W1ioi4Kw= @@ -96,3 +96,4 @@ h1:OHFtggoL5mkmeqgXZuWgj7C3qWmVM35zGB0am6ggGSE= 20250512174316_billing_profile_workflow_config_tax.up.sql h1:I0YdN4Ae13aknoW1H7SVIJKkthapK3AUE/vLZyiaNA0= 20250516090128_billing-invoice-snapshotted-at.up.sql h1:yp+/b5MSkqrWxuje1RkNdR/VxImDENZPnqg6q4+kjJ4= 20250522155754_billing-line-index.up.sql h1:2QRByVILhe0MWM2zDtdhiflw4uaC1smyQQuurlNqjXY= +20250526111543_plan-is-custom.up.sql h1:UP5zUDLkqANh4UvmpHVumBsezw8jB+vNPEWtqNAr7pQ=