Skip to content

Commit d8d8562

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

File tree

17 files changed

+443
-350
lines changed

17 files changed

+443
-350
lines changed

e2e/productcatalog_test.go

Lines changed: 76 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,75 @@ 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+
// Create a new customer for this test to avoid conflicts
784+
customer4APIRes, err := client.CreateCustomerWithResponse(ctx, api.CreateCustomerJSONRequestBody{
785+
Name: "Test Customer 4",
786+
Currency: lo.ToPtr(api.CurrencyCode("USD")),
787+
Description: lo.ToPtr("Test Customer Description"),
788+
PrimaryEmail: lo.ToPtr("[email protected]"),
789+
BillingAddress: &api.Address{
790+
City: lo.ToPtr("City"),
791+
Country: lo.ToPtr("US"),
792+
Line1: lo.ToPtr("Line 1"),
793+
Line2: lo.ToPtr("Line 2"),
794+
State: lo.ToPtr("State"),
795+
PhoneNumber: lo.ToPtr("1234567890"),
796+
PostalCode: lo.ToPtr("12345"),
797+
},
798+
UsageAttribution: api.CustomerUsageAttribution{
799+
SubjectKeys: []string{"test_customer_subject_4"},
800+
},
801+
})
802+
require.Nil(t, err)
803+
require.Equal(t, 201, customer4APIRes.StatusCode(), "received the following body: %s", customer4APIRes.Body)
804+
customer4 := customer4APIRes.JSON201
805+
require.NotNil(t, customer4)
806+
807+
body := api.CreateSubscriptionJSONRequestBody{}
808+
809+
ct := &api.SubscriptionTiming{}
810+
require.NoError(t, ct.FromSubscriptionTiming1(startTime))
811+
812+
body.FromCustomSubscriptionCreate(api.CustomSubscriptionCreate{
813+
CustomerId: lo.ToPtr(customer4.Id),
814+
CustomPlan: customPlanInput,
815+
Timing: ct,
816+
})
817+
// Create a subscription with a custom plan
818+
customSubAPIRes, err := client.CreateSubscriptionWithResponse(ctx, body)
819+
require.Nil(t, err)
820+
require.Equal(t, 201, customSubAPIRes.StatusCode(), "received the following body: %s", customSubAPIRes.Body)
821+
822+
// List all plans - custom plans should be filtered out
823+
listPlansAPIRes, err := client.ListPlansWithResponse(ctx, &api.ListPlansParams{})
824+
require.Nil(t, err)
825+
require.Equal(t, 200, listPlansAPIRes.StatusCode(), "received the following body: %s", listPlansAPIRes.Body)
826+
827+
plans := listPlansAPIRes.JSON200
828+
require.NotNil(t, plans)
829+
require.NotNil(t, plans.Items)
830+
831+
// Verify that all returned plans are not custom plans
832+
for _, plan := range plans.Items {
833+
if plan.Metadata != nil {
834+
metadata := lo.FromPtrOr(plan.Metadata, map[string]string{})
835+
customPlanValue, exists := metadata["openmeter.custom_plan"]
836+
assert.False(t, exists && customPlanValue == "true",
837+
"Plan %s should not be a custom plan in the list response", plan.Id)
838+
}
839+
}
840+
841+
// Verify that non-custom plans are still in the list
842+
planFound := false
843+
for _, plan := range plans.Items {
844+
t.Logf("Found plan: key=%s, version=%d, status=%s", plan.Key, plan.Version, plan.Status)
845+
if plan.Key == PlanKey {
846+
planFound = true
847+
break
848+
}
849+
}
850+
assert.True(t, planFound, "Non-custom plans should still be in the list")
851+
})
779852
}

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)