diff --git a/cmd/server/main.go b/cmd/server/main.go index 690077e52..df8500f51 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -42,6 +42,7 @@ import ( plan "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" planadapter "github.com/openmeterio/openmeter/openmeter/productcatalog/plan/adapter" planservice "github.com/openmeterio/openmeter/openmeter/productcatalog/plan/service" + plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" "github.com/openmeterio/openmeter/openmeter/registry" registrybuilder "github.com/openmeterio/openmeter/openmeter/registry/builder" secretadapter "github.com/openmeterio/openmeter/openmeter/secret/adapter" @@ -51,7 +52,6 @@ import ( "github.com/openmeterio/openmeter/openmeter/server/router" "github.com/openmeterio/openmeter/openmeter/subscription" subscriptionentitlement "github.com/openmeterio/openmeter/openmeter/subscription/adapters/entitlement" - subscriptionplan "github.com/openmeterio/openmeter/openmeter/subscription/adapters/plan" subscriptionrepo "github.com/openmeterio/openmeter/openmeter/subscription/repo" subscriptionservice "github.com/openmeterio/openmeter/openmeter/subscription/service" "github.com/openmeterio/openmeter/pkg/errorsx" @@ -358,6 +358,7 @@ func main() { // Initialize subscriptions var subscriptionService subscription.Service var subscriptionWorkflowService subscription.WorkflowService + var planSubscriptionAdapter plansubscription.Adapter if conf.ProductCatalog.Enabled { subscriptionRepo := subscriptionrepo.NewSubscriptionRepo(app.EntClient) subscriptionPhaseRepo := subscriptionrepo.NewSubscriptionPhaseRepo(app.EntClient) @@ -369,7 +370,7 @@ func main() { subscriptionItemRepo, ) - subscriptionPlanAdapter := subscriptionplan.NewSubscriptionPlanAdapter(subscriptionplan.PlanSubscriptionAdapterConfig{ + planSubscriptionAdapter = plansubscription.NewPlanSubscriptionAdapter(plansubscription.PlanSubscriptionAdapterConfig{ PlanService: planService, Logger: logger.With("subsystem", "subscription.plan.adapter"), }) @@ -386,7 +387,6 @@ func main() { subscriptionWorkflowService = subscriptionservice.NewWorkflowService(subscriptionservice.WorkflowServiceConfig{ Service: subscriptionService, CustomerService: customerService, - PlanAdapter: subscriptionPlanAdapter, TransactionManager: subscriptionRepo, }) } @@ -437,6 +437,7 @@ func main() { EntitlementConnector: entitlementConnRegistry.Entitlement, SubscriptionService: subscriptionService, SubscriptionWorkflowService: subscriptionWorkflowService, + SubscriptionPlanAdapter: planSubscriptionAdapter, Logger: logger, FeatureConnector: entitlementConnRegistry.Feature, GrantConnector: entitlementConnRegistry.Grant, diff --git a/e2e/productcatalog_test.go b/e2e/productcatalog_test.go index 93094728c..ed1141c9b 100644 --- a/e2e/productcatalog_test.go +++ b/e2e/productcatalog_test.go @@ -24,12 +24,12 @@ func TestPlan(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Let's set up a customer + // Let's set up two customers customerAPIRes, err := client.CreateCustomerWithResponse(ctx, api.CreateCustomerJSONRequestBody{ - Name: "Test Customer", + Name: "Test Customer 1", Currency: lo.ToPtr(api.CurrencyCode("USD")), Description: lo.ToPtr("Test Customer Description"), - PrimaryEmail: lo.ToPtr("customer@mail.com"), + PrimaryEmail: lo.ToPtr("customer1@mail.com"), Timezone: lo.ToPtr(time.UTC.String()), BillingAddress: &api.Address{ City: lo.ToPtr("City"), @@ -41,13 +41,37 @@ func TestPlan(t *testing.T) { PostalCode: lo.ToPtr("12345"), }, UsageAttribution: api.CustomerUsageAttribution{ - SubjectKeys: []string{"test_customer_subject"}, + SubjectKeys: []string{"test_customer_subject_1"}, }, }) require.Nil(t, err) - customer := customerAPIRes.JSON201 - require.NotNil(t, customer) + customer1 := customerAPIRes.JSON201 + require.NotNil(t, customer1) + + customerAPIRes, err = client.CreateCustomerWithResponse(ctx, api.CreateCustomerJSONRequestBody{ + Name: "Test Customer 2", + Currency: lo.ToPtr(api.CurrencyCode("USD")), + Description: lo.ToPtr("Test Customer Description"), + PrimaryEmail: lo.ToPtr("customer2@mail.com"), + Timezone: lo.ToPtr(time.UTC.String()), + 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_2"}, + }, + }) + require.Nil(t, err) + + customer2 := customerAPIRes.JSON201 + require.NotNil(t, customer1) // Now, let's create dedicated features for the plan featureAPIRes, err := client.CreateFeatureWithResponse(ctx, api.CreateFeatureJSONRequestBody{ @@ -71,115 +95,118 @@ func TestPlan(t *testing.T) { var planId string - t.Run("Should create a plan on happy path", func(t *testing.T) { - p1RC1 := api.RateCard{} - err := p1RC1.FromRateCardFlatFee(api.RateCardFlatFee{ - Name: "Test Plan Phase 1 Rate Card 1", - Description: lo.ToPtr("Has a one time flat price like an installation fee"), - Key: "test_plan_phase_1_rate_card_1", - TaxConfig: &api.TaxConfig{ - Stripe: &api.StripeTaxConfig{ - Code: "txcd_10000000", - }, - }, - Price: &api.FlatPriceWithPaymentTerm{ - Amount: "1000", - PaymentTerm: lo.ToPtr(api.PricePaymentTerm("in_advance")), - Type: api.FlatPriceWithPaymentTermType("flat"), + // Lets build a PlanCreate input + p1RC1 := api.RateCard{} + err = p1RC1.FromRateCardFlatFee(api.RateCardFlatFee{ + Name: "Test Plan Phase 1 Rate Card 1", + Description: lo.ToPtr("Has a one time flat price like an installation fee"), + Key: "test_plan_phase_1_rate_card_1", + TaxConfig: &api.TaxConfig{ + Stripe: &api.StripeTaxConfig{ + Code: "txcd_10000000", }, - BillingCadence: nil, - Type: api.RateCardFlatFeeType("flat"), - }) - require.Nil(t, err) + }, + Price: &api.FlatPriceWithPaymentTerm{ + Amount: "1000", + PaymentTerm: lo.ToPtr(api.PricePaymentTerm("in_advance")), + Type: api.FlatPriceWithPaymentTermType("flat"), + }, + BillingCadence: nil, + Type: api.RateCardFlatFeeType("flat"), + }) + require.Nil(t, err) - p1RC2 := api.RateCard{} - err = p1RC2.FromRateCardFlatFee(api.RateCardFlatFee{ - Name: "Test Plan Phase 1 Rate Card 2", - Description: lo.ToPtr("Has a monthly recurring price to grant access to a feature"), - Key: PlanFeatureKey, - FeatureKey: lo.ToPtr(PlanFeatureKey), - TaxConfig: &api.TaxConfig{ - Stripe: &api.StripeTaxConfig{ - Code: "txcd_10000000", - }, - }, - Price: &api.FlatPriceWithPaymentTerm{ - Amount: "1000", - PaymentTerm: lo.ToPtr(api.PricePaymentTerm("in_advance")), - Type: api.FlatPriceWithPaymentTermType("flat"), + p1RC2 := api.RateCard{} + err = p1RC2.FromRateCardFlatFee(api.RateCardFlatFee{ + Name: "Test Plan Phase 1 Rate Card 2", + Description: lo.ToPtr("Has a monthly recurring price to grant access to a feature"), + Key: PlanFeatureKey, + FeatureKey: lo.ToPtr(PlanFeatureKey), + TaxConfig: &api.TaxConfig{ + Stripe: &api.StripeTaxConfig{ + Code: "txcd_10000000", }, - BillingCadence: lo.ToPtr("P1M"), - Type: api.RateCardFlatFeeType("flat"), - }) - require.Nil(t, err) + }, + Price: &api.FlatPriceWithPaymentTerm{ + Amount: "1000", + PaymentTerm: lo.ToPtr(api.PricePaymentTerm("in_advance")), + Type: api.FlatPriceWithPaymentTermType("flat"), + }, + BillingCadence: lo.ToPtr("P1M"), + Type: api.RateCardFlatFeeType("flat"), + }) + require.Nil(t, err) - p2RC1 := api.RateCard{} - err = p2RC1.FromRateCardFlatFee(api.RateCardFlatFee{ - Name: "Test Plan Phase 2 Rate Card 1", - Description: lo.ToPtr("Keeps access to the same feature as in phase 1"), - Key: PlanFeatureKey, - FeatureKey: lo.ToPtr(PlanFeatureKey), - TaxConfig: &api.TaxConfig{ - Stripe: &api.StripeTaxConfig{ - Code: "txcd_10000000", - }, + p2RC1 := api.RateCard{} + err = p2RC1.FromRateCardFlatFee(api.RateCardFlatFee{ + Name: "Test Plan Phase 2 Rate Card 1", + Description: lo.ToPtr("Keeps access to the same feature as in phase 1"), + Key: PlanFeatureKey, + FeatureKey: lo.ToPtr(PlanFeatureKey), + TaxConfig: &api.TaxConfig{ + Stripe: &api.StripeTaxConfig{ + Code: "txcd_10000000", }, - Price: &api.FlatPriceWithPaymentTerm{ - Amount: "1000", - PaymentTerm: lo.ToPtr(api.PricePaymentTerm("in_advance")), - Type: api.FlatPriceWithPaymentTermType("flat"), - }, - BillingCadence: lo.ToPtr("P1M"), - Type: api.RateCardFlatFeeType("flat"), - }) - require.Nil(t, err) + }, + Price: &api.FlatPriceWithPaymentTerm{ + Amount: "1000", + PaymentTerm: lo.ToPtr(api.PricePaymentTerm("in_advance")), + Type: api.FlatPriceWithPaymentTermType("flat"), + }, + BillingCadence: lo.ToPtr("P1M"), + Type: api.RateCardFlatFeeType("flat"), + }) + require.Nil(t, err) - p2RC2P := api.RateCardUsageBasedPrice{} - err = p2RC2P.FromUnitPriceWithCommitments(api.UnitPriceWithCommitments{ - Amount: "0.1", - Type: api.UnitPriceWithCommitmentsType("unit"), - }) - require.Nil(t, err) + p2RC2P := api.RateCardUsageBasedPrice{} + err = p2RC2P.FromUnitPriceWithCommitments(api.UnitPriceWithCommitments{ + Amount: "0.1", + Type: api.UnitPriceWithCommitmentsType("unit"), + }) + require.Nil(t, err) - p2RC2 := api.RateCard{} - err = p2RC2.FromRateCardUsageBased(api.RateCardUsageBased{ - Name: "Test Plan Phase 2 Rate Card 2", - Description: lo.ToPtr("Adds a usage based price for the metered feature"), - Key: PlanMeteredFeatureKey, - FeatureKey: lo.ToPtr(PlanMeteredFeatureKey), - TaxConfig: &api.TaxConfig{ - Stripe: &api.StripeTaxConfig{ - Code: "txcd_10000000", - }, + p2RC2 := api.RateCard{} + err = p2RC2.FromRateCardUsageBased(api.RateCardUsageBased{ + Name: "Test Plan Phase 2 Rate Card 2", + Description: lo.ToPtr("Adds a usage based price for the metered feature"), + Key: PlanMeteredFeatureKey, + FeatureKey: lo.ToPtr(PlanMeteredFeatureKey), + TaxConfig: &api.TaxConfig{ + Stripe: &api.StripeTaxConfig{ + Code: "txcd_10000000", }, - BillingCadence: "P1M", - Price: &p2RC2P, - Type: api.RateCardUsageBasedType("usage_based"), - }) - require.Nil(t, err) + }, + BillingCadence: "P1M", + Price: &p2RC2P, + Type: api.RateCardUsageBasedType("usage_based"), + }) + require.Nil(t, err) - planAPIRes, err := client.CreatePlanWithResponse(ctx, api.CreatePlanJSONRequestBody{ - Currency: api.CurrencyCode("USD"), - Name: "Test Plan", - Description: lo.ToPtr("Test Plan Description"), - Key: PlanKey, - Phases: []api.PlanPhase{ - { - Name: "Test Plan Phase 1", - Key: "test_plan_phase_1", - Description: lo.ToPtr("Test Plan Phase 1 Description"), - StartAfter: nil, // Ommittable for first phase - RateCards: []api.RateCard{p1RC1, p1RC2}, - }, - { - Name: "Test Plan Phase 2", - Key: "test_plan_phase_2", - Description: lo.ToPtr("Test Plan Phase 1 Description"), - StartAfter: lo.ToPtr("P2M"), - RateCards: []api.RateCard{p2RC1, p2RC2}, - }, + planCreate := api.PlanCreate{ + Currency: api.CurrencyCode("USD"), + Name: "Test Plan", + Description: lo.ToPtr("Test Plan Description"), + Key: PlanKey, + Phases: []api.PlanPhase{ + { + Name: "Test Plan Phase 1", + Key: "test_plan_phase_1", + Description: lo.ToPtr("Test Plan Phase 1 Description"), + StartAfter: nil, + RateCards: []api.RateCard{p1RC1, p1RC2}, }, - }) + { + Name: "Test Plan Phase 2", + Key: "test_plan_phase_2", + Description: lo.ToPtr("Test Plan Phase 1 Description"), + StartAfter: lo.ToPtr("P2M"), + RateCards: []api.RateCard{p2RC1, p2RC2}, + }, + }, + } + + t.Run("Should create a plan on happy path", func(t *testing.T) { + planAPIRes, err := client.CreatePlanWithResponse(ctx, planCreate) require.Nil(t, err) require.Equal(t, 201, planAPIRes.StatusCode()) @@ -209,14 +236,38 @@ func TestPlan(t *testing.T) { var subscriptionId string + t.Run("Should create a custom subscription", func(t *testing.T) { + require.NotNil(t, customer1) + require.NotNil(t, customer1.Id) + + create := api.SubscriptionCreate{} + err := create.FromCustomSubscriptionCreate(api.CustomSubscriptionCreate{ + ActiveFrom: startTime, + CustomerId: *customer2.Id, + CustomPlan: planCreate, // For simplicity we can reuse the same plan input, we know its valid + }) + require.Nil(t, err) + + apiRes, err := client.CreateSubscriptionWithResponse(ctx, create) + require.Nil(t, err) + + assert.Equal(t, 201, apiRes.StatusCode(), "received the following body: %s", apiRes.Body) + + subscription := apiRes.JSON201 + require.NotNil(t, subscription) + require.NotNil(t, subscription.Id) + assert.Equal(t, api.SubscriptionStatusActive, *subscription.Status) + assert.Nil(t, subscription.Plan) + }) + t.Run("Should create a subscription based on the plan", func(t *testing.T) { - require.NotNil(t, customer) - require.NotNil(t, customer.Id) + require.NotNil(t, customer1) + require.NotNil(t, customer1.Id) create := api.SubscriptionCreate{} err := create.FromPlanSubscriptionCreate(api.PlanSubscriptionCreate{ ActiveFrom: startTime, - CustomerId: *customer.Id, + CustomerId: *customer1.Id, Name: "Test Subscription", Description: lo.ToPtr("Test Subscription Description"), Plan: api.PlanReferenceInput{ @@ -226,7 +277,6 @@ func TestPlan(t *testing.T) { }) require.Nil(t, err) - // FIXME: why is this the generated type CreateSubscriptionJSONRequestBody? apiRes, err := client.CreateSubscriptionWithResponse(ctx, create) require.Nil(t, err) @@ -294,4 +344,30 @@ func TestPlan(t *testing.T) { assert.Equal(t, 200, apiRes.StatusCode(), "received the following body: %s", apiRes.Body) }) + + t.Run("Should schedule a cancellation for the subscription", func(t *testing.T) { + require.NotEmpty(t, subscriptionId) + + apiRes, err := client.CancelSubscriptionWithResponse(ctx, subscriptionId, api.CancelSubscriptionJSONRequestBody{ + EffectiveDate: lo.ToPtr(time.Now().Add(time.Hour).UTC()), + }) + require.Nil(t, err) + + assert.Equal(t, 200, apiRes.StatusCode(), "received the following body: %s", apiRes.Body) + + require.NotNil(t, apiRes.JSON200) + assert.Equal(t, api.SubscriptionStatusCanceled, *apiRes.JSON200.Status) + }) + + t.Run("Should unschedule cancellation", func(t *testing.T) { + require.NotEmpty(t, subscriptionId) + + apiRes, err := client.UnscheduleCancelationWithResponse(ctx, subscriptionId) + require.Nil(t, err) + + assert.Equal(t, 200, apiRes.StatusCode(), "received the following body: %s", apiRes.Body) + + require.NotNil(t, apiRes.JSON200) + assert.Equal(t, api.SubscriptionStatusActive, *apiRes.JSON200.Status) + }) } diff --git a/openmeter/productcatalog/plan.go b/openmeter/productcatalog/plan.go index 576c92f30..3210e20cd 100644 --- a/openmeter/productcatalog/plan.go +++ b/openmeter/productcatalog/plan.go @@ -68,6 +68,25 @@ func (p Plan) Validate() error { return nil } +// ValidForCreatingSubscriptions checks if the Plan is valid for creating Subscriptions, a stricter version of Validate +func (p Plan) ValidForCreatingSubscriptions() error { + if err := p.Validate(); err != nil { + return err + } + + if len(p.Phases) == 0 { + return fmt.Errorf("invalid Plan: at least one PlanPhase is required") + } + + if !lo.SomeBy(p.Phases, func(phase Phase) bool { + return phase.StartAfter.IsZero() + }) { + return fmt.Errorf("invalid Plan: there has to be a starting phase") + } + + return nil +} + var _ models.Validator = (*PlanMeta)(nil) type PlanMeta struct { diff --git a/openmeter/productcatalog/plan/phase.go b/openmeter/productcatalog/plan/phase.go index de34d68c2..c0bab2fda 100644 --- a/openmeter/productcatalog/plan/phase.go +++ b/openmeter/productcatalog/plan/phase.go @@ -107,6 +107,10 @@ func (p Phase) Validate() error { return nil } +func (p Phase) AsProductCatalogPhase() productcatalog.Phase { + return p.Phase +} + type SortPhasesFunc = func(left, right Phase) int var SortPhasesByStartAfter SortPhasesFunc = func(left, right Phase) int { diff --git a/openmeter/productcatalog/plan/plan.go b/openmeter/productcatalog/plan/plan.go index 6dcf71633..c975f6966 100644 --- a/openmeter/productcatalog/plan/plan.go +++ b/openmeter/productcatalog/plan/plan.go @@ -3,6 +3,9 @@ package plan import ( "errors" "fmt" + "time" + + "github.com/samber/lo" "github.com/openmeterio/openmeter/openmeter/productcatalog" "github.com/openmeterio/openmeter/pkg/datex" @@ -48,3 +51,21 @@ func (p Plan) Validate() error { return nil } + +func (p Plan) AsProductCatalogPlan(at time.Time) (productcatalog.Plan, error) { + // We filter out deleted resources. Its an interesting mind-bender why we'd have deleted resources in the first place... + // Let's start with the plan itself + if p.DeletedAt != nil && !at.Before(*p.DeletedAt) { + return productcatalog.Plan{}, errors.New("plan is deleted") + } + + // Then continue with the phases + phases := lo.Filter(p.Phases, func(phase Phase, _ int) bool { + return phase.DeletedAt == nil || at.After(*phase.DeletedAt) + }) + + return productcatalog.Plan{ + PlanMeta: p.PlanMeta, + Phases: lo.Map(phases, func(phase Phase, _ int) productcatalog.Phase { return phase.AsProductCatalogPhase() }), + }, nil +} diff --git a/openmeter/productcatalog/plan/service/plan.go b/openmeter/productcatalog/plan/service/plan.go index 69f0e574a..84dc907ee 100644 --- a/openmeter/productcatalog/plan/service/plan.go +++ b/openmeter/productcatalog/plan/service/plan.go @@ -9,6 +9,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/productcatalog" "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/framework/transaction" "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/pagination" @@ -313,43 +314,36 @@ func (s service) PublishPlan(ctx context.Context, params plan.PublishPlanInput) return nil, fmt.Errorf("failed to get Plan: %w", err) } - allowedPlanStatuses := []productcatalog.PlanStatus{ - productcatalog.DraftStatus, - productcatalog.ScheduledStatus, - } - planStatus := p.Status() - if !lo.Contains(allowedPlanStatuses, p.Status()) { - return nil, fmt.Errorf("invalid Plan: only Plans in %+v can be published/rescheduled, but it has %s state", allowedPlanStatuses, planStatus) - } - - // Check if there is at least one Phase available for Plan - if len(p.Phases) == 0 { - return nil, fmt.Errorf("invalid Plan: at least one PlanPhase is required") + pp, err := p.AsProductCatalogPlan(clock.Now()) + if err != nil { + return nil, fmt.Errorf("failed to convert Plan to ProductCatalog Plan: %w", err) } - // Check if there is at least one Phase with StartAfter set to P0D to ensure there is no leading gap in Plan lifecycle - var hasZeroStartAfter bool - for _, phase := range p.Phases { - if phase.DeletedAt == nil && phase.StartAfter.IsZero() { - hasZeroStartAfter = true + // First, let's validate that the a Subscription can successfully be created from this Plan - break - } + if err := pp.ValidForCreatingSubscriptions(); err != nil { + return nil, &models.GenericUserError{Message: fmt.Sprintf("invalid Plan for creating subscriptions: %s", err)} } - if !hasZeroStartAfter { - return nil, fmt.Errorf("invalid Plan: at least one PlanPhase with StartAfter set to 0 (P0D) is required") + // Second, let's validate that the plan status and the version history is correct + allowedPlanStatuses := []productcatalog.PlanStatus{ + productcatalog.DraftStatus, + productcatalog.ScheduledStatus, + } + planStatus := pp.Status() + if !lo.Contains(allowedPlanStatuses, pp.Status()) { + return nil, fmt.Errorf("invalid Plan: only Plans in %+v can be published/rescheduled, but it has %s state", allowedPlanStatuses, planStatus) } // Find and archive Plan version with plan.ActiveStatus if there is one. Only perform lookup if // the Plan to be published has higher version then 1 meaning that it has previous versions, // otherwise skip this step. - if p.Version > 1 { + if pp.Version > 1 { activePlan, err := s.adapter.GetPlan(ctx, plan.GetPlanInput{ NamespacedID: models.NamespacedID{ Namespace: params.Namespace, }, - Key: p.Key, + Key: pp.Key, }) if err != nil { if !plan.IsNotFound(err) { @@ -374,10 +368,7 @@ func (s service) PublishPlan(ctx context.Context, params plan.PublishPlanInput) // Publish new Plan version input := plan.UpdatePlanInput{ - NamespacedID: models.NamespacedID{ - Namespace: p.Namespace, - ID: p.ID, - }, + NamespacedID: params.NamespacedID, } if params.EffectiveFrom != nil { diff --git a/openmeter/productcatalog/subscription/adapter.go b/openmeter/productcatalog/subscription/adapter.go new file mode 100644 index 000000000..6791dfd65 --- /dev/null +++ b/openmeter/productcatalog/subscription/adapter.go @@ -0,0 +1,91 @@ +package plansubscription + +import ( + "context" + "fmt" + "log/slog" + + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + "github.com/openmeterio/openmeter/openmeter/subscription" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/defaultx" + "github.com/openmeterio/openmeter/pkg/models" +) + +type Adapter interface { + // GetPlan returns the plan for the Ref with all it's dependent resources. + // + // If the Plan is Not Found, it should return a PlanNotFoundError. + GetVersion(ctx context.Context, namespace string, ref 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 { + PlanService plan.Service + Logger *slog.Logger +} + +type adapter struct { + PlanSubscriptionAdapterConfig +} + +var _ Adapter = &adapter{} + +func NewPlanSubscriptionAdapter(config PlanSubscriptionAdapterConfig) Adapter { + return &adapter{config} +} + +func (a *adapter) GetVersion(ctx context.Context, namespace string, ref PlanRefInput) (subscription.Plan, error) { + planKey := ref.Key + version := defaultx.WithDefault(ref.Version, 0) // plan service treats 0 as special case + + p, err := a.PlanService.GetPlan(ctx, plan.GetPlanInput{ + NamespacedID: models.NamespacedID{ + Namespace: namespace, + }, + Key: planKey, + Version: version, + }) + + if _, ok := lo.ErrorsAs[plan.NotFoundError](err); ok { + return nil, subscription.PlanNotFoundError{ + Key: planKey, + Version: version, + } + } else if err != nil { + return nil, err + } + + if p == nil { + return nil, subscription.PlanNotFoundError{ + Key: planKey, + Version: version, + } + } + + pp, err := p.AsProductCatalogPlan(clock.Now()) + if err != nil { + return nil, err + } + + return &Plan{ + Plan: pp, + Ref: &p.NamespacedID, + }, nil +} + +func (a *adapter) FromInput(ctx context.Context, namespace string, input plan.CreatePlanInput) (subscription.Plan, error) { + p := input.Plan + + if err := p.ValidForCreatingSubscriptions(); err != nil { + return nil, &models.GenericUserError{Message: fmt.Sprintf("invalid plan: %v", err)} + } + + return &Plan{ + Plan: p, + }, nil +} diff --git a/openmeter/productcatalog/subscription/http/cancel.go b/openmeter/productcatalog/subscription/http/cancel.go new file mode 100644 index 000000000..b59935103 --- /dev/null +++ b/openmeter/productcatalog/subscription/http/cancel.go @@ -0,0 +1,102 @@ +package httpdriver + +import ( + "context" + "net/http" + "time" + + "github.com/openmeterio/openmeter/api" + "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" +) + +type ( + CancelSubscriptionRequest = struct { + EffectiveAt *time.Time + ID models.NamespacedID + } + CancelSubscriptionResponse = api.Subscription + CancelSubscriptionParams = struct { + ID string + } + CancelSubscriptionHandler = httptransport.HandlerWithArgs[CancelSubscriptionRequest, CancelSubscriptionResponse, CancelSubscriptionParams] +) + +func (h *handler) CancelSubscription() CancelSubscriptionHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params CancelSubscriptionParams) (CancelSubscriptionRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return CancelSubscriptionRequest{}, err + } + + var body api.CancelSubscriptionJSONRequestBody + + if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil { + return CancelSubscriptionRequest{}, err + } + + return CancelSubscriptionRequest{ + EffectiveAt: body.EffectiveDate, + ID: models.NamespacedID{Namespace: ns, ID: params.ID}, + }, nil + }, + func(ctx context.Context, req CancelSubscriptionRequest) (CancelSubscriptionResponse, error) { + sub, err := h.SubscriptionService.Cancel(ctx, req.ID, defaultx.WithDefault(req.EffectiveAt, clock.Now())) + if err != nil { + return CancelSubscriptionResponse{}, err + } + + return MapSubscriptionToAPI(sub), nil + }, + commonhttp.JSONResponseEncoderWithStatus[CancelSubscriptionResponse](http.StatusOK), + httptransport.AppendOptions( + h.Options, + httptransport.WithOperationName("cancelSubscription"), + httptransport.WithErrorEncoder(errorEncoder()), + )..., + ) +} + +type ( + ContinueSubscriptionRequest = struct { + ID models.NamespacedID + } + ContinueSubscriptionResponse = api.Subscription + ContinueSubscriptionParams = struct { + ID string + } + ContinueSubscriptionHandler = httptransport.HandlerWithArgs[ContinueSubscriptionRequest, ContinueSubscriptionResponse, ContinueSubscriptionParams] +) + +func (h *handler) ContinueSubscription() ContinueSubscriptionHandler { + return httptransport.NewHandlerWithArgs( + func(ctx context.Context, r *http.Request, params ContinueSubscriptionParams) (ContinueSubscriptionRequest, error) { + ns, err := h.resolveNamespace(ctx) + if err != nil { + return ContinueSubscriptionRequest{}, err + } + + return ContinueSubscriptionRequest{ + ID: models.NamespacedID{Namespace: ns, ID: params.ID}, + }, nil + }, + func(ctx context.Context, req ContinueSubscriptionRequest) (ContinueSubscriptionResponse, error) { + sub, err := h.SubscriptionService.Continue(ctx, req.ID) + if err != nil { + return ContinueSubscriptionResponse{}, err + } + + return MapSubscriptionToAPI(sub), nil + }, + commonhttp.JSONResponseEncoderWithStatus[ContinueSubscriptionResponse](http.StatusOK), + httptransport.AppendOptions( + h.Options, + httptransport.WithOperationName("continueSubscription"), + httptransport.WithErrorEncoder(errorEncoder()), + )..., + ) +} diff --git a/openmeter/productcatalog/subscription/http/create.go b/openmeter/productcatalog/subscription/http/create.go new file mode 100644 index 000000000..0efc6d6dc --- /dev/null +++ b/openmeter/productcatalog/subscription/http/create.go @@ -0,0 +1,151 @@ +package httpdriver + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/openmeterio/openmeter/api" + "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" + planhttp "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/pkg/convert" + "github.com/openmeterio/openmeter/pkg/framework/commonhttp" + "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" + "github.com/openmeterio/openmeter/pkg/models" +) + +type ( + // TODO: might need or not need a single interface for using the multiple workflow methods + CreateSubscriptionRequest = struct { + inp subscription.CreateSubscriptionWorkflowInput + planRef *plansubscription.PlanRefInput + plan *plan.CreatePlanInput + } + CreateSubscriptionResponse = api.Subscription + // CreateSubscriptionParams = api.CreateSubscriptionParams + // CreateSubscriptionHandler httptransport.HandlerWithArgs[ListPlansRequest, ListPlansResponse, ListPlansParams] + CreateSubscriptionHandler = httptransport.Handler[CreateSubscriptionRequest, CreateSubscriptionResponse] +) + +func (h *handler) CreateSubscription() CreateSubscriptionHandler { + return httptransport.NewHandler( + func(ctx context.Context, r *http.Request) (CreateSubscriptionRequest, error) { + body := api.CreateSubscriptionJSONRequestBody{} + + if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil { + return CreateSubscriptionRequest{}, err + } + + ns, err := h.resolveNamespace(ctx) + if err != nil { + return CreateSubscriptionRequest{}, fmt.Errorf("failed to resolve namespace: %w", 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"` + } + + var t testForCustomPlan + + bodyBytes, err := json.Marshal(body) + if err != nil { + return CreateSubscriptionRequest{}, fmt.Errorf("failed to marshal request body: %w", err) + } + + if err := json.Unmarshal(bodyBytes, &t); err != nil { + return CreateSubscriptionRequest{}, fmt.Errorf("failed to unmarshal request body: %w", err) + } + + if t.CustomPlan != nil { + // Custom subscription creation + parsedBody, err := body.AsCustomSubscriptionCreate() + if err != nil { + return CreateSubscriptionRequest{}, fmt.Errorf("failed to decode request body: %w", err) + } + + req, err := planhttp.AsCreatePlanRequest(parsedBody.CustomPlan, ns) + if err != nil { + return CreateSubscriptionRequest{}, fmt.Errorf("failed to create plan request: %w", err) + } + + return CreateSubscriptionRequest{ + inp: subscription.CreateSubscriptionWorkflowInput{ + Namespace: ns, + ActiveFrom: parsedBody.ActiveFrom, + CustomerID: parsedBody.CustomerId, + Name: req.Name, // We map the plan name to the subscription name + Description: req.Description, // We map the plan description to the subscription description + AnnotatedModel: models.AnnotatedModel{ + Metadata: req.Metadata, // We map the plan metadata to the subscription metadata + }, + }, + + plan: &req, + }, nil + } else { + // Plan subscription creation + parsedBody, err := body.AsPlanSubscriptionCreate() + if err != nil { + return CreateSubscriptionRequest{}, fmt.Errorf("failed to decode request body: %w", err) + } + return CreateSubscriptionRequest{ + inp: subscription.CreateSubscriptionWorkflowInput{ + Namespace: ns, + ActiveFrom: parsedBody.ActiveFrom, + CustomerID: parsedBody.CustomerId, + Name: parsedBody.Name, + Description: parsedBody.Description, + AnnotatedModel: models.AnnotatedModel{ + Metadata: convert.DerefHeaderPtr[string](parsedBody.Metadata), + }, + }, + planRef: &plansubscription.PlanRefInput{ + Key: parsedBody.Plan.Key, + Version: parsedBody.Plan.Version, + }, + }, nil + } + }, + func(ctx context.Context, request CreateSubscriptionRequest) (CreateSubscriptionResponse, error) { + // First, let's map the input to a Plan + var plan subscription.Plan + + if request.plan != nil { + p, err := h.SubscrpiptionPlanAdapter.FromInput(ctx, request.inp.Namespace, *request.plan) + if err != nil { + return CreateSubscriptionResponse{}, err + } + + plan = p + } else if request.planRef != nil { + p, err := h.SubscrpiptionPlanAdapter.GetVersion(ctx, request.inp.Namespace, *request.planRef) + if err != nil { + return CreateSubscriptionResponse{}, err + } + + plan = p + } else { + return CreateSubscriptionResponse{}, fmt.Errorf("plan or plan reference must be provided") + } + + // Then let's create the subscription form the plan + subView, err := h.SubscriptionWorkflowService.CreateFromPlan(ctx, request.inp, plan) + if err != nil { + return CreateSubscriptionResponse{}, err + } + + return MapSubscriptionToAPI(subView.Subscription), nil + }, + commonhttp.JSONResponseEncoderWithStatus[CreateSubscriptionResponse](http.StatusCreated), + httptransport.AppendOptions( + h.Options, + httptransport.WithOperationName("createSubscription"), + httptransport.WithErrorEncoder(errorEncoder()), + )..., + ) +} diff --git a/openmeter/subscription/httpdriver/edit.go b/openmeter/productcatalog/subscription/http/edit.go similarity index 97% rename from openmeter/subscription/httpdriver/edit.go rename to openmeter/productcatalog/subscription/http/edit.go index e4ca4e9c1..ee0c824d6 100644 --- a/openmeter/subscription/httpdriver/edit.go +++ b/openmeter/productcatalog/subscription/http/edit.go @@ -68,7 +68,7 @@ func (h *handler) EditSubscription() EditSubscriptionHandler { commonhttp.JSONResponseEncoderWithStatus[EditSubscriptionResponse](http.StatusOK), httptransport.AppendOptions( h.Options, - httptransport.WithOperationName("getSubscription"), + httptransport.WithOperationName("editSubscription"), httptransport.WithErrorEncoder(errorEncoder()), )..., ) diff --git a/openmeter/subscription/httpdriver/errors.go b/openmeter/productcatalog/subscription/http/errors.go similarity index 100% rename from openmeter/subscription/httpdriver/errors.go rename to openmeter/productcatalog/subscription/http/errors.go diff --git a/openmeter/subscription/httpdriver/get.go b/openmeter/productcatalog/subscription/http/get.go similarity index 100% rename from openmeter/subscription/httpdriver/get.go rename to openmeter/productcatalog/subscription/http/get.go diff --git a/openmeter/subscription/httpdriver/handler.go b/openmeter/productcatalog/subscription/http/handler.go similarity index 83% rename from openmeter/subscription/httpdriver/handler.go rename to openmeter/productcatalog/subscription/http/handler.go index 2b1fd80da..0d9f8d322 100644 --- a/openmeter/subscription/httpdriver/handler.go +++ b/openmeter/productcatalog/subscription/http/handler.go @@ -7,6 +7,7 @@ import ( "net/http" "github.com/openmeterio/openmeter/openmeter/namespace/namespacedriver" + plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" "github.com/openmeterio/openmeter/openmeter/subscription" "github.com/openmeterio/openmeter/pkg/framework/commonhttp" "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" @@ -16,11 +17,14 @@ type Handler interface { CreateSubscription() CreateSubscriptionHandler GetSubscription() GetSubscriptionHandler EditSubscription() EditSubscriptionHandler + CancelSubscription() CancelSubscriptionHandler + ContinueSubscription() ContinueSubscriptionHandler } type HandlerConfig struct { SubscriptionWorkflowService subscription.WorkflowService SubscriptionService subscription.Service + SubscrpiptionPlanAdapter plansubscription.Adapter NamespaceDecoder namespacedriver.NamespaceDecoder Logger *slog.Logger } diff --git a/openmeter/subscription/httpdriver/mapping.go b/openmeter/productcatalog/subscription/http/mapping.go similarity index 97% rename from openmeter/subscription/httpdriver/mapping.go rename to openmeter/productcatalog/subscription/http/mapping.go index 72669ca3b..95ad1845e 100644 --- a/openmeter/subscription/httpdriver/mapping.go +++ b/openmeter/productcatalog/subscription/http/mapping.go @@ -11,8 +11,8 @@ import ( "github.com/openmeterio/openmeter/openmeter/productcatalog" plandriver "github.com/openmeterio/openmeter/openmeter/productcatalog/plan/httpdriver" planhttpdriver "github.com/openmeterio/openmeter/openmeter/productcatalog/plan/httpdriver" + plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" "github.com/openmeterio/openmeter/openmeter/subscription" - subscriptionplan "github.com/openmeterio/openmeter/openmeter/subscription/adapters/plan" "github.com/openmeterio/openmeter/openmeter/subscription/patch" "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/datex" @@ -38,7 +38,7 @@ func MapAPISubscriptionEditOperationToPatch(apiPatch api.SubscriptionEditOperati return nil, fmt.Errorf("failed to cast to RateCard: %w", err) } - sPRC := &subscriptionplan.SubscriptionPlanRateCard{ + sPRC := &plansubscription.RateCard{ PhaseKey: apiP.PhaseKey, RateCard: planRC, } @@ -157,6 +157,16 @@ func MapAPISubscriptionEditOperationToPatch(apiPatch api.SubscriptionEditOperati } func MapSubscriptionToAPI(sub subscription.Subscription) api.Subscription { + var ref *api.PlanReference + + if sub.PlanRef != nil { + ref = &api.PlanReference{ + Id: sub.PlanRef.Id, + Key: sub.PlanRef.Key, + Version: sub.PlanRef.Version, + } + } + return api.Subscription{ Id: sub.ID, ActiveFrom: sub.ActiveFrom, @@ -166,15 +176,11 @@ func MapSubscriptionToAPI(sub subscription.Subscription) api.Subscription { Description: sub.Description, Name: sub.Name, Status: api.SubscriptionStatus(sub.GetStatusAt(clock.Now())), - Plan: &api.PlanReference{ - Id: sub.PlanRef.Id, - Key: sub.PlanRef.Key, - Version: sub.PlanRef.Version, - }, - Metadata: &sub.Metadata, - CreatedAt: sub.CreatedAt, - UpdatedAt: sub.UpdatedAt, - DeletedAt: sub.DeletedAt, + Plan: ref, + Metadata: &sub.Metadata, + CreatedAt: sub.CreatedAt, + UpdatedAt: sub.UpdatedAt, + DeletedAt: sub.DeletedAt, } } diff --git a/openmeter/subscription/adapters/plan/plan.go b/openmeter/productcatalog/subscription/plan.go similarity index 57% rename from openmeter/subscription/adapters/plan/plan.go rename to openmeter/productcatalog/subscription/plan.go index 534fe219a..dcfc93d04 100644 --- a/openmeter/subscription/adapters/plan/plan.go +++ b/openmeter/productcatalog/subscription/plan.go @@ -1,41 +1,46 @@ -package subscriptionplan +package plansubscription import ( "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/datex" + "github.com/openmeterio/openmeter/pkg/models" ) -type SubscriptionPlan struct { - plan.Plan +type PlanRefInput struct { + Key string `json:"key"` + Version *int `json:"version,omitempty"` } -var _ subscription.Plan = &SubscriptionPlan{} - -func (p *SubscriptionPlan) GetRef() subscription.PlanRef { - return subscription.PlanRef{ - Id: p.ID, - Key: p.Key, - Version: p.Version, - } +type Plan struct { + productcatalog.Plan + Ref *models.NamespacedID } -func (p *SubscriptionPlan) ToCreateSubscriptionPlanInput() subscription.CreateSubscriptionPlanInput { - return subscription.CreateSubscriptionPlanInput{ - Plan: subscription.PlanRef{ - Id: p.ID, +var _ subscription.Plan = &Plan{} + +func (p *Plan) ToCreateSubscriptionPlanInput() subscription.CreateSubscriptionPlanInput { + // We only store a reference if the Plan exists + var ref *subscription.PlanRef + + if p.Ref != nil { + ref = &subscription.PlanRef{ + Id: p.Ref.ID, Key: p.Key, Version: p.Version, - }, + } + } + + return subscription.CreateSubscriptionPlanInput{ + Plan: ref, } } -func (p *SubscriptionPlan) GetPhases() []subscription.PlanPhase { +func (p *Plan) GetPhases() []subscription.PlanPhase { ps := make([]subscription.PlanPhase, 0, len(p.Phases)) for _, ph := range p.Phases { - ps = append(ps, &SubscriptionPlanPhase{ + ps = append(ps, &Phase{ Phase: ph, }) } @@ -43,17 +48,17 @@ func (p *SubscriptionPlan) GetPhases() []subscription.PlanPhase { return ps } -func (p *SubscriptionPlan) Currency() currencyx.Code { +func (p *Plan) Currency() currencyx.Code { return currencyx.Code(p.Plan.Currency) } -type SubscriptionPlanPhase struct { - plan.Phase +type Phase struct { + productcatalog.Phase } -var _ subscription.PlanPhase = &SubscriptionPlanPhase{} +var _ subscription.PlanPhase = &Phase{} -func (p *SubscriptionPlanPhase) ToCreateSubscriptionPhasePlanInput() subscription.CreateSubscriptionPhasePlanInput { +func (p *Phase) ToCreateSubscriptionPhasePlanInput() subscription.CreateSubscriptionPhasePlanInput { return subscription.CreateSubscriptionPhasePlanInput{ PhaseKey: p.Key, StartAfter: p.StartAfter, @@ -62,10 +67,10 @@ func (p *SubscriptionPlanPhase) ToCreateSubscriptionPhasePlanInput() subscriptio } } -func (p *SubscriptionPlanPhase) GetRateCards() []subscription.PlanRateCard { +func (p *Phase) GetRateCards() []subscription.PlanRateCard { rcs := make([]subscription.PlanRateCard, 0, len(p.RateCards)) for _, rc := range p.RateCards { - rcs = append(rcs, &SubscriptionPlanRateCard{ + rcs = append(rcs, &RateCard{ PhaseKey: p.Key, RateCard: rc, }) @@ -74,18 +79,18 @@ func (p *SubscriptionPlanPhase) GetRateCards() []subscription.PlanRateCard { return rcs } -func (p *SubscriptionPlanPhase) GetKey() string { +func (p *Phase) GetKey() string { return p.Key } -type SubscriptionPlanRateCard struct { +type RateCard struct { PhaseKey string productcatalog.RateCard } -var _ subscription.PlanRateCard = &SubscriptionPlanRateCard{} +var _ subscription.PlanRateCard = &RateCard{} -func (r *SubscriptionPlanRateCard) ToCreateSubscriptionItemPlanInput() subscription.CreateSubscriptionItemPlanInput { +func (r *RateCard) ToCreateSubscriptionItemPlanInput() subscription.CreateSubscriptionItemPlanInput { m := r.RateCard.AsMeta() var fk *string @@ -122,6 +127,6 @@ func (r *SubscriptionPlanRateCard) ToCreateSubscriptionItemPlanInput() subscript } } -func (r *SubscriptionPlanRateCard) GetKey() string { +func (r *RateCard) GetKey() string { return r.Key() } diff --git a/openmeter/server/router/router.go b/openmeter/server/router/router.go index 262064f21..541a79bb8 100644 --- a/openmeter/server/router/router.go +++ b/openmeter/server/router/router.go @@ -37,10 +37,11 @@ import ( "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" plan "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" planhttpdriver "github.com/openmeterio/openmeter/openmeter/productcatalog/plan/httpdriver" + plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" + subscriptionhttpdriver "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription/http" "github.com/openmeterio/openmeter/openmeter/server/authenticator" "github.com/openmeterio/openmeter/openmeter/streaming" "github.com/openmeterio/openmeter/openmeter/subscription" - subscriptionhttpdriver "github.com/openmeterio/openmeter/openmeter/subscription/httpdriver" "github.com/openmeterio/openmeter/pkg/errorsx" "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" ) @@ -81,6 +82,7 @@ type Config struct { Plan plan.Service SubscriptionService subscription.Service SubscriptionWorkflowService subscription.WorkflowService + SubscriptionPlanAdapter plansubscription.Adapter DebugConnector debug.DebugConnector FeatureConnector feature.FeatureConnector EntitlementConnector entitlement.Connector @@ -284,8 +286,8 @@ func NewRouter(config Config) (*Router, error) { httptransport.WithErrorHandler(config.ErrorHandler), ) - if config.SubscriptionService == nil || config.SubscriptionWorkflowService == nil { - return nil, errors.New("subscription service and workflow service are required when productcatalog is enabled") + if config.SubscriptionService == nil || config.SubscriptionWorkflowService == nil || config.SubscriptionPlanAdapter == nil { + return nil, errors.New("subscription services are required when productcatalog is enabled") } if config.Logger == nil { @@ -296,6 +298,7 @@ func NewRouter(config Config) (*Router, error) { subscriptionhttpdriver.HandlerConfig{ SubscriptionWorkflowService: config.SubscriptionWorkflowService, SubscriptionService: config.SubscriptionService, + SubscrpiptionPlanAdapter: config.SubscriptionPlanAdapter, NamespaceDecoder: staticNamespaceDecoder, Logger: config.Logger, }, diff --git a/openmeter/server/router/subscription.go b/openmeter/server/router/subscription.go index b3ebd680a..6c748ff17 100644 --- a/openmeter/server/router/subscription.go +++ b/openmeter/server/router/subscription.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/openmeterio/openmeter/api" - subscriptionhttpdriver "github.com/openmeterio/openmeter/openmeter/subscription/httpdriver" + subscriptionhttpdriver "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription/http" ) // (POST /api/v1/subscriptions) @@ -45,7 +45,13 @@ func (a *Router) EditSubscription(w http.ResponseWriter, r *http.Request, subscr // (POST /api/v1/subscriptions/{subscriptionId}/cancel) func (a *Router) CancelSubscription(w http.ResponseWriter, r *http.Request, subscriptionId string) { - w.WriteHeader(http.StatusNotImplemented) + if !a.config.ProductCatalogEnabled { + w.WriteHeader(http.StatusNotImplemented) + return + } + a.subscriptionHandler.CancelSubscription().With(subscriptionhttpdriver.CancelSubscriptionParams{ + ID: subscriptionId, + }).ServeHTTP(w, r) } // (POST /api/v1/subscriptions/{subscriptionId}/migrate) @@ -55,5 +61,12 @@ func (a *Router) MigrateSubscription(w http.ResponseWriter, r *http.Request, sub // (POST /api/v1/subscriptions/{subscriptionId}/unschedule-cancelation) func (a *Router) UnscheduleCancelation(w http.ResponseWriter, r *http.Request, subscriptionId string) { - w.WriteHeader(http.StatusNotImplemented) + if !a.config.ProductCatalogEnabled { + w.WriteHeader(http.StatusNotImplemented) + return + } + + a.subscriptionHandler.ContinueSubscription().With(subscriptionhttpdriver.ContinueSubscriptionParams{ + ID: subscriptionId, + }).ServeHTTP(w, r) } diff --git a/openmeter/subscription/adapters/plan/adapter.go b/openmeter/subscription/adapters/plan/adapter.go deleted file mode 100644 index cbafe4f15..000000000 --- a/openmeter/subscription/adapters/plan/adapter.go +++ /dev/null @@ -1,61 +0,0 @@ -package subscriptionplan - -import ( - "context" - "log/slog" - - "github.com/samber/lo" - - "github.com/openmeterio/openmeter/openmeter/productcatalog/plan" - "github.com/openmeterio/openmeter/openmeter/subscription" - "github.com/openmeterio/openmeter/pkg/defaultx" - "github.com/openmeterio/openmeter/pkg/models" -) - -type PlanSubscriptionAdapterConfig struct { - PlanService plan.Service - Logger *slog.Logger -} - -type PlanSubscriptionAdapter struct { - PlanSubscriptionAdapterConfig -} - -var _ subscription.PlanAdapter = &PlanSubscriptionAdapter{} - -func NewSubscriptionPlanAdapter(config PlanSubscriptionAdapterConfig) subscription.PlanAdapter { - return &PlanSubscriptionAdapter{config} -} - -func (a *PlanSubscriptionAdapter) GetVersion(ctx context.Context, namespace string, ref subscription.PlanRefInput) (subscription.Plan, error) { - planKey := ref.Key - version := defaultx.WithDefault(ref.Version, 0) // plan service treats 0 as special case - - p, err := a.PlanService.GetPlan(ctx, plan.GetPlanInput{ - NamespacedID: models.NamespacedID{ - Namespace: namespace, - }, - Key: planKey, - Version: version, - }) - - if _, ok := lo.ErrorsAs[plan.NotFoundError](err); ok { - return nil, subscription.PlanNotFoundError{ - Key: planKey, - Version: version, - } - } else if err != nil { - return nil, err - } - - if p == nil { - return nil, subscription.PlanNotFoundError{ - Key: planKey, - Version: version, - } - } - - return &SubscriptionPlan{ - Plan: *p, - }, nil -} diff --git a/openmeter/subscription/httpdriver/create.go b/openmeter/subscription/httpdriver/create.go deleted file mode 100644 index da6a4880f..000000000 --- a/openmeter/subscription/httpdriver/create.go +++ /dev/null @@ -1,81 +0,0 @@ -package httpdriver - -import ( - "context" - "fmt" - "net/http" - - "github.com/openmeterio/openmeter/api" - "github.com/openmeterio/openmeter/openmeter/subscription" - "github.com/openmeterio/openmeter/pkg/convert" - "github.com/openmeterio/openmeter/pkg/framework/commonhttp" - "github.com/openmeterio/openmeter/pkg/framework/transport/httptransport" - "github.com/openmeterio/openmeter/pkg/models" -) - -type ( - // TODO: might need or not need a single interface for using the multiple workflow methods - CreateSubscriptionRequest = subscription.CreateFromPlanInput - CreateSubscriptionResponse = api.Subscription - // CreateSubscriptionParams = api.CreateSubscriptionParams - // CreateSubscriptionHandler httptransport.HandlerWithArgs[ListPlansRequest, ListPlansResponse, ListPlansParams] - CreateSubscriptionHandler = httptransport.Handler[CreateSubscriptionRequest, CreateSubscriptionResponse] -) - -func (h *handler) CreateSubscription() CreateSubscriptionHandler { - return httptransport.NewHandler( - func(ctx context.Context, r *http.Request) (CreateSubscriptionRequest, error) { - body := api.CreateSubscriptionJSONRequestBody{} - - if err := commonhttp.JSONRequestBodyDecoder(r, &body); err != nil { - return CreateSubscriptionRequest{}, err - } - - ns, err := h.resolveNamespace(ctx) - if err != nil { - return CreateSubscriptionRequest{}, fmt.Errorf("failed to resolve namespace: %w", err) - } - - planSubBody, errAsPlanSub := body.AsPlanSubscriptionCreate() - _, errAsCustSub := body.AsCustomSubscriptionCreate() - - // Custom subscription creation is not currently supported - if errAsPlanSub != nil && errAsCustSub == nil { - return CreateSubscriptionRequest{}, commonhttp.NewHTTPError(http.StatusNotImplemented, fmt.Errorf("custom subscription creation is not supported")) - } - - if errAsPlanSub != nil { - return CreateSubscriptionRequest{}, errAsPlanSub - } - - return CreateSubscriptionRequest{ - Namespace: ns, - ActiveFrom: planSubBody.ActiveFrom, - CustomerID: planSubBody.CustomerId, - Plan: subscription.PlanRefInput{ - Key: planSubBody.Plan.Key, - Version: planSubBody.Plan.Version, - }, - Name: planSubBody.Name, - Description: planSubBody.Description, - AnnotatedModel: models.AnnotatedModel{ - Metadata: convert.DerefHeaderPtr[string](planSubBody.Metadata), - }, - }, nil - }, - func(ctx context.Context, request CreateSubscriptionRequest) (CreateSubscriptionResponse, error) { - subView, err := h.SubscriptionWorkflowService.CreateFromPlan(ctx, request) - if err != nil { - return CreateSubscriptionResponse{}, err - } - - return MapSubscriptionToAPI(subView.Subscription), nil - }, - commonhttp.JSONResponseEncoderWithStatus[CreateSubscriptionResponse](http.StatusCreated), - httptransport.AppendOptions( - h.Options, - httptransport.WithOperationName("createSubscription"), - httptransport.WithErrorEncoder(errorEncoder()), - )..., - ) -} diff --git a/openmeter/subscription/plan.go b/openmeter/subscription/plan.go index 65bfbef86..11a4964de 100644 --- a/openmeter/subscription/plan.go +++ b/openmeter/subscription/plan.go @@ -1,24 +1,18 @@ package subscription import ( - "context" "fmt" "github.com/openmeterio/openmeter/pkg/currencyx" ) -type PlanRefInput struct { - Key string `json:"key"` - Version *int `json:"version,omitempty"` -} - type PlanRef struct { Id string `json:"id"` Key string `json:"key"` Version int `json:"version"` } -func (p PlanRef) Equals(p2 PlanRef) bool { +func (p PlanRef) Equal(p2 PlanRef) bool { if p.Id != p2.Id { return false } @@ -31,11 +25,15 @@ func (p PlanRef) Equals(p2 PlanRef) bool { return true } -type PlanAdapter interface { - // GetPlan returns the plan with the given key and version with all it's dependent resources. - // - // If the Plan is Not Found, it should return a PlanNotFoundError. - GetVersion(ctx context.Context, namespace string, ref PlanRefInput) (Plan, error) +func (p *PlanRef) NilEqual(p2 *PlanRef) bool { + if p == nil && p2 == nil { + return true + } + if p != nil && p2 != nil { + return p.Equal(*p2) + } + + return false } // All methods are expected to return stable values. @@ -55,8 +53,6 @@ type PlanPhase interface { type Plan interface { ToCreateSubscriptionPlanInput() CreateSubscriptionPlanInput - GetRef() PlanRef - // Phases are expected to be returned in the order they activate. GetPhases() []PlanPhase diff --git a/openmeter/subscription/ratecard.go b/openmeter/subscription/ratecard.go index 8ebe8ded2..75488a72c 100644 --- a/openmeter/subscription/ratecard.go +++ b/openmeter/subscription/ratecard.go @@ -35,7 +35,7 @@ type RateCard struct { BillingCadence *datex.Period `json:"billingCadence"` } -func (r RateCard) Equals(other RateCard) bool { +func (r RateCard) Equal(other RateCard) bool { return reflect.DeepEqual(r, other) } diff --git a/openmeter/subscription/repo/mapping.go b/openmeter/subscription/repo/mapping.go index 2b5b1624c..b60e7f614 100644 --- a/openmeter/subscription/repo/mapping.go +++ b/openmeter/subscription/repo/mapping.go @@ -6,7 +6,6 @@ import ( "github.com/openmeterio/openmeter/openmeter/ent/db" "github.com/openmeterio/openmeter/openmeter/subscription" "github.com/openmeterio/openmeter/pkg/convert" - "github.com/openmeterio/openmeter/pkg/defaultx" "github.com/openmeterio/openmeter/pkg/models" ) @@ -15,15 +14,15 @@ func MapDBSubscription(sub *db.Subscription) (subscription.Subscription, error) return subscription.Subscription{}, fmt.Errorf("unexpected nil subscription") } - // TODO: Once PlanRef is properly optional we can remove this hackery - ref := subscription.PlanRef{ - Id: defaultx.WithDefault(sub.PlanID, ""), - } + var ref *subscription.PlanRef if sub.Edges.Plan != nil { + ref = &subscription.PlanRef{ + Id: sub.Edges.Plan.ID, + Key: sub.Edges.Plan.Key, + Version: sub.Edges.Plan.Version, + } ref.Id = sub.Edges.Plan.ID - ref.Key = sub.Edges.Plan.Key - ref.Version = sub.Edges.Plan.Version } return subscription.Subscription{ diff --git a/openmeter/subscription/repo/subscriptionrepo.go b/openmeter/subscription/repo/subscriptionrepo.go index b20236b12..db151995d 100644 --- a/openmeter/subscription/repo/subscriptionrepo.go +++ b/openmeter/subscription/repo/subscriptionrepo.go @@ -98,7 +98,6 @@ func (r *subscriptionRepo) Create(ctx context.Context, sub subscription.CreateSu return entutils.TransactingRepo(ctx, r, func(ctx context.Context, repo *subscriptionRepo) (subscription.Subscription, error) { command := repo.db.Subscription.Create(). SetNamespace(sub.Namespace). - SetPlanID(sub.Plan.Id). SetCustomerID(sub.CustomerId). SetCurrency(sub.Currency). SetActiveFrom(sub.ActiveFrom). @@ -110,6 +109,10 @@ func (r *subscriptionRepo) Create(ctx context.Context, sub subscription.CreateSu command = command.SetActiveTo(*sub.ActiveTo) } + if sub.Plan != nil { + command = command.SetPlanID(sub.Plan.Id) + } + res, err := command.Save(ctx) if err != nil { return subscription.Subscription{}, err diff --git a/openmeter/subscription/repository.go b/openmeter/subscription/repository.go index bcd86f933..46b33d214 100644 --- a/openmeter/subscription/repository.go +++ b/openmeter/subscription/repository.go @@ -17,7 +17,7 @@ type CreateSubscriptionEntityInput struct { models.NamespacedModel models.AnnotatedModel - Plan PlanRef + Plan *PlanRef Name string `json:"name,omitempty"` Description *string `json:"description,omitempty"` diff --git a/openmeter/subscription/service.go b/openmeter/subscription/service.go index f6e09bb4c..693e3132f 100644 --- a/openmeter/subscription/service.go +++ b/openmeter/subscription/service.go @@ -23,17 +23,15 @@ type Service interface { } type WorkflowService interface { - CreateFromPlan(ctx context.Context, inp CreateFromPlanInput) (SubscriptionView, error) + CreateFromPlan(ctx context.Context, inp CreateSubscriptionWorkflowInput, plan Plan) (SubscriptionView, error) EditRunning(ctx context.Context, subscriptionID models.NamespacedID, customizations []Patch) (SubscriptionView, error) } -type CreateFromPlanInput struct { +type CreateSubscriptionWorkflowInput struct { + models.AnnotatedModel Namespace string ActiveFrom time.Time CustomerID string Name string Description *string - models.AnnotatedModel - - Plan PlanRefInput } diff --git a/openmeter/subscription/service/service_test.go b/openmeter/subscription/service/service_test.go index d84496079..9b04e8392 100644 --- a/openmeter/subscription/service/service_test.go +++ b/openmeter/subscription/service/service_test.go @@ -34,7 +34,7 @@ func TestCreation(t *testing.T) { cust := deps.CustomerAdapter.CreateExampleCustomer(t) _ = deps.FeatureConnector.CreateExampleFeature(t) - plan := deps.PlanAdapter.CreateExamplePlan(t, ctx) + plan := deps.PlanHelper.CreatePlan(t, subscriptiontestutils.GetExamplePlanInput(t)) defaultSpecFromPlan, err := subscription.NewSpecFromPlan(plan, subscription.CreateSubscriptionCustomerInput{ CustomerId: cust.ID, @@ -47,7 +47,7 @@ func TestCreation(t *testing.T) { sub, err := service.Create(ctx, subscriptiontestutils.ExampleNamespace, defaultSpecFromPlan) require.Nil(t, err) - require.Equal(t, plan.GetRef(), sub.PlanRef) + require.Equal(t, plan.ToCreateSubscriptionPlanInput().Plan, sub.PlanRef) require.Equal(t, subscriptiontestutils.ExampleNamespace, sub.Namespace) require.Equal(t, cust.ID, sub.CustomerId) require.Equal(t, currencyx.Code("USD"), sub.Currency) @@ -175,7 +175,7 @@ func TestCancellation(t *testing.T) { cust := deps.CustomerAdapter.CreateExampleCustomer(t) _ = deps.FeatureConnector.CreateExampleFeature(t) - plan := deps.PlanAdapter.CreateExamplePlan(t, ctx) + plan := deps.PlanHelper.CreatePlan(t, subscriptiontestutils.GetExamplePlanInput(t)) // First, let's create a subscription defaultSpecFromPlan, err := subscription.NewSpecFromPlan(plan, subscription.CreateSubscriptionCustomerInput{ @@ -289,7 +289,7 @@ func TestContinuing(t *testing.T) { cust := deps.CustomerAdapter.CreateExampleCustomer(t) _ = deps.FeatureConnector.CreateExampleFeature(t) - plan := deps.PlanAdapter.CreateExamplePlan(t, ctx) + plan := deps.PlanHelper.CreatePlan(t, subscriptiontestutils.GetExamplePlanInput(t)) // First, let's create a subscription defaultSpecFromPlan, err := subscription.NewSpecFromPlan(plan, subscription.CreateSubscriptionCustomerInput{ diff --git a/openmeter/subscription/service/update.go b/openmeter/subscription/service/update.go index b082a417e..8bf05e5ef 100644 --- a/openmeter/subscription/service/update.go +++ b/openmeter/subscription/service/update.go @@ -42,11 +42,8 @@ func (s *service) sync(ctx context.Context, view subscription.SubscriptionView, if view.Subscription.CustomerId != newSpec.CustomerId { return def, fmt.Errorf("cannot change customer id") } - if view.Subscription.PlanRef.Key != newSpec.Plan.Key { - return def, fmt.Errorf("cannot change plan key") - } - if view.Subscription.PlanRef.Version != newSpec.Plan.Version { - return def, fmt.Errorf("cannot change plan version") + if !view.Subscription.PlanRef.NilEqual(newSpec.Plan) { + return def, fmt.Errorf("cannot change plan") } if !view.Subscription.ActiveFrom.Equal(newSpec.ActiveFrom) { return def, fmt.Errorf("cannot change subscription active from") diff --git a/openmeter/subscription/service/update_test.go b/openmeter/subscription/service/update_test.go index 93f11f149..1b5d4b0af 100644 --- a/openmeter/subscription/service/update_test.go +++ b/openmeter/subscription/service/update_test.go @@ -1,7 +1,6 @@ package service_test import ( - "context" "testing" "time" @@ -98,7 +97,7 @@ func TestEdit(t *testing.T) { require.NotNil(t, cust) _ = deps.FeatureConnector.CreateExampleFeature(t) - plan := deps.PlanAdapter.CreateExamplePlan(t, context.Background()) + plan := deps.PlanHelper.CreatePlan(t, subscriptiontestutils.GetExamplePlanInput(t)) tc.Handler(t, TDeps{ CurrentTime: currentTime, diff --git a/openmeter/subscription/service/workflowservice.go b/openmeter/subscription/service/workflowservice.go index 2bb1ad1cd..de578edfb 100644 --- a/openmeter/subscription/service/workflowservice.go +++ b/openmeter/subscription/service/workflowservice.go @@ -18,8 +18,6 @@ type WorkflowServiceConfig struct { Service subscription.Service // connectors CustomerService customer.Service - // adapters - PlanAdapter subscription.PlanAdapter // framework TransactionManager transaction.Creator } @@ -36,7 +34,7 @@ func NewWorkflowService(cfg WorkflowServiceConfig) subscription.WorkflowService var _ subscription.WorkflowService = &workflowService{} -func (s *workflowService) CreateFromPlan(ctx context.Context, inp subscription.CreateFromPlanInput) (subscription.SubscriptionView, error) { +func (s *workflowService) CreateFromPlan(ctx context.Context, inp subscription.CreateSubscriptionWorkflowInput, plan subscription.Plan) (subscription.SubscriptionView, error) { var def subscription.SubscriptionView // Let's validate the customer exists @@ -52,12 +50,6 @@ func (s *workflowService) CreateFromPlan(ctx context.Context, inp subscription.C return def, fmt.Errorf("unexpected nil customer") } - // Let's validate the plan exists - plan, err := s.PlanAdapter.GetVersion(ctx, inp.Namespace, inp.Plan) - if err != nil { - return def, fmt.Errorf("failed to fetch plan: %w", err) - } - // Let's create the new Spec spec, err := subscription.NewSpecFromPlan(plan, subscription.CreateSubscriptionCustomerInput{ CustomerId: cust.ID, diff --git a/openmeter/subscription/service/workflowservice_test.go b/openmeter/subscription/service/workflowservice_test.go index 132455383..729b1e124 100644 --- a/openmeter/subscription/service/workflowservice_test.go +++ b/openmeter/subscription/service/workflowservice_test.go @@ -39,37 +39,33 @@ func TestCreateFromPlan(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - _, err := deps.WorkflowService.CreateFromPlan(ctx, subscription.CreateFromPlanInput{ + _, err := deps.WorkflowService.CreateFromPlan(ctx, subscription.CreateSubscriptionWorkflowInput{ CustomerID: fmt.Sprintf("nonexistent-customer-%s", deps.Customer.ID), Namespace: subscriptiontestutils.ExampleNamespace, ActiveFrom: deps.CurrentTime, - Plan: subscription.PlanRefInput{ - Key: deps.Plan.GetRef().Key, - Version: lo.ToPtr(deps.Plan.GetRef().Version), - }, - }) + }, deps.Plan) assert.ErrorAs(t, err, &customerentity.NotFoundError{}, "expected customer not found error, got %T", err) }, }, - { - Name: "Should error if plan is not found", - Handler: func(t *testing.T, deps testCaseDeps) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + // { + // Name: "Should error if plan is not found", + // Handler: func(t *testing.T, deps testCaseDeps) { + // ctx, cancel := context.WithCancel(context.Background()) + // defer cancel() - _, err := deps.WorkflowService.CreateFromPlan(ctx, subscription.CreateFromPlanInput{ - CustomerID: deps.Customer.ID, - Namespace: subscriptiontestutils.ExampleNamespace, - ActiveFrom: deps.CurrentTime, - Plan: subscription.PlanRefInput{Key: "nonexistent-plan", Version: lo.ToPtr(1)}, - }) + // _, err := deps.WorkflowService.CreateFromPlan(ctx, subscription.CreateSubscriptionWorkflowInput{ + // CustomerID: deps.Customer.ID, + // Namespace: subscriptiontestutils.ExampleNamespace, + // ActiveFrom: deps.CurrentTime, + // }, subscription.PlanRefInput{Key: "nonexistent-plan", Version: lo.ToPtr(1)}, + // ) - // assert.ErrorAs does not recognize this error - _, isErr := lo.ErrorsAs[subscription.PlanNotFoundError](err) - assert.True(t, isErr, "expected plan not found error, got %T", err) - }, - }, + // // assert.ErrorAs does not recognize this error + // _, isErr := lo.ErrorsAs[subscription.PlanNotFoundError](err) + // assert.True(t, isErr, "expected plan not found error, got %T", err) + // }, + // }, // TODO: validate patches separately // { // Name: "Should error if a patch is invalid", @@ -286,7 +282,7 @@ func TestCreateFromPlan(t *testing.T) { services, deps := subscriptiontestutils.NewService(t, dbDeps) deps.FeatureConnector.CreateExampleFeature(t) - plan := deps.PlanAdapter.CreateExamplePlan(t, context.Background()) + plan := deps.PlanHelper.CreatePlan(t, subscriptiontestutils.GetExamplePlanInput(t)) cust := deps.CustomerAdapter.CreateExampleCustomer(t) require.NotNil(t, cust) @@ -386,7 +382,7 @@ func TestEditRunning(t *testing.T) { }, c, "apply context is incorrect") // Lets modify the spec to see if its passed to the next - spec.Plan.Key = "modified-plan" + spec.Name = "modified-name" return nil }, @@ -395,7 +391,7 @@ func TestEditRunning(t *testing.T) { patch2 := subscriptiontestutils.TestPatch{ ApplyToFn: func(spec *subscription.SubscriptionSpec, c subscription.ApplyContext) error { // Let's see if the modification is passed along - assert.Equal(t, "modified-plan", spec.Plan.Key, "expected plan key to be modified") + assert.Equal(t, "modified-name", spec.Name, "expected name to be modified") // Let's return an error to see if it is surfaced return errors.New(errMSg) @@ -457,7 +453,6 @@ func TestEditRunning(t *testing.T) { workflowService := service.NewWorkflowService(service.WorkflowServiceConfig{ Service: &mSvc, CustomerService: tuDeps.CustomerService, - PlanAdapter: tuDeps.PlanAdapter, TransactionManager: tuDeps.CustomerAdapter, }) @@ -482,21 +477,17 @@ func TestEditRunning(t *testing.T) { services, deps := subscriptiontestutils.NewService(t, dbDeps) deps.FeatureConnector.CreateExampleFeature(t) - plan := deps.PlanAdapter.CreateExamplePlan(t, context.Background()) + plan := deps.PlanHelper.CreatePlan(t, subscriptiontestutils.GetExamplePlanInput(t)) cust := deps.CustomerAdapter.CreateExampleCustomer(t) require.NotNil(t, cust) // Let's create an example subscription - sub, err := services.WorkflowService.CreateFromPlan(context.Background(), subscription.CreateFromPlanInput{ + sub, err := services.WorkflowService.CreateFromPlan(context.Background(), subscription.CreateSubscriptionWorkflowInput{ CustomerID: cust.ID, Namespace: subscriptiontestutils.ExampleNamespace, ActiveFrom: tcDeps.CurrentTime, - Plan: subscription.PlanRefInput{ - Key: plan.GetRef().Key, - Version: lo.ToPtr(plan.GetRef().Version), - }, - Name: "Example Subscription", - }) + Name: "Example Subscription", + }, plan) require.Nil(t, err) tcDeps.SubView = sub diff --git a/openmeter/subscription/subscription.go b/openmeter/subscription/subscription.go index f997bd2f0..8b211b4ee 100644 --- a/openmeter/subscription/subscription.go +++ b/openmeter/subscription/subscription.go @@ -16,7 +16,8 @@ type Subscription struct { Name string `json:"name,omitempty"` Description *string `json:"description,omitempty"` - PlanRef PlanRef `json:"planRef"` + // References the plan (if the Subscription was created form one) + PlanRef *PlanRef `json:"planRef"` CustomerId string `json:"customerId,omitempty"` Currency currencyx.Code diff --git a/openmeter/subscription/subscriptionspec.go b/openmeter/subscription/subscriptionspec.go index c096aac28..913cecca7 100644 --- a/openmeter/subscription/subscriptionspec.go +++ b/openmeter/subscription/subscriptionspec.go @@ -27,7 +27,7 @@ import ( // Third is the final spec which is a combination of the above two, it is suffixed with Spec. type CreateSubscriptionPlanInput struct { - Plan PlanRef `json:"plan"` + Plan *PlanRef `json:"plan"` } type CreateSubscriptionCustomerInput struct { @@ -561,8 +561,15 @@ func NewSpecFromPlan(p Plan, c CreateSubscriptionCustomerInput) (SubscriptionSpe Phases: make(map[string]*SubscriptionPhaseSpec), } + // Let's find an intelligent name by which we can refer to the plan in contextual errors + planRefName := "custom plan" + + if ref := p.ToCreateSubscriptionPlanInput().Plan; ref != nil { + planRefName = fmt.Sprintf("plan %s version %d", ref.Key, ref.Version) + } + if len(p.GetPhases()) == 0 { - return spec, fmt.Errorf("plan %s version %d has no phases", p.GetRef().Key, p.GetRef().Version) + return spec, fmt.Errorf("%s has no phases", planRefName) } // Validate that the plan phases are returned in order @@ -572,13 +579,13 @@ func NewSpecFromPlan(p Plan, c CreateSubscriptionCustomerInput) (SubscriptionSpe continue } if diff, err := planPhases[i].ToCreateSubscriptionPhasePlanInput().StartAfter.Subtract(planPhases[i-1].ToCreateSubscriptionPhasePlanInput().StartAfter); err != nil || diff.IsNegative() { - return spec, fmt.Errorf("phases %s and %s of plan %s version %d are in the wrong order", planPhases[i].GetKey(), planPhases[i-1].GetKey(), p.GetRef().Key, p.GetRef().Version) + return spec, fmt.Errorf("phases %s and %s of %s are in the wrong order", planPhases[i].GetKey(), planPhases[i-1].GetKey(), planRefName) } } for _, planPhase := range planPhases { if _, ok := spec.Phases[planPhase.GetKey()]; ok { - return spec, fmt.Errorf("phase %s of plan %s version %d is duplicated", planPhase.GetKey(), p.GetRef().Key, p.GetRef().Version) + return spec, fmt.Errorf("phase %s of %s is duplicated", planPhase.GetKey(), planRefName) } createSubscriptionPhasePlanInput := planPhase.ToCreateSubscriptionPhasePlanInput() @@ -592,7 +599,7 @@ func NewSpecFromPlan(p Plan, c CreateSubscriptionCustomerInput) (SubscriptionSpe } if len(planPhase.GetRateCards()) == 0 { - return spec, fmt.Errorf("phase %s of plan %s version %d has no rate cards", phase.PhaseKey, p.GetRef().Key, p.GetRef().Version) + return spec, fmt.Errorf("phase %s of %s has no rate cards", phase.PhaseKey, planRefName) } // We expect that in a plan phase, each rate card is unique by key, so let's validate that @@ -600,7 +607,7 @@ func NewSpecFromPlan(p Plan, c CreateSubscriptionCustomerInput) (SubscriptionSpe for _, rateCard := range planPhase.GetRateCards() { if _, ok := rcByKey[rateCard.GetKey()]; ok { - return spec, fmt.Errorf("rate card %s of phase %s of plan %s version %d is duplicated", rateCard.GetKey(), phase.PhaseKey, p.GetRef().Key, p.GetRef().Version) + return spec, fmt.Errorf("rate card %s of phase %s of %s is duplicated", rateCard.GetKey(), phase.PhaseKey, planRefName) } rcByKey[rateCard.GetKey()] = struct{}{} diff --git a/openmeter/subscription/subscriptionview.go b/openmeter/subscription/subscriptionview.go index 326341e2a..7c9675289 100644 --- a/openmeter/subscription/subscriptionview.go +++ b/openmeter/subscription/subscriptionview.go @@ -41,7 +41,8 @@ func (s *SubscriptionView) Validate(includePhases bool) error { if spec.Currency != s.Subscription.Currency { return fmt.Errorf("subscription currency %s does not match spec currency %s", s.Subscription.Currency, spec.Currency) } - if !spec.Plan.Equals(s.Subscription.PlanRef) { + + if !spec.Plan.NilEqual(s.Subscription.PlanRef) { return fmt.Errorf("subscription plan %v does not match spec plan %v", s.Subscription.PlanRef, spec.Plan) } @@ -96,7 +97,7 @@ func (s *SubscriptionItemView) AsSpec() SubscriptionItemSpec { func (s *SubscriptionItemView) Validate() error { // Let's validate that the RateCard contents match in Spec and SubscriptionItem - if !s.Spec.RateCard.Equals(s.SubscriptionItem.RateCard) { + if !s.Spec.RateCard.Equal(s.SubscriptionItem.RateCard) { return fmt.Errorf("item %s rate card %+v does not match spec rate card %+v", s.Spec.ItemKey, s.SubscriptionItem.RateCard, s.Spec.RateCard) } diff --git a/openmeter/subscription/testutils/plan.go b/openmeter/subscription/testutils/plan.go index 1b6f9f3d0..bf5dd3abc 100644 --- a/openmeter/subscription/testutils/plan.go +++ b/openmeter/subscription/testutils/plan.go @@ -2,7 +2,6 @@ package subscriptiontestutils import ( "context" - "log/slog" "testing" "github.com/invopop/gobl/currency" @@ -10,12 +9,9 @@ import ( "github.com/stretchr/testify/require" "github.com/openmeterio/openmeter/openmeter/productcatalog" - "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" "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" + plansubscription "github.com/openmeterio/openmeter/openmeter/productcatalog/subscription" "github.com/openmeterio/openmeter/openmeter/subscription" - subscriptionplan "github.com/openmeterio/openmeter/openmeter/subscription/adapters/plan" "github.com/openmeterio/openmeter/openmeter/testutils" "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/models" @@ -73,48 +69,26 @@ func GetExamplePlanInput(t *testing.T) plan.CreatePlanInput { } } -type planAdapter struct { - subscription.PlanAdapter +// PlanHelper simply creates and returns a plan +type planHelper struct { planService plan.Service } -func NewPlanAdapter(t *testing.T, dbDeps *DBDeps, logger *slog.Logger, featureConnector feature.FeatureConnector) *planAdapter { - t.Helper() - - planRepo, err := planrepo.New(planrepo.Config{ - Client: dbDeps.dbClient, - Logger: logger, - }) - - require.Nil(t, err) - - planService, err := planservice.New(planservice.Config{ - Feature: featureConnector, - Adapter: planRepo, - Logger: testutils.NewLogger(t), - }) - - require.Nil(t, err) - - return &planAdapter{ +func NewPlanHelper(planService plan.Service) *planHelper { + return &planHelper{ planService: planService, - PlanAdapter: subscriptionplan.NewSubscriptionPlanAdapter( - subscriptionplan.PlanSubscriptionAdapterConfig{ - PlanService: planService, - Logger: logger, - }, - ), } } -func (a *planAdapter) CreateExamplePlan(t *testing.T, ctx context.Context) subscription.Plan { +func (h *planHelper) CreatePlan(t *testing.T, input plan.CreatePlanInput) subscription.Plan { t.Helper() + ctx := context.Background() - p, err := a.planService.CreatePlan(ctx, GetExamplePlanInput(t)) + p, err := h.planService.CreatePlan(ctx, GetExamplePlanInput(t)) require.Nil(t, err) require.NotNil(t, p) - p, err = a.planService.PublishPlan(ctx, plan.PublishPlanInput{ + p, err = h.planService.PublishPlan(ctx, plan.PublishPlanInput{ NamespacedID: p.NamespacedID, EffectivePeriod: productcatalog.EffectivePeriod{ EffectiveFrom: lo.ToPtr(clock.Now()), @@ -125,7 +99,10 @@ func (a *planAdapter) CreateExamplePlan(t *testing.T, ctx context.Context) subsc require.Nil(t, err) require.NotNil(t, p) - return &subscriptionplan.SubscriptionPlan{ - Plan: *p, + pp, err := p.AsProductCatalogPlan(clock.Now()) + require.Nil(t, err) + + return &plansubscription.Plan{ + Plan: pp, } } diff --git a/openmeter/subscription/testutils/repository.go b/openmeter/subscription/testutils/repository.go index efd9388bb..fc445be28 100644 --- a/openmeter/subscription/testutils/repository.go +++ b/openmeter/subscription/testutils/repository.go @@ -35,7 +35,7 @@ func (r *testSubscriptionRepo) CreateExampleSubscription(t *testing.T, customerI func getExampleCreateSubscriptionInput(customerId string, planRef subscription.PlanRef) subscription.CreateSubscriptionEntityInput { return subscription.CreateSubscriptionEntityInput{ - Plan: planRef, + Plan: &planRef, CustomerId: customerId, Currency: "USD", CadencedModel: models.CadencedModel{ diff --git a/openmeter/subscription/testutils/service.go b/openmeter/subscription/testutils/service.go index ac20ce199..2291fa43c 100644 --- a/openmeter/subscription/testutils/service.go +++ b/openmeter/subscription/testutils/service.go @@ -5,8 +5,12 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "github.com/openmeterio/openmeter/openmeter/customer" "github.com/openmeterio/openmeter/openmeter/meter" + 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" @@ -22,7 +26,7 @@ type ExposedServiceDeps struct { CustomerService customer.Service FeatureConnector *testFeatureConnector EntitlementAdapter subscription.EntitlementAdapter - PlanAdapter *planAdapter + PlanHelper *planHelper DBDeps *DBDeps } @@ -61,7 +65,20 @@ func NewService(t *testing.T, dbDeps *DBDeps) (services, ExposedServiceDeps) { customerAdapter := NewCustomerAdapter(t, dbDeps) customer := NewCustomerService(t, dbDeps) - planAdapter := NewPlanAdapter(t, dbDeps, logger, entitlementRegistry.Feature) + planRepo, err := planrepo.New(planrepo.Config{ + Client: dbDeps.dbClient, + Logger: logger, + }) + require.NoError(t, err) + + planService, err := planservice.New(planservice.Config{ + Feature: entitlementRegistry.Feature, + Logger: logger, + Adapter: planRepo, + }) + require.NoError(t, err) + + planHelper := NewPlanHelper(planService) svc := service.New(service.ServiceConfig{ SubscriptionRepo: subRepo, @@ -75,7 +92,6 @@ func NewService(t *testing.T, dbDeps *DBDeps) (services, ExposedServiceDeps) { workflowSvc := service.NewWorkflowService(service.WorkflowServiceConfig{ Service: svc, CustomerService: customer, - PlanAdapter: planAdapter, TransactionManager: subItemRepo, }) @@ -87,8 +103,8 @@ func NewService(t *testing.T, dbDeps *DBDeps) (services, ExposedServiceDeps) { CustomerService: customer, FeatureConnector: NewTestFeatureConnector(entitlementRegistry.Feature), EntitlementAdapter: entitlementAdapter, - PlanAdapter: planAdapter, DBDeps: dbDeps, + PlanHelper: planHelper, } }