| package service_test |
|
|
| import ( |
| "context" |
| "testing" |
| "time" |
|
|
| "github.com/samber/lo" |
| "github.com/stretchr/testify/assert" |
| "github.com/stretchr/testify/require" |
|
|
| "github.com/openmeterio/openmeter/openmeter/customer" |
| "github.com/openmeterio/openmeter/openmeter/productcatalog" |
| "github.com/openmeterio/openmeter/openmeter/subscription" |
| subscriptiontestutils "github.com/openmeterio/openmeter/openmeter/subscription/testutils" |
| subscriptionworkflow "github.com/openmeterio/openmeter/openmeter/subscription/workflow" |
| "github.com/openmeterio/openmeter/openmeter/testutils" |
| "github.com/openmeterio/openmeter/pkg/clock" |
| "github.com/openmeterio/openmeter/pkg/currencyx" |
| "github.com/openmeterio/openmeter/pkg/datetime" |
| "github.com/openmeterio/openmeter/pkg/filter" |
| "github.com/openmeterio/openmeter/pkg/models" |
| ) |
|
|
| func TestEdit(t *testing.T) { |
| type TDeps struct { |
| CurrentTime time.Time |
| Customer customer.Customer |
| ExamplePlan subscription.Plan |
| ServiceDeps subscriptiontestutils.SubscriptionDependencies |
| Service subscription.Service |
| } |
|
|
| tt := []struct { |
| Name string |
| Handler func(t *testing.T, deps TDeps) |
| }{ |
| { |
| Name: "Should do nothing if no changes", |
| Handler: func(t *testing.T, deps TDeps) { |
| ctx, cancel := context.WithCancel(context.Background()) |
| defer cancel() |
|
|
| spec, err := subscription.NewSpecFromPlan(deps.ExamplePlan, subscription.CreateSubscriptionCustomerInput{ |
| Name: "Test", |
| CustomerId: deps.Customer.ID, |
| Currency: currencyx.Code("USD"), |
| ActiveFrom: deps.CurrentTime, |
| BillingAnchor: deps.CurrentTime, |
| }) |
| require.Nil(t, err) |
|
|
| sub, err := deps.Service.Create(ctx, deps.Customer.Namespace, spec) |
| require.Nil(t, err) |
|
|
| _, err = deps.Service.Update(ctx, sub.NamespacedID, spec) |
| require.Nil(t, err) |
| }, |
| }, |
| { |
| Name: "Should error if plan changes", |
| Handler: func(t *testing.T, deps TDeps) { |
| ctx, cancel := context.WithCancel(context.Background()) |
| defer cancel() |
|
|
| spec, err := subscription.NewSpecFromPlan(deps.ExamplePlan, subscription.CreateSubscriptionCustomerInput{ |
| Name: "Test", |
| CustomerId: deps.Customer.ID, |
| Currency: currencyx.Code("USD"), |
| ActiveFrom: deps.CurrentTime, |
| BillingAnchor: deps.CurrentTime, |
| }) |
| require.Nil(t, err) |
|
|
| sub, err := deps.Service.Create(ctx, deps.Customer.Namespace, spec) |
| require.Nil(t, err) |
|
|
| require.NotNil(t, spec.Plan) |
| |
| spec.Plan = nil |
|
|
| _, err = deps.Service.Update(ctx, sub.NamespacedID, spec) |
| assert.Error(t, err) |
| assert.ErrorContains(t, err, "cannot change plan") |
| }, |
| }, |
| { |
| Name: "Should error if customer changes", |
| Handler: func(t *testing.T, deps TDeps) { |
| ctx, cancel := context.WithCancel(context.Background()) |
| defer cancel() |
|
|
| spec, err := subscription.NewSpecFromPlan(deps.ExamplePlan, subscription.CreateSubscriptionCustomerInput{ |
| Name: "Test", |
| CustomerId: deps.Customer.ID, |
| Currency: currencyx.Code("USD"), |
| ActiveFrom: deps.CurrentTime, |
| BillingAnchor: deps.CurrentTime, |
| }) |
| require.Nil(t, err) |
|
|
| sub, err := deps.Service.Create(ctx, deps.Customer.Namespace, spec) |
| require.Nil(t, err) |
|
|
| |
| spec.CustomerId = "new-customer-id" |
|
|
| _, err = deps.Service.Update(ctx, sub.NamespacedID, spec) |
| assert.Error(t, err) |
| assert.ErrorContains(t, err, "cannot change customer") |
| }, |
| }, |
| { |
| Name: "Should error if subscription start changes", |
| Handler: func(t *testing.T, deps TDeps) { |
| ctx, cancel := context.WithCancel(context.Background()) |
| defer cancel() |
|
|
| spec, err := subscription.NewSpecFromPlan(deps.ExamplePlan, subscription.CreateSubscriptionCustomerInput{ |
| Name: "Test", |
| CustomerId: deps.Customer.ID, |
| Currency: currencyx.Code("USD"), |
| ActiveFrom: deps.CurrentTime, |
| BillingAnchor: deps.CurrentTime, |
| }) |
| require.Nil(t, err) |
|
|
| sub, err := deps.Service.Create(ctx, deps.Customer.Namespace, spec) |
| require.Nil(t, err) |
|
|
| |
| spec.ActiveFrom = spec.ActiveFrom.Add(time.Hour) |
|
|
| _, err = deps.Service.Update(ctx, sub.NamespacedID, spec) |
| assert.Error(t, err) |
| assert.ErrorContains(t, err, "cannot change subscription start") |
| }, |
| }, |
| { |
| Name: "Should update contents of future phases when phase start changes", |
| Handler: func(t *testing.T, deps TDeps) { |
| ctx, cancel := context.WithCancel(context.Background()) |
| defer cancel() |
|
|
| spec, err := subscription.NewSpecFromPlan(deps.ExamplePlan, subscription.CreateSubscriptionCustomerInput{ |
| Name: "Test", |
| CustomerId: deps.Customer.ID, |
| Currency: currencyx.Code("USD"), |
| ActiveFrom: deps.CurrentTime, |
| BillingAnchor: deps.CurrentTime, |
| }) |
| require.Nil(t, err) |
|
|
| sub, err := deps.Service.Create(ctx, deps.Customer.Namespace, spec) |
| require.Nil(t, err) |
|
|
| _, err = deps.Service.GetView(ctx, sub.NamespacedID) |
| require.Nil(t, err) |
|
|
| |
| require.Equal(t, 3, len(spec.Phases)) |
|
|
| pKey := "test_phase_3" |
| _, ok := spec.Phases[pKey] |
| require.True(t, ok) |
|
|
| |
| st, _ := spec.Phases[pKey].StartAfter.AddTo(sub.ActiveFrom) |
| require.True(t, st.After(clock.Now())) |
|
|
| |
| spec.Phases[pKey].StartAfter, err = spec.Phases[pKey].StartAfter.Add(datetime.MustParseDuration(t, "P1M")) |
| require.Nil(t, err) |
|
|
| _, err = deps.Service.Update(ctx, sub.NamespacedID, spec) |
| require.Nil(t, err) |
|
|
| v2, err := deps.Service.GetView(ctx, sub.NamespacedID) |
| require.Nil(t, err) |
|
|
| |
| subscriptiontestutils.ValidateSpecAndView(t, spec, v2) |
| }, |
| }, |
| { |
| Name: "Should delete item from future phase", |
| Handler: func(t *testing.T, deps TDeps) { |
| ctx, cancel := context.WithCancel(context.Background()) |
| defer cancel() |
|
|
| spec, err := subscription.NewSpecFromPlan(deps.ExamplePlan, subscription.CreateSubscriptionCustomerInput{ |
| Name: "Test", |
| CustomerId: deps.Customer.ID, |
| Currency: currencyx.Code("USD"), |
| ActiveFrom: deps.CurrentTime, |
| BillingAnchor: deps.CurrentTime, |
| }) |
| require.Nil(t, err) |
|
|
| sub, err := deps.Service.Create(ctx, deps.Customer.Namespace, spec) |
| require.Nil(t, err) |
|
|
| _, err = deps.Service.GetView(ctx, sub.NamespacedID) |
| require.Nil(t, err) |
|
|
| |
| require.Equal(t, 3, len(spec.Phases)) |
|
|
| pKey := "test_phase_2" |
| _, ok := spec.Phases[pKey] |
| require.True(t, ok) |
|
|
| |
| iKey := subscriptiontestutils.ExampleFeatureKey |
|
|
| v, ok := spec.Phases[pKey].ItemsByKey[iKey] |
| require.True(t, ok) |
| require.Greater(t, len(v), 0) |
|
|
| |
| delete(spec.Phases[pKey].ItemsByKey, iKey) |
|
|
| _, err = deps.Service.Update(ctx, sub.NamespacedID, spec) |
| require.Nil(t, err) |
|
|
| v2, err := deps.Service.GetView(ctx, sub.NamespacedID) |
| require.Nil(t, err) |
|
|
| |
| subscriptiontestutils.ValidateSpecAndView(t, spec, v2) |
| }, |
| }, |
| { |
| Name: "Should add item to future phase", |
| Handler: func(t *testing.T, deps TDeps) { |
| ctx, cancel := context.WithCancel(context.Background()) |
| defer cancel() |
|
|
| spec, err := subscription.NewSpecFromPlan(deps.ExamplePlan, subscription.CreateSubscriptionCustomerInput{ |
| Name: "Test", |
| CustomerId: deps.Customer.ID, |
| Currency: currencyx.Code("USD"), |
| ActiveFrom: deps.CurrentTime, |
| BillingAnchor: deps.CurrentTime, |
| }) |
| require.Nil(t, err) |
|
|
| sub, err := deps.Service.Create(ctx, deps.Customer.Namespace, spec) |
| require.Nil(t, err) |
|
|
| _, err = deps.Service.GetView(ctx, sub.NamespacedID) |
| require.Nil(t, err) |
|
|
| |
| require.Equal(t, 3, len(spec.Phases)) |
|
|
| pKey := "test_phase_3" |
| _, ok := spec.Phases[pKey] |
| require.True(t, ok) |
|
|
| |
| iKey := subscriptiontestutils.ExampleRateCard2.Key() |
|
|
| _, ok = spec.Phases[pKey].ItemsByKey[iKey] |
| require.False(t, ok) |
|
|
| rc := subscriptiontestutils.ExampleRateCard2 |
|
|
| |
| spec.Phases[pKey].ItemsByKey[iKey] = []*subscription.SubscriptionItemSpec{ |
| { |
| CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{ |
| CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{ |
| PhaseKey: pKey, |
| ItemKey: iKey, |
| RateCard: &rc, |
| }, |
| CreateSubscriptionItemCustomerInput: subscription.CreateSubscriptionItemCustomerInput{}, |
| }, |
| }, |
| } |
|
|
| _, err = deps.Service.Update(ctx, sub.NamespacedID, spec) |
| require.Nil(t, err) |
|
|
| v2, err := deps.Service.GetView(ctx, sub.NamespacedID) |
| require.Nil(t, err) |
|
|
| |
| subscriptiontestutils.ValidateSpecAndView(t, spec, v2) |
| }, |
| }, |
| { |
| Name: "Should update item entitlement", |
| Handler: func(t *testing.T, deps TDeps) { |
| ctx, cancel := context.WithCancel(context.Background()) |
| defer cancel() |
|
|
| spec, err := subscription.NewSpecFromPlan(deps.ExamplePlan, subscription.CreateSubscriptionCustomerInput{ |
| Name: "Test", |
| CustomerId: deps.Customer.ID, |
| Currency: currencyx.Code("USD"), |
| ActiveFrom: deps.CurrentTime, |
| BillingAnchor: deps.CurrentTime, |
| }) |
| require.Nil(t, err) |
|
|
| sub, err := deps.Service.Create(ctx, deps.Customer.Namespace, spec) |
| require.Nil(t, err) |
|
|
| v1, err := deps.Service.GetView(ctx, sub.NamespacedID) |
| require.Nil(t, err) |
| require.NotEmpty(t, v1.Subscription.ID) |
|
|
| |
| require.Equal(t, 3, len(spec.Phases)) |
|
|
| pKey := "test_phase_1" |
| _, ok := spec.Phases[pKey] |
| require.True(t, ok) |
|
|
| |
| iKey := subscriptiontestutils.ExampleRateCard1.Key() |
|
|
| v, ok := spec.Phases[pKey].ItemsByKey[iKey] |
| require.True(t, ok) |
| require.Greater(t, len(v), 0) |
|
|
| item := v[0] |
|
|
| |
| require.NoError(t, item.RateCard.ChangeMeta(func(m productcatalog.RateCardMeta) (productcatalog.RateCardMeta, error) { |
| m.EntitlementTemplate = nil |
| return m, nil |
| })) |
|
|
| |
| spec.Phases[pKey].ItemsByKey[iKey] = []*subscription.SubscriptionItemSpec{ |
| item, |
| } |
|
|
| u, err := deps.Service.Update(ctx, sub.NamespacedID, spec) |
| require.Nil(t, err) |
| require.NotEmpty(t, u.ID) |
|
|
| v2, err := deps.Service.GetView(ctx, sub.NamespacedID) |
| require.Nil(t, err) |
|
|
| |
| subscriptiontestutils.ValidateSpecAndView(t, spec, v2) |
| }, |
| }, |
| } |
|
|
| for _, tc := range tt { |
| t.Run(tc.Name, func(t *testing.T) { |
| currentTime := testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z") |
| clock.SetTime(currentTime) |
|
|
| dbDeps := subscriptiontestutils.SetupDBDeps(t) |
| defer dbDeps.Cleanup(t) |
|
|
| deps := subscriptiontestutils.NewService(t, dbDeps) |
| service := deps.SubscriptionService |
|
|
| cust := deps.CustomerAdapter.CreateExampleCustomer(t) |
| require.NotNil(t, cust) |
|
|
| _ = deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID) |
| plan := deps.PlanHelper.CreatePlan(t, subscriptiontestutils.GetExamplePlanInput(t)) |
|
|
| tc.Handler(t, TDeps{ |
| CurrentTime: currentTime, |
| Customer: *cust, |
| ExamplePlan: plan, |
| ServiceDeps: deps, |
| Service: service, |
| }) |
| }) |
| } |
| } |
|
|
| func TestDeleteScheduledDowngradeCanExpandDeletedSubscriptionForSync(t *testing.T) { |
| ctx := t.Context() |
| currentTime := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) |
| clock.FreezeTime(currentTime) |
| defer clock.UnFreeze() |
|
|
| dbDeps := subscriptiontestutils.SetupDBDeps(t) |
| defer dbDeps.Cleanup(t) |
|
|
| deps := subscriptiontestutils.NewService(t, dbDeps) |
| customerEntity := deps.CustomerAdapter.CreateExampleCustomer(t) |
| _ = deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID) |
|
|
| billingAnchor := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) |
| premiumPlanInput := subscriptiontestutils.GetExamplePlanInput(t) |
| premiumPlanInput.Key = "premium-plan" |
| premiumPlanInput.Name = "Premium" |
| premiumPlan := deps.PlanHelper.CreatePlan(t, premiumPlanInput) |
|
|
| basicPlanInput := subscriptiontestutils.GetExamplePlanInput(t) |
| basicPlanInput.Key = "basic-plan" |
| basicPlanInput.Name = "Basic" |
| basicPlan := deps.PlanHelper.CreatePlan(t, basicPlanInput) |
|
|
| var premiumSubscription subscription.SubscriptionView |
| var scheduledSubscription subscription.SubscriptionView |
|
|
| t.Run("given a customer with a premium subscription", func(t *testing.T) { |
| |
| |
| |
| |
| |
| |
| |
| var err error |
| premiumSubscription, err = deps.WorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{ |
| ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{ |
| Timing: subscription.Timing{ |
| Custom: ¤tTime, |
| }, |
| }, |
| Namespace: subscriptiontestutils.ExampleNamespace, |
| CustomerID: customerEntity.ID, |
| BillingAnchor: &billingAnchor, |
| }, premiumPlan) |
| require.NoError(t, err) |
| require.NotNil(t, premiumSubscription) |
| }) |
|
|
| t.Run("when a downgrade is scheduled for the next billing cycle", func(t *testing.T) { |
| |
| |
| |
| |
| |
| |
| |
| currentSubscription, nextSubscription, err := deps.WorkflowService.ChangeToPlan(ctx, premiumSubscription.Subscription.NamespacedID, subscriptionworkflow.ChangeSubscriptionWorkflowInput{ |
| Timing: subscription.Timing{ |
| Enum: lo.ToPtr(subscription.TimingNextBillingCycle), |
| }, |
| }, basicPlan) |
| require.NoError(t, err) |
| require.NotNil(t, currentSubscription.ActiveTo) |
| require.NotNil(t, nextSubscription) |
|
|
| scheduledSubscription = nextSubscription |
| require.Equal(t, subscription.SubscriptionStatusScheduled, scheduledSubscription.Subscription.GetStatusAt(clock.Now())) |
| }) |
|
|
| t.Run("when the scheduled downgrade is deleted", func(t *testing.T) { |
| |
| |
| |
| |
| |
| |
| |
| require.NoError(t, deps.SubscriptionService.Delete(ctx, scheduledSubscription.Subscription.NamespacedID)) |
|
|
| _, err := deps.SubscriptionService.GetView(ctx, scheduledSubscription.Subscription.NamespacedID) |
| require.Error(t, err) |
|
|
| deletedSubscription := getSubscriptionViewIncludingDeleted(t, ctx, deps.SubscriptionService, scheduledSubscription.Subscription.NamespacedID) |
| require.Equal(t, scheduledSubscription.Subscription.ID, deletedSubscription.Subscription.ID) |
| require.NotNil(t, deletedSubscription.Subscription.DeletedAt) |
| }) |
| } |
|
|
| func getSubscriptionViewIncludingDeleted(t *testing.T, ctx context.Context, service subscription.Service, subscriptionID models.NamespacedID) subscription.SubscriptionView { |
| t.Helper() |
|
|
| subscriptions, err := service.List(ctx, subscription.ListSubscriptionsInput{ |
| Namespaces: []string{subscriptionID.Namespace}, |
| ID: &filter.FilterULID{FilterString: filter.FilterString{Eq: &subscriptionID.ID}}, |
| IncludeDeleted: true, |
| }) |
| require.NoError(t, err) |
| require.Len(t, subscriptions.Items, 1) |
|
|
| views, err := service.ExpandViews(ctx, subscriptions.Items) |
| require.NoError(t, err) |
| require.Len(t, views, 1) |
|
|
| return views[0] |
| } |
|
|