Skip to content

Commit cf337b6

Browse files
committed
feat(backend): consolidate subscription creation by persisting custom plans to the database
1 parent e281913 commit cf337b6

File tree

16 files changed

+409
-368
lines changed

16 files changed

+409
-368
lines changed

e2e/productcatalog_test.go

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@ import (
44
"net/http"
55
"slices"
66
"testing"
7-
"time"
87

98
"github.com/samber/lo"
109
"github.com/stretchr/testify/assert"
1110
"github.com/stretchr/testify/require"
1211
"golang.org/x/net/context"
1312

1413
api "github.com/openmeterio/openmeter/api/client/go"
14+
"github.com/openmeterio/openmeter/pkg/clock"
1515
"github.com/openmeterio/openmeter/pkg/models"
1616
)
1717

@@ -397,7 +397,7 @@ func TestPlan(t *testing.T) {
397397
assert.Equal(t, 200, publishRes.StatusCode(), "received the following body: %s", publishRes.Body)
398398
})
399399

400-
startTime := time.Now()
400+
startTime := clock.Now()
401401

402402
var subscriptionId string
403403
var customSubscriptionId string
@@ -426,7 +426,9 @@ func TestPlan(t *testing.T) {
426426
require.NotNil(t, subscription)
427427
require.NotNil(t, subscription.Id)
428428
assert.Equal(t, api.SubscriptionStatusActive, subscription.Status)
429-
assert.Nil(t, subscription.Plan)
429+
// Custom subscriptions now have a plan reference since we create and publish the custom plan
430+
assert.NotNil(t, subscription.Plan)
431+
assert.Contains(t, subscription.Plan.Key, "custom_plan_")
430432

431433
customSubscriptionId = subscription.Id
432434
})
@@ -776,4 +778,51 @@ func TestPlan(t *testing.T) {
776778
require.NotNil(t, res.JSON200.Entitlements[PlanFeatureKey])
777779
require.True(t, res.JSON200.Entitlements[PlanFeatureKey].HasAccess)
778780
})
781+
782+
t.Run("Should filter out custom plans from list", func(t *testing.T) {
783+
body := api.CreateSubscriptionJSONRequestBody{}
784+
785+
ct := &api.SubscriptionTiming{}
786+
require.NoError(t, ct.FromSubscriptionTiming1(startTime))
787+
788+
body.FromCustomSubscriptionCreate(api.CustomSubscriptionCreate{
789+
CustomerId: lo.ToPtr(customer1.Id),
790+
CustomPlan: customPlanInput,
791+
Timing: ct,
792+
})
793+
// Create a subscription with a custom plan
794+
customSubAPIRes, err := client.CreateSubscriptionWithResponse(ctx, body)
795+
require.Nil(t, err)
796+
require.Equal(t, 201, customSubAPIRes.StatusCode(), "received the following body: %s", customSubAPIRes.Body)
797+
798+
// List all plans - custom plans should be filtered out
799+
listPlansAPIRes, err := client.ListPlansWithResponse(ctx, &api.ListPlansParams{})
800+
require.Nil(t, err)
801+
require.Equal(t, 200, listPlansAPIRes.StatusCode(), "received the following body: %s", listPlansAPIRes.Body)
802+
803+
plans := listPlansAPIRes.JSON200
804+
require.NotNil(t, plans)
805+
require.NotNil(t, plans.Items)
806+
807+
// Verify that all returned plans are not custom plans
808+
for _, plan := range plans.Items {
809+
if plan.Metadata != nil {
810+
metadata := lo.FromPtrOr(plan.Metadata, map[string]string{})
811+
customPlanValue, exists := metadata["openmeter.custom_plan"]
812+
assert.False(t, exists && customPlanValue == "true",
813+
"Plan %s should not be a custom plan in the list response", plan.Id)
814+
}
815+
}
816+
817+
// Verify that non-custom plans are still in the list
818+
planFound := false
819+
for _, plan := range plans.Items {
820+
t.Logf("Found plan: key=%s, version=%d, status=%s", plan.Key, plan.Version, plan.Status)
821+
if plan.Key == PlanKey {
822+
planFound = true
823+
break
824+
}
825+
}
826+
assert.True(t, planFound, "Non-custom plans should still be in the list")
827+
})
779828
}

openmeter/productcatalog/plan/adapter/plan.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"slices"
77

88
"entgo.io/ent/dialect/sql"
9+
"entgo.io/ent/dialect/sql/sqljson"
910

1011
entdb "github.com/openmeterio/openmeter/openmeter/ent/db"
1112
plandb "github.com/openmeterio/openmeter/openmeter/ent/db/plan"
@@ -59,6 +60,11 @@ func (a *adapter) ListPlans(ctx context.Context, params plan.ListPlansInput) (pa
5960
query = query.Where(plandb.DeletedAtIsNil())
6061
}
6162

63+
// Filter out custom plans from the list (plans with custom plan metadata)
64+
query = query.Where(func(s *sql.Selector) {
65+
s.Where(sql.Not(sqljson.HasKey(plandb.FieldMetadata, sqljson.Path(plan.MetadataKeyCustomPlan))))
66+
})
67+
6268
if len(params.Status) > 0 {
6369
var predicates []predicate.Plan
6470

openmeter/productcatalog/plan/plan.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,10 @@ func (p Plan) AsProductCatalogPlan() productcatalog.Plan {
5555
Phases: lo.Map(p.Phases, func(phase Phase, _ int) productcatalog.Phase { return phase.AsProductCatalogPhase() }),
5656
}
5757
}
58+
59+
const (
60+
MetadataKeyCustomPlan = "openmeter.custom_plan"
61+
// TODO(tothandras): add base plan key and version that was customized
62+
// MetadataKeyCustomPlanBasePlanKey = "openmeter.custom_plan.base_plan_key"
63+
// MetadataKeyCustomPlanBasePlanVersion = "openmeter.custom_plan.base_plan_version"
64+
)

openmeter/productcatalog/subscription/http/change.go

Lines changed: 65 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@ package httpdriver
22

33
import (
44
"context"
5-
"encoding/json"
65
"fmt"
76
"net/http"
87

98
"github.com/samber/lo"
109

1110
"github.com/openmeterio/openmeter/api"
11+
"github.com/openmeterio/openmeter/openmeter/productcatalog"
12+
"github.com/openmeterio/openmeter/openmeter/productcatalog/plan"
1213
plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription"
1314
subscriptionworkflow "github.com/openmeterio/openmeter/openmeter/subscription/workflow"
15+
"github.com/openmeterio/openmeter/pkg/clock"
1416
"github.com/openmeterio/openmeter/pkg/convert"
1517
"github.com/openmeterio/openmeter/pkg/framework/commonhttp"
1618
"github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
@@ -37,90 +39,94 @@ func (h *handler) ChangeSubscription() ChangeSubscriptionHandler {
3739

3840
ns, err := h.resolveNamespace(ctx)
3941
if err != nil {
40-
return ChangeSubscriptionRequest{}, err
41-
}
42-
43-
// Any transformation function generated by the API will succeed if the body is serializable, so we have to check for the presence of
44-
// fields to determine what body type we're dealing with
45-
type testForCustomPlan struct {
46-
CustomPlan any `json:"customPlan"`
42+
return ChangeSubscriptionRequest{}, fmt.Errorf("failed to resolve namespace: %w", err)
4743
}
4844

49-
var t testForCustomPlan
45+
workflowInput := subscriptionworkflow.ChangeSubscriptionWorkflowInput{}
5046

51-
bodyBytes, err := json.Marshal(body)
52-
if err != nil {
53-
return ChangeSubscriptionRequest{}, fmt.Errorf("failed to marshal request body: %w", err)
54-
}
47+
var planInput plansubscription.PlanInput
48+
var startingPhase *string
5549

56-
if err := json.Unmarshal(bodyBytes, &t); err != nil {
57-
return ChangeSubscriptionRequest{}, fmt.Errorf("failed to unmarshal request body: %w", err)
58-
}
50+
// Try to parse as custom subscription change
51+
if b, err := body.AsCustomSubscriptionChange(); err == nil {
52+
// Convert API input to plan creation input using the mapping function
53+
createPlanInput, err := AsCustomPlanCreateInput(b.CustomPlan, ns)
54+
if err != nil {
55+
return ChangeSubscriptionRequest{}, fmt.Errorf("failed to convert custom plan: %w", err)
56+
}
5957

60-
if t.CustomPlan != nil {
61-
// Changing to a custom Plan
62-
parsedBody, err := body.AsCustomSubscriptionChange()
58+
// Create the custom plan and set the reference to it in the plan input
59+
customPlan, err := h.PlanService.CreatePlan(ctx, createPlanInput)
6360
if err != nil {
64-
return ChangeSubscriptionRequest{}, fmt.Errorf("failed to parse custom plan: %w", err)
61+
return ChangeSubscriptionRequest{}, fmt.Errorf("failed to create custom plan: %w", err)
6562
}
6663

67-
req, err := CustomPlanToCreatePlanRequest(parsedBody.CustomPlan, ns)
64+
// Publish the custom plan to make it active
65+
effectiveFrom := createPlanInput.EffectiveFrom
66+
if effectiveFrom == nil {
67+
effectiveFrom = lo.ToPtr(clock.Now())
68+
}
69+
customPlan, err = h.PlanService.PublishPlan(ctx, plan.PublishPlanInput{
70+
NamespacedID: customPlan.NamespacedID,
71+
EffectivePeriod: productcatalog.EffectivePeriod{
72+
EffectiveFrom: effectiveFrom,
73+
EffectiveTo: createPlanInput.EffectiveTo,
74+
},
75+
})
6876
if err != nil {
69-
return ChangeSubscriptionRequest{}, fmt.Errorf("failed to create plan request: %w", err)
77+
return ChangeSubscriptionRequest{}, fmt.Errorf("failed to publish custom plan: %w", err)
7078
}
7179

72-
planInp := plansubscription.PlanInput{}
73-
planInp.FromInput(&req)
80+
planInput.FromRef(&plansubscription.PlanRefInput{
81+
Key: customPlan.Key,
82+
Version: &customPlan.Version,
83+
})
7484

75-
timing, err := MapAPITimingToTiming(parsedBody.Timing)
85+
subscriptionTiming, err := MapAPITimingToTiming(b.Timing)
7686
if err != nil {
7787
return ChangeSubscriptionRequest{}, fmt.Errorf("failed to map timing: %w", err)
7888
}
7989

80-
return ChangeSubscriptionRequest{
81-
ID: models.NamespacedID{Namespace: ns, ID: params.ID},
82-
PlanInput: planInp,
83-
WorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
84-
Timing: timing,
85-
Name: req.Name,
86-
Description: req.Description,
87-
MetadataModel: models.MetadataModel{
88-
Metadata: req.Metadata,
89-
},
90+
workflowInput = subscriptionworkflow.ChangeSubscriptionWorkflowInput{
91+
Timing: subscriptionTiming,
92+
Name: b.CustomPlan.Name,
93+
Description: b.CustomPlan.Description,
94+
MetadataModel: models.MetadataModel{
95+
Metadata: convert.DerefHeaderPtr[string](b.CustomPlan.Metadata),
9096
},
91-
}, nil
92-
} else {
93-
// Changing to a Plan
94-
parsedBody, err := body.AsPlanSubscriptionChange()
95-
if err != nil {
96-
return ChangeSubscriptionRequest{}, fmt.Errorf("failed to parse plan: %w", err)
9797
}
98-
99-
planInp := plansubscription.PlanInput{}
100-
planInp.FromRef(&plansubscription.PlanRefInput{
101-
Key: parsedBody.Plan.Key,
102-
Version: parsedBody.Plan.Version,
98+
// Try to parse as plan subscription change
99+
} else if b, err := body.AsPlanSubscriptionChange(); err == nil {
100+
planInput.FromRef(&plansubscription.PlanRefInput{
101+
Key: b.Plan.Key,
102+
Version: b.Plan.Version,
103103
})
104104

105-
timing, err := MapAPITimingToTiming(parsedBody.Timing)
105+
subscriptionTiming, err := MapAPITimingToTiming(b.Timing)
106106
if err != nil {
107107
return ChangeSubscriptionRequest{}, fmt.Errorf("failed to map timing: %w", err)
108108
}
109109

110-
return ChangeSubscriptionRequest{
111-
ID: models.NamespacedID{Namespace: ns, ID: params.ID},
112-
PlanInput: planInp,
113-
WorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
114-
Timing: timing,
115-
MetadataModel: models.MetadataModel{
116-
Metadata: convert.DerefHeaderPtr[string](parsedBody.Metadata),
117-
},
118-
Name: lo.FromPtr(parsedBody.Name),
119-
Description: parsedBody.Description,
110+
startingPhase = b.StartingPhase
111+
112+
workflowInput = subscriptionworkflow.ChangeSubscriptionWorkflowInput{
113+
Timing: subscriptionTiming,
114+
Name: lo.FromPtr(b.Name),
115+
Description: b.Description,
116+
MetadataModel: models.MetadataModel{
117+
Metadata: convert.DerefHeaderPtr[string](b.Metadata),
120118
},
121-
StartingPhase: parsedBody.StartingPhase,
122-
}, nil
119+
}
120+
} else {
121+
return ChangeSubscriptionRequest{}, models.NewGenericValidationError(fmt.Errorf("invalid request body"))
123122
}
123+
124+
return ChangeSubscriptionRequest{
125+
ID: models.NamespacedID{Namespace: ns, ID: params.ID},
126+
PlanInput: planInput,
127+
WorkflowInput: workflowInput,
128+
StartingPhase: startingPhase,
129+
}, nil
124130
},
125131
func(ctx context.Context, request ChangeSubscriptionRequest) (ChangeSubscriptionResponse, error) {
126132
res, err := h.PlanSubscriptionService.Change(ctx, request)

0 commit comments

Comments
 (0)