openmeter / subscription /workflow /service /subscription_test.go
Leon4gr45's picture
Upload folder using huggingface_hub (part 7)
16cdcb7 verified
Raw
History Blame Contribute Delete
86.6 kB
package service_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"testing"
"time"
"github.com/alpacahq/alpacadecimal"
"github.com/invopop/gobl/currency"
"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/productcatalog/plan"
"github.com/openmeterio/openmeter/openmeter/registry"
"github.com/openmeterio/openmeter/openmeter/subscription"
"github.com/openmeterio/openmeter/openmeter/subscription/patch"
subscriptiontestutils "github.com/openmeterio/openmeter/openmeter/subscription/testutils"
subscriptionworkflow "github.com/openmeterio/openmeter/openmeter/subscription/workflow"
workflowservice "github.com/openmeterio/openmeter/openmeter/subscription/workflow/service"
"github.com/openmeterio/openmeter/openmeter/testutils"
"github.com/openmeterio/openmeter/pkg/clock"
"github.com/openmeterio/openmeter/pkg/datetime"
"github.com/openmeterio/openmeter/pkg/featuregate"
"github.com/openmeterio/openmeter/pkg/ffx"
"github.com/openmeterio/openmeter/pkg/framework/lockr"
"github.com/openmeterio/openmeter/pkg/models"
)
func TestCreateFromPlan(t *testing.T) {
type testCaseDeps struct {
Plan subscription.Plan
CurrentTime time.Time
Customer customer.Customer
WorkflowService subscriptionworkflow.Service
DBDeps *subscriptiontestutils.DBDeps
}
testCases := []struct {
Name string
Handler func(t *testing.T, deps testCaseDeps)
}{
{
Name: "Should error if customer is not found",
Handler: func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
_, err := deps.WorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
},
CustomerID: fmt.Sprintf("nonexistent-customer-%s", deps.Customer.ID),
Namespace: subscriptiontestutils.ExampleNamespace,
}, deps.Plan)
assert.True(t, models.IsGenericNotFoundError(err), "expected customer not found error, got %T", err)
},
},
{
Name: "Should add annotations to all created items",
Handler: func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
subView, err := deps.WorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, deps.Plan)
require.Nil(t, err)
for _, phase := range subView.Phases {
for _, item := range phase.ItemsByKey {
for _, i := range item {
require.NotNil(t, i.SubscriptionItem.Annotations)
systems := subscription.AnnotationParser.ListOwnerSubSystems(i.SubscriptionItem.Annotations)
require.Contains(t, systems, subscription.OwnerSubscriptionSubSystem)
}
}
}
},
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
tcDeps := testCaseDeps{
CurrentTime: testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z"),
}
clock.SetTime(tcDeps.CurrentTime)
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
plan := deps.PlanHelper.CreatePlan(t, subscriptiontestutils.GetExamplePlanInput(t))
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
require.NotNil(t, cust)
tcDeps.Customer = *cust
tcDeps.Plan = plan
tcDeps.DBDeps = dbDeps
tcDeps.WorkflowService = deps.WorkflowService
tc.Handler(t, tcDeps)
})
}
t.Run("Should add boolean entitlement count annotations", func(t *testing.T) {
now := testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z")
clock.SetTime(now)
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
p := deps.PlanHelper.CreatePlan(t, subscriptiontestutils.BuildTestPlanInput(t).
AddPhase(nil, subscriptiontestutils.ExampleRateCard3ForAddons.Clone(), subscriptiontestutils.ExampleRateCard4ForAddons.Clone()).
Build())
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
subView, err := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &now,
},
},
CustomerID: cust.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, p)
require.Nil(t, err)
nonBoolItem := subView.Phases[0].ItemsByKey[subscriptiontestutils.ExampleRateCard3ForAddons.Key()][0]
require.NotNil(t, nonBoolItem)
require.Equal(t, 0, subscription.AnnotationParser.GetBooleanEntitlementCount(nonBoolItem.SubscriptionItem.Annotations))
boolItem := subView.Phases[0].ItemsByKey[subscriptiontestutils.ExampleRateCard4ForAddons.Key()][0]
require.NotNil(t, boolItem)
require.Equal(t, 1, subscription.AnnotationParser.GetBooleanEntitlementCount(boolItem.SubscriptionItem.Annotations))
})
}
func TestEditRunning(t *testing.T) {
type testCaseDeps struct {
CurrentTime time.Time
SubView subscription.SubscriptionView
Customer customer.Customer
WorkflowService subscriptionworkflow.Service
Service subscription.Service
DBDeps *subscriptiontestutils.DBDeps
Plan subscription.Plan
}
testCases := []struct {
Name string
Handler func(t *testing.T, deps testCaseDeps)
}{
{
Name: "Should error if subscription is not found",
Handler: func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
_, err := deps.WorkflowService.EditRunning(ctx, models.NamespacedID{
ID: "nonexistent-subscription",
Namespace: subscriptiontestutils.ExampleNamespace,
}, nil, immediate)
assert.ErrorAs(t, err, lo.ToPtr(&subscription.SubscriptionNotFoundError{}), "expected subscription not found error, got %T", err)
},
},
{
Name: "Should do nothing if no patches are provided",
Handler: func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
subView, err := deps.WorkflowService.EditRunning(ctx, deps.SubView.Subscription.NamespacedID, nil, immediate)
assert.Nil(t, err)
subscriptiontestutils.SpecsEqual(t, deps.SubView.Spec, subView.Spec)
// cannot be used as function identity always fails (in UsagePeriod.recs[*].GetTime())...
// assert.Equal(t, deps.SubView, subView)
},
},
{
Name: "Should validate the provided patches",
Handler: func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errorMsg := "this is an invalid patch"
invalidPatch := subscriptiontestutils.TestPatch{
ValdiateFn: func() error {
return errors.New(errorMsg)
},
}
_, err := deps.WorkflowService.EditRunning(ctx, deps.SubView.Subscription.NamespacedID, []subscription.Patch{&invalidPatch}, immediate)
assert.ErrorContains(t, err, errorMsg, "expected error message to contain %q, got %v", errorMsg, err)
},
},
{
Name: "Should apply the customizations on the current spec",
Handler: func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Let's increment time by 1 second
deps.CurrentTime = deps.CurrentTime.Add(time.Second)
// We have to freeze time here for the assertions
clock.FreezeTime(deps.CurrentTime)
defer clock.UnFreeze()
errMSg := "this custom patch apply failed"
patch1 := subscriptiontestutils.TestPatch{
ApplyToFn: func(spec *subscription.SubscriptionSpec, c subscription.ApplyContext) error {
// Let's assert that the correct spec is passed to the patch
assert.Equal(t, lo.ToPtr(deps.SubView.AsSpec()), spec, "expected spec to be equal to the current spec")
assert.Equal(t, subscription.ApplyContext{
CurrentTime: deps.CurrentTime,
}, c, "apply context is incorrect")
// Lets modify the spec to see if its passed to the next
spec.Name = "modified-name"
return nil
},
}
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-name", spec.Name, "expected name to be modified")
// Let's return an error to see if it is surfaced
return errors.New(errMSg)
},
}
_, err := deps.WorkflowService.EditRunning(
ctx,
deps.SubView.Subscription.NamespacedID,
[]subscription.Patch{&patch1, &patch2},
immediate,
)
assert.ErrorContains(t, err, errMSg, "expected error message to contain %q, got %v", errMSg, err)
},
},
{
Name: "Should use the output of patches without modifications",
Handler: func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
returnedSpec := deps.SubView.AsSpec()
pKey := "test_phase_1"
require.Contains(t, returnedSpec.Phases, pKey, "expected %s to be present in the starting spec", pKey)
returnedSpec.Phases[pKey].Name = "New Phase 1 Name"
returnedSpec.Phases[pKey].Description = lo.ToPtr("This is a new description")
patch1 := subscriptiontestutils.TestPatch{
ApplyToFn: func(spec *subscription.SubscriptionSpec, c subscription.ApplyContext) error {
// Let's set the new values
spec.CreateSubscriptionPlanInput = returnedSpec.CreateSubscriptionPlanInput
spec.CreateSubscriptionCustomerInput = returnedSpec.CreateSubscriptionCustomerInput
spec.Phases = returnedSpec.Phases
return nil
},
}
sID := deps.SubView.Subscription.NamespacedID
mSvc := subscriptiontestutils.MockService{
UpdateFn: func(ctx context.Context, id models.NamespacedID, spec subscription.SubscriptionSpec) (subscription.Subscription, error) {
// Let's validate that the spec is passed as is
assert.Equal(t, returnedSpec, spec, "expected spec to be equal to the returned spec")
return deps.Service.Update(ctx, id, spec)
},
GetViewFn: func(ctx context.Context, id models.NamespacedID) (subscription.SubscriptionView, error) {
assert.Equal(t, sID, id, "expected id to be equal to the returned id")
return deps.Service.GetView(ctx, id)
},
}
tuDeps := subscriptiontestutils.NewService(t, deps.DBDeps)
lockr, err := lockr.NewLocker(&lockr.LockerConfig{Logger: slog.Default()})
require.NoError(t, err)
workflowService := workflowservice.NewWorkflowService(workflowservice.WorkflowServiceConfig{
Service: &mSvc,
CustomerService: tuDeps.CustomerService,
TransactionManager: tuDeps.CustomerAdapter,
AddonService: tuDeps.SubscriptionAddonService,
Logger: slog.Default(),
Lockr: lockr,
FeatureFlags: ffx.NewTestContextService(ffx.AccessConfig{
subscription.MultiSubscriptionEnabledFF: false,
}),
})
_, err = workflowService.EditRunning(ctx, sID, []subscription.Patch{&patch1}, immediate)
assert.Nil(t, err)
},
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
tcDeps := testCaseDeps{
CurrentTime: testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z"),
}
clock.SetTime(tcDeps.CurrentTime)
defer clock.ResetTime()
// Let's build the dependencies
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
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 := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &tcDeps.CurrentTime,
},
Name: "Example Subscription",
},
CustomerID: cust.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, plan)
require.Nil(t, err)
tcDeps.SubView = sub
tcDeps.Customer = *cust
tcDeps.DBDeps = dbDeps
tcDeps.Service = deps.SubscriptionService
tcDeps.WorkflowService = deps.WorkflowService
tcDeps.Plan = plan
tc.Handler(t, tcDeps)
})
}
}
func TestEditingCurrentPhase(t *testing.T) {
type testCaseDeps struct {
CurrentTime time.Time
SubView subscription.SubscriptionView
Customer customer.Customer
WorkflowService subscriptionworkflow.Service
Service subscription.Service
ItemRepo subscription.SubscriptionItemRepository
DBDeps *subscriptiontestutils.DBDeps
Plan subscription.Plan
EntReg *registry.Entitlement
}
testCases := []struct {
Name string
Handler func(t *testing.T, deps testCaseDeps)
}{
{
Name: "Should remove item WITHOUT entitlement from the current phase starting now",
Handler: func(t *testing.T, deps testCaseDeps) {
second_phase_key := "test_phase_2"
item_key := "rate-card-2"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Let's assert we have two items in the second phase
require.GreaterOrEqual(t, len(deps.SubView.Phases), 2, "expected at least two phases")
require.GreaterOrEqual(t, len(deps.SubView.Phases[1].ItemsByKey), 2, "expected at least two items in the second phase")
require.Equal(t, second_phase_key, deps.SubView.Phases[1].SubscriptionPhase.Key, "expected the second phase to be of known key")
itOrigi, ok := deps.SubView.Phases[1].ItemsByKey[item_key]
require.True(t, ok, "expected item to be present in the second phase")
require.Len(t, itOrigi, 1, "expected one item to be present")
// Let's assert the second phase starts when we expect it to
require.Equal(t, deps.CurrentTime.AddDate(0, 1, 0), deps.SubView.Phases[1].SubscriptionPhase.ActiveFrom, "expected the second phase to start in a month")
// Let's advance the clock into the 2nd phase where we have two items
currentTime := deps.CurrentTime.AddDate(0, 1, 1)
clock.SetTime(currentTime)
// Let's freeze the time so we can assert properly
// Let's remove the item without feature & entitlement
s, err := deps.WorkflowService.EditRunning(ctx, deps.SubView.Subscription.NamespacedID, []subscription.Patch{
patch.PatchRemoveItem{
PhaseKey: second_phase_key,
ItemKey: item_key,
},
}, immediate)
require.Nil(t, err)
require.NotNil(t, s)
// Let's fetch the edited subscription and check that the item was removed effective now
subView, err := deps.Service.GetView(ctx, deps.SubView.Subscription.NamespacedID)
require.Nil(t, err)
// Let's assert that the item is present and has been marked as inactive at the given time
items, ok := subView.Phases[1].ItemsByKey[item_key]
require.True(t, ok, "expected item to be present in the second phase")
assert.Len(t, items, 1, "expected one item to be present")
tolerance := 5 * time.Second
testutils.TimeEqualsApproximately(t, currentTime, *items[0].SubscriptionItem.ActiveTo, tolerance)
// Let's check that the item did get deleted in the background
// For this, we'll need to do a bit of time travel
timeBeforeTravel := clock.Now()
clock.SetTime(currentTime.AddDate(0, 0, -1))
it, err := deps.ItemRepo.GetByID(ctx, itOrigi[0].SubscriptionItem.NamespacedID)
require.NoError(t, err)
testutils.TimeEqualsApproximately(t, currentTime, *it.DeletedAt, tolerance)
clock.SetTime(timeBeforeTravel)
},
},
{
Name: "Should remove item WITH entitlement from the current phase starting now",
Handler: func(t *testing.T, deps testCaseDeps) {
second_phase_key := "test_phase_2"
item_key := subscriptiontestutils.ExampleFeatureKey
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Let's assert we have two items in the second phase
require.GreaterOrEqual(t, len(deps.SubView.Phases), 2, "expected at least two phases")
require.GreaterOrEqual(t, len(deps.SubView.Phases[1].ItemsByKey), 2, "expected at least two items in the second phase")
require.Equal(t, second_phase_key, deps.SubView.Phases[1].SubscriptionPhase.Key, "expected the second phase to be of known key")
itOrigi, ok := deps.SubView.Phases[1].ItemsByKey[item_key]
require.True(t, ok, "expected item to be present in the second phase")
require.Len(t, itOrigi, 1, "expected one item to be present")
// Let's assert the second phase starts when we expect it to
require.Equal(t, deps.CurrentTime.AddDate(0, 1, 0), deps.SubView.Phases[1].SubscriptionPhase.ActiveFrom, "expected the second phase to start in a month")
// Let's advance the clock into the 2nd phase where we have two items
currentTime := deps.CurrentTime.AddDate(0, 1, 1)
clock.SetTime(currentTime)
// Let's freeze the time so we can assert properly
// Let's remove the item without
s, err := deps.WorkflowService.EditRunning(ctx, deps.SubView.Subscription.NamespacedID, []subscription.Patch{
patch.PatchRemoveItem{
PhaseKey: second_phase_key,
ItemKey: item_key,
},
}, immediate)
require.Nil(t, err)
require.NotNil(t, s)
// Let's fetch the edited subscription and check that the item was removed effective now
subView, err := deps.Service.GetView(ctx, deps.SubView.Subscription.NamespacedID)
require.Nil(t, err)
// Let's assert that the item is present and has been marked as inactive at the given time
items, ok := subView.Phases[1].ItemsByKey[item_key]
require.True(t, ok, "expected item to be present in the second phase")
assert.Len(t, items, 1, "expected one item to be present")
tolerance := 5 * time.Second
testutils.TimeEqualsApproximately(t, currentTime, *items[0].SubscriptionItem.ActiveTo, tolerance)
// Let's check that the item & entitlement did get deleted in the background
// For this, we'll need to do a bit of time travel
timeBeforeTravel := clock.Now()
clock.SetTime(currentTime.AddDate(0, 0, -1))
it, err := deps.ItemRepo.GetByID(ctx, itOrigi[0].SubscriptionItem.NamespacedID)
require.NoError(t, err)
testutils.TimeEqualsApproximately(t, currentTime, *it.DeletedAt, tolerance)
require.NotNil(t, it.EntitlementID)
ent, err := deps.EntReg.EntitlementRepo.GetEntitlement(ctx, models.NamespacedID{
Namespace: it.Namespace,
ID: *it.EntitlementID,
})
require.Nil(t, err)
testutils.TimeEqualsApproximately(t, currentTime, *ent.DeletedAt, tolerance)
clock.SetTime(timeBeforeTravel)
},
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
tcDeps := testCaseDeps{
CurrentTime: testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z"),
}
clock.SetTime(tcDeps.CurrentTime)
// Let's build the dependencies
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
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 := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &tcDeps.CurrentTime,
},
Name: "Example Subscription",
},
CustomerID: cust.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, plan)
require.Nil(t, err)
tcDeps.SubView = sub
tcDeps.Customer = *cust
tcDeps.DBDeps = dbDeps
tcDeps.Service = deps.SubscriptionService
tcDeps.WorkflowService = deps.WorkflowService
tcDeps.Plan = plan
tcDeps.ItemRepo = deps.ItemRepo
tcDeps.EntReg = deps.EntitlementRegistry
tc.Handler(t, tcDeps)
})
}
}
func TestEditingWithTiming(t *testing.T) {
type testCaseDeps struct {
CurrentTime time.Time
SubView subscription.SubscriptionView
Customer customer.Customer
WorkflowService subscriptionworkflow.Service
Service subscription.Service
ItemRepo subscription.SubscriptionItemRepository
DBDeps *subscriptiontestutils.DBDeps
Plan subscription.Plan
EntReg *registry.Entitlement
}
testCases := []struct {
Name string
IsAligned bool
Handler func(t *testing.T, deps testCaseDeps)
}{
{
Name: "Should work with next_billing_cycle in an aligned Subscription",
Handler: func(t *testing.T, deps testCaseDeps) {
second_phase_key := "test_phase_2"
item_key := "rate-card-2"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Let's assert we have two items in the second phase
require.GreaterOrEqual(t, len(deps.SubView.Phases), 2, "expected at least two phases")
require.GreaterOrEqual(t, len(deps.SubView.Phases[1].ItemsByKey), 2, "expected at least two items in the second phase")
require.Equal(t, second_phase_key, deps.SubView.Phases[1].SubscriptionPhase.Key, "expected the second phase to be of known key")
itOrigi, ok := deps.SubView.Phases[1].ItemsByKey[item_key]
require.True(t, ok, "expected item to be present in the second phase")
require.Len(t, itOrigi, 1, "expected one item to be present")
// Let's assert the second phase starts when we expect it to
require.Equal(t, deps.CurrentTime.AddDate(0, 1, 0), deps.SubView.Phases[1].SubscriptionPhase.ActiveFrom, "expected the second phase to start in a month")
// Let's advance the clock into the 2nd phase where we have two items
currentTime := deps.CurrentTime.AddDate(0, 1, 1)
clock.SetTime(currentTime)
defer clock.ResetTime()
// Let's remove the item without feature & entitlement at the end of the billingcycle
_, err := deps.WorkflowService.EditRunning(ctx, deps.SubView.Subscription.NamespacedID, []subscription.Patch{
patch.PatchRemoveItem{
PhaseKey: second_phase_key,
ItemKey: item_key,
},
}, subscription.Timing{
Enum: lo.ToPtr(subscription.TimingNextBillingCycle),
})
require.NoError(t, err)
},
},
{
Name: "Should error when trying to time to next_billing_cycle when that falls into a different phase (ergo no next billingcycle in the current phase)",
IsAligned: true,
Handler: func(t *testing.T, deps testCaseDeps) {
second_phase_key := "test_phase_2"
item_key := "rate-card-2"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Let's assert we have two items in the second phase
require.GreaterOrEqual(t, len(deps.SubView.Phases), 3, "expected at least three phases")
require.GreaterOrEqual(t, len(deps.SubView.Phases[1].ItemsByKey), 2, "expected at least two items in the second phase")
require.Equal(t, second_phase_key, deps.SubView.Phases[1].SubscriptionPhase.Key, "expected the second phase to be of known key")
itOrigi, ok := deps.SubView.Phases[1].ItemsByKey[item_key]
require.True(t, ok, "expected item to be present in the second phase")
require.Len(t, itOrigi, 1, "expected one item to be present")
// Let's assert the second phase starts when we expect it to
require.Equal(t, deps.CurrentTime.AddDate(0, 1, 0), deps.SubView.Phases[1].SubscriptionPhase.ActiveFrom, "expected the second phase to start in a month")
// Let's assert that the third phase starts when we expct it to
require.Equal(t, deps.CurrentTime.AddDate(0, 3, 0), deps.SubView.Phases[2].SubscriptionPhase.ActiveFrom, "expected the third phase to start in 3 months")
// Let's advance the clock into the 2nd cycle of the 2nd phase where we have two items
currentTime := deps.CurrentTime.AddDate(0, 2, 1)
clock.SetTime(currentTime)
defer clock.ResetTime()
// Let's remove the item without feature & entitlement at the end of the billingcycle
_, err := deps.WorkflowService.EditRunning(ctx, deps.SubView.Subscription.NamespacedID, []subscription.Patch{
patch.PatchRemoveItem{
PhaseKey: second_phase_key,
ItemKey: item_key,
},
}, subscription.Timing{
Enum: lo.ToPtr(subscription.TimingNextBillingCycle),
})
require.Error(t, err)
require.ErrorAs(t, err, lo.ToPtr(&models.GenericValidationError{}), "expected error to be of type models.GenericUserError")
require.ErrorContains(t, err, "cannot edit to the next billing cycle as it falls into a different phase", "expected error to be about next billing cycle, while it was: %v", err)
},
},
{
Name: "Should error when trying to time to a specified time",
IsAligned: true,
Handler: func(t *testing.T, deps testCaseDeps) {
second_phase_key := "test_phase_2"
item_key := "rate-card-2"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Let's assert we have two items in the second phase
require.GreaterOrEqual(t, len(deps.SubView.Phases), 3, "expected at least three phases")
require.GreaterOrEqual(t, len(deps.SubView.Phases[1].ItemsByKey), 2, "expected at least two items in the second phase")
require.Equal(t, second_phase_key, deps.SubView.Phases[1].SubscriptionPhase.Key, "expected the second phase to be of known key")
itOrigi, ok := deps.SubView.Phases[1].ItemsByKey[item_key]
require.True(t, ok, "expected item to be present in the second phase")
require.Len(t, itOrigi, 1, "expected one item to be present")
// Let's assert the second phase starts when we expect it to
require.Equal(t, deps.CurrentTime.AddDate(0, 1, 0), deps.SubView.Phases[1].SubscriptionPhase.ActiveFrom, "expected the second phase to start in a month")
// Let's assert that the third phase starts when we expct it to
require.Equal(t, deps.CurrentTime.AddDate(0, 3, 0), deps.SubView.Phases[2].SubscriptionPhase.ActiveFrom, "expected the third phase to start in 3 months")
// Let's advance the clock into the 2nd cycle of the 2nd phase where we have two items
currentTime := deps.CurrentTime.AddDate(0, 1, 1)
clock.SetTime(currentTime)
defer clock.ResetTime()
// Let's remove the item without feature & entitlement at the end of the billingcycle
_, err := deps.WorkflowService.EditRunning(ctx, deps.SubView.Subscription.NamespacedID, []subscription.Patch{
patch.PatchRemoveItem{
PhaseKey: second_phase_key,
ItemKey: item_key,
},
}, subscription.Timing{
Custom: lo.ToPtr(currentTime.Add(time.Hour)),
})
require.Error(t, err)
require.ErrorAs(t, err, lo.ToPtr(&models.GenericValidationError{}), "expected error to be of type models.GenericUserError")
require.ErrorContains(t, err, "cannot edit running subscription with custom timing", "expected error to be about custom timing, while it was: %v", err)
},
},
{
Name: "Should edit with the start of the next billing cycle",
IsAligned: true,
Handler: func(t *testing.T, deps testCaseDeps) {
second_phase_key := "test_phase_2"
item_key := "rate-card-2"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Let's assert we have two items in the second phase
require.GreaterOrEqual(t, len(deps.SubView.Phases), 3, "expected at least three phases")
require.GreaterOrEqual(t, len(deps.SubView.Phases[1].ItemsByKey), 2, "expected at least two items in the second phase")
require.Equal(t, second_phase_key, deps.SubView.Phases[1].SubscriptionPhase.Key, "expected the second phase to be of known key")
itOrigi, ok := deps.SubView.Phases[1].ItemsByKey[item_key]
require.True(t, ok, "expected item to be present in the second phase")
require.Len(t, itOrigi, 1, "expected one item to be present")
// Let's assert the second phase starts when we expect it to
require.Equal(t, deps.CurrentTime.AddDate(0, 1, 0), deps.SubView.Phases[1].SubscriptionPhase.ActiveFrom, "expected the second phase to start in a month")
// Let's assert that the third phase starts when we expct it to
require.Equal(t, deps.CurrentTime.AddDate(0, 3, 0), deps.SubView.Phases[2].SubscriptionPhase.ActiveFrom, "expected the third phase to start in 3 months")
// Let's advance the clock into the 2nd cycle of the 2nd phase where we have two items
currentTime := deps.CurrentTime.AddDate(0, 1, 1)
clock.SetTime(currentTime)
defer clock.ResetTime()
// Let's remove the item without feature & entitlement at the end of the billingcycle
view, err := deps.WorkflowService.EditRunning(ctx, deps.SubView.Subscription.NamespacedID, []subscription.Patch{
patch.PatchRemoveItem{
PhaseKey: second_phase_key,
ItemKey: item_key,
},
}, subscription.Timing{
Enum: lo.ToPtr(subscription.TimingNextBillingCycle),
})
require.NoError(t, err)
expectedEditTime := deps.CurrentTime.AddDate(0, 2, 0)
// Let's validate that the removed item is present until that point
items, ok := view.Phases[1].ItemsByKey[item_key]
require.True(t, ok, "expected item to be present in the second phase")
assert.Len(t, items, 1, "expected one item to be present")
testutils.TimeEqualsApproximately(t, expectedEditTime, *items[0].SubscriptionItem.ActiveTo, time.Second)
},
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
tcDeps := testCaseDeps{
CurrentTime: testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z"),
}
clock.SetTime(tcDeps.CurrentTime)
// Let's build the dependencies
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
planInput := subscriptiontestutils.GetExamplePlanInput(t)
plan := deps.PlanHelper.CreatePlan(t, planInput)
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
require.NotNil(t, cust)
// Let's create an example subscription
sub, err := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &tcDeps.CurrentTime,
},
Name: "Example Subscription",
},
CustomerID: cust.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, plan)
require.Nil(t, err)
tcDeps.SubView = sub
tcDeps.Customer = *cust
tcDeps.DBDeps = dbDeps
tcDeps.Service = deps.SubscriptionService
tcDeps.WorkflowService = deps.WorkflowService
tcDeps.Plan = plan
tcDeps.ItemRepo = deps.ItemRepo
tcDeps.EntReg = deps.EntitlementRegistry
tc.Handler(t, tcDeps)
})
}
}
func TestChangeToPlan(t *testing.T) {
// Let's define what deps a test case needs
type testCaseDeps struct {
CurrentTime time.Time
Customer customer.Customer
WorkflowService subscriptionworkflow.Service
Service subscription.Service
DBDeps *subscriptiontestutils.DBDeps
Plan1 subscription.Plan
Plan2 subscription.Plan
}
withDeps := func(t *testing.T) func(fn func(t *testing.T, deps testCaseDeps)) {
return func(fn func(t *testing.T, deps testCaseDeps)) {
// Let's define two plans. One is the example plan, and the second is a slightly modified version of that.
// Build them per invocation so plan service mutations (e.g. resolving TaxCodeIDs) don't leak across subtests.
examplePlanInput1 := subscriptiontestutils.GetExamplePlanInput(t)
rc1 := productcatalog.UsageBasedRateCard{
RateCardMeta: productcatalog.RateCardMeta{
Key: subscriptiontestutils.ExampleFeatureKey,
Name: "Rate Card 1",
Description: lo.ToPtr("Rate Card 1 Description"),
FeatureKey: lo.ToPtr(subscriptiontestutils.ExampleFeatureKey),
FeatureID: nil,
EntitlementTemplate: productcatalog.NewEntitlementTemplateFrom(productcatalog.MeteredEntitlementTemplate{
IssueAfterReset: lo.ToPtr(111.0),
UsagePeriod: subscriptiontestutils.ISOMonth,
}),
TaxConfig: &productcatalog.TaxConfig{
Stripe: &productcatalog.StripeTaxConfig{
Code: "txcd_10000001",
},
},
Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{
Amount: alpacadecimal.NewFromInt(int64(1)),
}),
},
BillingCadence: subscriptiontestutils.ISOMonth,
}
rc2 := productcatalog.FlatFeeRateCard{
RateCardMeta: productcatalog.RateCardMeta{
Key: "new-rc-2",
Name: "Rate Card 2",
Description: lo.ToPtr("Rate Card 2 Description"),
TaxConfig: &productcatalog.TaxConfig{
Stripe: &productcatalog.StripeTaxConfig{
Code: "txcd_10000001",
},
},
Price: nil,
},
BillingCadence: nil,
}
examplePlanInput2 := plan.CreatePlanInput{
NamespacedModel: models.NamespacedModel{
Namespace: subscriptiontestutils.ExampleNamespace,
},
Plan: productcatalog.Plan{
PlanMeta: productcatalog.PlanMeta{
Name: "Test Plan 2",
Key: "test_plan_2",
Version: 1,
Currency: currency.USD,
BillingCadence: datetime.MustParseDuration(t, "P1M"),
ProRatingConfig: productcatalog.ProRatingConfig{
Enabled: true,
Mode: productcatalog.ProRatingModeProratePrices,
},
SettlementMode: productcatalog.CreditThenInvoiceSettlementMode,
},
Phases: []productcatalog.Phase{
{
PhaseMeta: productcatalog.PhaseMeta{
Key: "test_phase_1_new",
Name: "Test Phase 1 New",
Description: lo.ToPtr("Test Phase 1 Description"),
Duration: lo.ToPtr(datetime.MustParseDuration(t, "P2M")),
},
RateCards: productcatalog.RateCards{
&rc1,
},
},
{
PhaseMeta: productcatalog.PhaseMeta{
Key: "test_phase_2",
Name: "Test Phase 2",
Description: lo.ToPtr("Test Phase 2 Description"),
Duration: lo.ToPtr(datetime.MustParseDuration(t, "P1M")),
},
RateCards: productcatalog.RateCards{
subscriptiontestutils.ExampleRateCard1.Clone(),
&rc2,
},
},
{
PhaseMeta: productcatalog.PhaseMeta{
Key: "test_phase_3",
Name: "Test Phase 3",
Description: lo.ToPtr("Test Phase 3 Description"),
Duration: nil,
},
RateCards: productcatalog.RateCards{
&rc1,
},
},
},
},
}
tcDeps := testCaseDeps{
CurrentTime: testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z"),
}
clock.SetTime(tcDeps.CurrentTime)
// Let's build the dependencies
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
// Let's create the two plans
plan1 := deps.PlanHelper.CreatePlan(t, examplePlanInput1)
plan2 := deps.PlanHelper.CreatePlan(t, examplePlanInput2)
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
require.NotNil(t, cust)
tcDeps.Customer = *cust
tcDeps.DBDeps = dbDeps
tcDeps.Service = deps.SubscriptionService
tcDeps.WorkflowService = deps.WorkflowService
tcDeps.Plan1 = plan1
tcDeps.Plan2 = plan2
fn(t, tcDeps)
}
}
t.Run("Should change to the new plan", func(t *testing.T) {
withDeps(t)(func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// First, let's create a subscription from the first plan
// Let's create an example subscription
sub, err := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
Name: "Example Subscription",
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, deps.Plan1)
require.Nil(t, err)
changeInput := subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Enum: lo.ToPtr(subscription.TimingNextBillingCycle),
},
Name: "New Subscription",
}
curr, new, err := deps.WorkflowService.ChangeToPlan(ctx, sub.Subscription.NamespacedID, changeInput, deps.Plan2)
require.Nil(t, err)
// Let's do some simple validations
require.Equal(t, sub.Subscription.NamespacedID, curr.NamespacedID)
require.NotNil(t, curr.ActiveTo)
require.NotNil(t, new.Subscription.PlanRef)
require.Equal(t, deps.Plan2.ToCreateSubscriptionPlanInput().Plan.Key, new.Subscription.PlanRef.Key)
// Let's check that the new plan looks as we expect
targetSpec, err := subscription.NewSpecFromPlan(deps.Plan2, subscription.CreateSubscriptionCustomerInput{
Name: changeInput.Name,
Description: changeInput.Description,
MetadataModel: changeInput.MetadataModel,
CustomerId: curr.CustomerId,
Currency: deps.Plan2.Currency(),
ActiveFrom: testutils.GetRFC3339Time(t, "2021-02-01T00:00:00Z"),
BillingAnchor: sub.Subscription.BillingAnchor,
ActiveTo: nil,
})
require.Nil(t, err)
subscriptiontestutils.ValidateSpecAndView(t, targetSpec, new)
})
})
}
func TestEditCombinations(t *testing.T) {
// Let's define what deps a test case needs
type testCaseDeps struct {
CurrentTime time.Time
Customer customer.Customer
WorkflowService subscriptionworkflow.Service
Service subscription.Service
DBDeps *subscriptiontestutils.DBDeps
Plan1 subscription.Plan
SubsDeps *subscriptiontestutils.SubscriptionDependencies
}
withDeps := func(t *testing.T) func(fn func(t *testing.T, deps testCaseDeps)) {
return func(fn func(t *testing.T, deps testCaseDeps)) {
examplePlanInput1 := subscriptiontestutils.GetExamplePlanInput(t)
tcDeps := testCaseDeps{
CurrentTime: testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z"),
}
clock.SetTime(tcDeps.CurrentTime)
// Let's build the dependencies
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
// Let's create the plan
plan1 := deps.PlanHelper.CreatePlan(t, examplePlanInput1)
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
require.NotNil(t, cust)
tcDeps.Customer = *cust
tcDeps.DBDeps = dbDeps
tcDeps.Service = deps.SubscriptionService
tcDeps.WorkflowService = deps.WorkflowService
tcDeps.Plan1 = plan1
tcDeps.SubsDeps = &deps
fn(t, tcDeps)
}
}
t.Run("Should error on edit if subscription has addons purchased", func(t *testing.T) {
now := testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z")
clock.SetTime(now)
// Let's build the dependencies
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
p, add := subscriptiontestutils.CreatePlanWithAddon(
t,
deps,
subscriptiontestutils.GetExamplePlanInput(t),
subscriptiontestutils.BuildAddonForTesting(
t,
productcatalog.EffectivePeriod{
EffectiveFrom: &now,
EffectiveTo: nil,
},
productcatalog.AddonInstanceTypeSingle,
subscriptiontestutils.ExampleAddonRateCard2.Clone(), // This will add a new item
subscriptiontestutils.ExampleAddonRateCard4.Clone(), // This will extend existing items
),
)
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
subView, err := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &now,
},
Name: "Example Subscription",
},
CustomerID: cust.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, p)
require.Nil(t, err)
_ = subscriptiontestutils.CreateAddonForSubscription(t, &deps, subView.Subscription.NamespacedID, add.NamespacedID, models.CadencedModel{
ActiveFrom: now,
ActiveTo: nil,
})
_, err = deps.WorkflowService.EditRunning(context.Background(), subView.Subscription.NamespacedID, []subscription.Patch{
patch.PatchRemoveItem{
PhaseKey: "test_phase_1",
ItemKey: subscriptiontestutils.ExampleFeatureKey,
},
}, immediate)
require.Error(t, err)
require.ErrorAs(t, err, lo.ToPtr(&models.GenericForbiddenError{}))
require.True(t, models.IsGenericForbiddenError(err))
})
t.Run("Should add boolean entitlement count annotations", func(t *testing.T) {
withDeps(t)(func(t *testing.T, deps testCaseDeps) {
plan := deps.SubsDeps.PlanHelper.CreatePlan(t, subscriptiontestutils.BuildTestPlanInput(t).
AddPhase(nil, subscriptiontestutils.ExampleRateCard3ForAddons.Clone()).
Build())
timingNow := subscription.Timing{
Enum: lo.ToPtr(subscription.TimingImmediate),
}
subView, err := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
Namespace: deps.Customer.Namespace,
CustomerID: deps.Customer.ID,
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Name: "test",
Timing: timingNow,
},
}, plan)
require.NoError(t, err)
subView, err = deps.WorkflowService.EditRunning(context.Background(), subView.Subscription.NamespacedID, []subscription.Patch{
patch.PatchAddItem{
PhaseKey: "test_phase_1",
ItemKey: subscriptiontestutils.ExampleRateCard4ForAddons.Key(),
CreateInput: subscription.SubscriptionItemSpec{
CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{
CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{
PhaseKey: "test_phase_1",
ItemKey: subscriptiontestutils.ExampleRateCard4ForAddons.Key(),
RateCard: subscriptiontestutils.ExampleRateCard4ForAddons.Clone(),
},
},
},
},
}, timingNow)
require.Nil(t, err)
t.Run("Should properly serialize and deserialize SubscriptionView", func(t *testing.T) {
viewBytes, err := json.MarshalIndent(subView, "", " ")
require.Nil(t, err)
var targetView subscription.SubscriptionView
err = json.Unmarshal(viewBytes, &targetView)
require.Nil(t, err)
// Lets test the item's ActiveFromOverrideRelativeToPhaseStart
require.Equal(t, subView.Phases[0].ItemsByKey[subscriptiontestutils.ExampleRateCard4ForAddons.Key()][0].Spec.ActiveFromOverrideRelativeToPhaseStart, targetView.Phases[0].ItemsByKey[subscriptiontestutils.ExampleRateCard4ForAddons.Key()][0].Spec.ActiveFromOverrideRelativeToPhaseStart)
})
boolItem := subView.Phases[0].ItemsByKey[subscriptiontestutils.ExampleRateCard4ForAddons.Key()][0]
require.NotNil(t, boolItem)
require.Equal(t, 1, subscription.AnnotationParser.GetBooleanEntitlementCount(boolItem.SubscriptionItem.Annotations))
})
})
t.Run("Should be able to cancel an edited subscription", func(t *testing.T) {
withDeps(t)(func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Let's create an example subscription
sub, err := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
Name: "Example Subscription",
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, deps.Plan1)
require.Nil(t, err)
// Let's make sure the sub looks as we expect it to
require.Equal(t, "test_phase_1", sub.Phases[0].SubscriptionPhase.Key)
// Let's make sure it has the rate card we're editing
values, ok := sub.Phases[1].ItemsByKey[subscriptiontestutils.ExampleFeatureKey]
require.True(t, ok)
require.Equal(t, 1, len(values))
val := values[0]
rc := val.Spec.RateCard
require.NoError(t, rc.ChangeMeta(func(m productcatalog.RateCardMeta) (productcatalog.RateCardMeta, error) {
m.Price = productcatalog.NewPriceFrom(productcatalog.UnitPrice{Amount: alpacadecimal.NewFromInt(19)})
return m, nil
}))
// Let's edit the subscription
edits := []subscription.Patch{
// Let's edit an Item that has an Entitlement Associated
patch.PatchRemoveItem{
PhaseKey: "test_phase_1",
ItemKey: subscriptiontestutils.ExampleFeatureKey,
},
patch.PatchAddItem{
PhaseKey: "test_phase_1",
ItemKey: subscriptiontestutils.ExampleFeatureKey,
CreateInput: subscription.SubscriptionItemSpec{
CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{
CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{
PhaseKey: "test_phase_1",
ItemKey: subscriptiontestutils.ExampleFeatureKey,
RateCard: rc,
},
CreateSubscriptionItemCustomerInput: subscription.CreateSubscriptionItemCustomerInput{},
},
},
},
}
// Let 5 minutes pass
clock.SetTime(deps.CurrentTime.Add(5 * time.Minute))
_, err = deps.WorkflowService.EditRunning(ctx, sub.Subscription.NamespacedID, edits, immediate)
require.Nil(t, err)
// Now let's fetch the view
view, err := deps.Service.GetView(ctx, sub.Subscription.NamespacedID)
require.Nil(t, err)
require.Equal(t, sub.Subscription.NamespacedID, view.Subscription.NamespacedID)
t.Run("Should add owner annotations", func(t *testing.T) {
items := view.Phases[0].ItemsByKey[subscriptiontestutils.ExampleFeatureKey]
addedItem := items[len(items)-1]
ownerSystems := subscription.AnnotationParser.ListOwnerSubSystems(addedItem.SubscriptionItem.Annotations)
require.Equal(t, 1, len(ownerSystems))
require.Equal(t, subscription.OwnerSubscriptionSubSystem, ownerSystems[0])
})
// Now let's cancel the subscription
s, err := deps.Service.Cancel(ctx, sub.Subscription.NamespacedID, subscription.Timing{
Enum: lo.ToPtr(subscription.TimingNextBillingCycle),
})
require.Nil(t, err)
require.Equal(t, subscription.SubscriptionStatusCanceled, s.GetStatusAt(clock.Now()))
require.Equal(t, subscription.SubscriptionStatusInactive, s.GetStatusAt(deps.CurrentTime.AddDate(0, 1, 0)))
})
})
t.Run("Let's assert that subscriptions can be cascade deleted", func(t *testing.T) {
// This will be a bit of a hacky test, but its better see it enforced than to leave it to human testing
// We'll create a subscription
// Then we'll delete the subscription
// Then we'll assert that there are no
// - phases left
// - items left
// - entitlements left
// - grants left
withDeps(t)(func(t *testing.T, deps testCaseDeps) {
// Let's create an example subscription
_, err := deps.WorkflowService.CreateFromPlan(t.Context(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
Name: "Example Subscription",
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, deps.Plan1)
require.Nil(t, err)
// Now that we've got a subscription we'll use raw SQL to do the rest, this is the hacky part
res, err := deps.DBDeps.DBClient.ExecContext(t.Context(), `DELETE FROM subscriptions`)
require.Nil(t, err)
affected, err := res.RowsAffected()
require.Nil(t, err)
require.Equal(t, int64(1), affected) // deleted the one subscription we had
// Now let's query the DB, lets encapsulate in inline functions for referential integrity
func() {
rows, err := deps.DBDeps.DBClient.QueryContext(t.Context(), `SELECT COUNT(*) FROM subscription_phases`)
require.Nil(t, err)
defer rows.Close()
for rows.Next() {
var count int
require.Nil(t, rows.Scan(&count))
require.Equal(t, 0, count)
}
}()
func() {
rows, err := deps.DBDeps.DBClient.QueryContext(t.Context(), `SELECT COUNT(*) FROM subscription_items`)
require.Nil(t, err)
defer rows.Close()
for rows.Next() {
var count int
require.Nil(t, rows.Scan(&count))
require.Equal(t, 0, count)
}
}()
func() {
rows, err := deps.DBDeps.DBClient.QueryContext(t.Context(), `SELECT COUNT(*) FROM entitlements`)
require.Nil(t, err)
defer rows.Close()
for rows.Next() {
var count int
require.Nil(t, rows.Scan(&count))
require.Equal(t, 0, count)
}
}()
func() {
rows, err := deps.DBDeps.DBClient.QueryContext(t.Context(), `SELECT COUNT(*) FROM grants`)
require.Nil(t, err)
defer rows.Close()
for rows.Next() {
var count int
require.Nil(t, rows.Scan(&count))
require.Equal(t, 0, count)
}
}()
// Just to be safe, let's also assert the things we're not deleting, being
// - customers
// - plans
// - features
func() {
rows, err := deps.DBDeps.DBClient.QueryContext(t.Context(), `SELECT COUNT(*) FROM customers`)
require.Nil(t, err)
defer rows.Close()
for rows.Next() {
var count int
require.Nil(t, rows.Scan(&count))
require.NotEqual(t, 0, count)
}
}()
func() {
rows, err := deps.DBDeps.DBClient.QueryContext(t.Context(), `SELECT COUNT(*) FROM plans`)
require.Nil(t, err)
defer rows.Close()
for rows.Next() {
var count int
require.Nil(t, rows.Scan(&count))
require.NotEqual(t, 0, count)
}
}()
func() {
rows, err := deps.DBDeps.DBClient.QueryContext(t.Context(), `SELECT COUNT(*) FROM features`)
require.Nil(t, err)
defer rows.Close()
for rows.Next() {
var count int
require.Nil(t, rows.Scan(&count))
require.NotEqual(t, 0, count)
}
}()
})
})
}
func TestRestore(t *testing.T) {
// Let's define what deps a test case needs
type testCaseDeps struct {
CurrentTime time.Time
Customer customer.Customer
WorkflowService subscriptionworkflow.Service
Service subscription.Service
DBDeps *subscriptiontestutils.DBDeps
Plan1 subscription.Plan
}
withDeps := func(t *testing.T) func(fn func(t *testing.T, deps testCaseDeps)) {
return func(fn func(t *testing.T, deps testCaseDeps)) {
examplePlanInput1 := subscriptiontestutils.GetExamplePlanInput(t)
tcDeps := testCaseDeps{
CurrentTime: testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z"),
}
clock.SetTime(tcDeps.CurrentTime)
// Let's build the dependencies
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
// Let's create the plan
plan1 := deps.PlanHelper.CreatePlan(t, examplePlanInput1)
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
require.NotNil(t, cust)
tcDeps.Customer = *cust
tcDeps.DBDeps = dbDeps
tcDeps.Service = deps.SubscriptionService
tcDeps.WorkflowService = deps.WorkflowService
tcDeps.Plan1 = plan1
fn(t, tcDeps)
}
}
t.Run("Should restore a subscription that was canceled", func(t *testing.T) {
withDeps(t)(func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Let's create an example subscription
sub, err := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
Name: "Example Subscription",
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, deps.Plan1)
require.Nil(t, err)
// Let's pass some time
clock.SetTime(clock.Now().Add(time.Hour))
// Let's cancel the subscription
s, err := deps.Service.Cancel(ctx, sub.Subscription.NamespacedID, subscription.Timing{
Enum: lo.ToPtr(subscription.TimingNextBillingCycle),
})
require.Nil(t, err)
// Let's validate its canceled
require.Equal(t, subscription.SubscriptionStatusCanceled, s.GetStatusAt(clock.Now()))
// Let's pass some more time
clock.SetTime(deps.CurrentTime.AddDate(0, 0, 1))
// Let's restore the subscription
restored, err := deps.WorkflowService.Restore(ctx, sub.Subscription.NamespacedID)
require.Nil(t, err)
// Let's validate its active
require.Equal(t, subscription.SubscriptionStatusActive, restored.GetStatusAt(clock.Now()))
})
})
t.Run("Should restore a subscription that was changed to another plan", func(t *testing.T) {
withDeps(t)(func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Let's create an example subscription
sub, err := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
Name: "Example Subscription",
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, deps.Plan1)
require.Nil(t, err)
// Let's pass some time
clock.SetTime(clock.Now().Add(time.Hour))
// Let's change to another plan (same plan, but still a change)
expectedChangeBy := deps.CurrentTime.AddDate(0, 1, 0)
old, new, err := deps.WorkflowService.ChangeToPlan(ctx, sub.Subscription.NamespacedID, subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Enum: lo.ToPtr(subscription.TimingNextBillingCycle),
},
Name: "Example Subscription 2",
}, deps.Plan1)
require.Nil(t, err)
// Let's validate the change
require.Equal(t, subscription.SubscriptionStatusCanceled, old.GetStatusAt(clock.Now()))
require.Equal(t, subscription.SubscriptionStatusScheduled, new.Subscription.GetStatusAt(clock.Now()))
require.Equal(t, subscription.SubscriptionStatusInactive, old.GetStatusAt(expectedChangeBy))
require.Equal(t, subscription.SubscriptionStatusActive, new.Subscription.GetStatusAt(expectedChangeBy))
// Let's pass some more time
clock.SetTime(clock.Now().Add(time.Hour))
// Let's restore the subscription
restored, err := deps.WorkflowService.Restore(ctx, sub.Subscription.NamespacedID)
require.Nil(t, err)
// Let's validate the restored subscription
require.Equal(t, subscription.SubscriptionStatusActive, restored.GetStatusAt(clock.Now()))
require.Equal(t, subscription.SubscriptionStatusActive, restored.GetStatusAt(expectedChangeBy))
// Let's make sure the new sub was deleted
_, err = deps.Service.GetView(ctx, new.Subscription.NamespacedID)
require.Error(t, err)
require.ErrorAs(t, err, lo.ToPtr(&subscription.SubscriptionNotFoundError{}))
})
})
t.Run("Should not allow restore if multi-subscription is enabled", func(t *testing.T) {
withDeps(t)(func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ctx = ffx.SetAccessOnContext(ctx, ffx.AccessConfig{
subscription.MultiSubscriptionEnabledFF: true,
})
// Let's create an example subscription
sub, err := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
Name: "Example Subscription",
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, deps.Plan1)
require.Nil(t, err)
// Let's pass some time
clock.SetTime(clock.Now().Add(time.Hour))
// Let's cancel the subscription
s, err := deps.Service.Cancel(ctx, sub.Subscription.NamespacedID, subscription.Timing{
Enum: lo.ToPtr(subscription.TimingNextBillingCycle),
})
require.Nil(t, err)
// Let's validate its canceled
require.Equal(t, subscription.SubscriptionStatusCanceled, s.GetStatusAt(clock.Now()))
// Let's pass some more time
clock.SetTime(deps.CurrentTime.AddDate(0, 0, 1))
// Let's restore the subscription
_, err = deps.WorkflowService.Restore(ctx, sub.Subscription.NamespacedID)
require.Error(t, err)
require.ErrorIs(t, err, subscription.ErrRestoreSubscriptionNotAllowedForMultiSubscription)
})
})
}
func TestMultiSubscription(t *testing.T) {
// Let's define what deps a test case needs
type testCaseDeps struct {
CurrentTime time.Time
Customer customer.Customer
WorkflowService subscriptionworkflow.Service
Service subscription.Service
DBDeps *subscriptiontestutils.DBDeps
Plan1 subscription.Plan
SubsDeps *subscriptiontestutils.SubscriptionDependencies
}
withDeps := func(t *testing.T) func(fn func(t *testing.T, deps testCaseDeps)) {
return func(fn func(t *testing.T, deps testCaseDeps)) {
examplePlanInput1 := subscriptiontestutils.GetExamplePlanInput(t)
tcDeps := testCaseDeps{
CurrentTime: testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z"),
}
clock.SetTime(tcDeps.CurrentTime)
// Let's build the dependencies
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
// Let's create the plan
plan1 := deps.PlanHelper.CreatePlan(t, examplePlanInput1)
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
require.NotNil(t, cust)
tcDeps.Customer = *cust
tcDeps.DBDeps = dbDeps
tcDeps.Service = deps.SubscriptionService
tcDeps.WorkflowService = deps.WorkflowService
tcDeps.Plan1 = plan1
tcDeps.SubsDeps = &deps
fn(t, tcDeps)
}
}
t.Run("Should only allow one subscription per customer at a given time", func(t *testing.T) {
withDeps(t)(func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
ctx = ffx.SetAccessOnContext(ctx, ffx.AccessConfig{
subscription.MultiSubscriptionEnabledFF: false,
})
// Let's create an example subscription
_, err := deps.WorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
Name: "Example Subscription",
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, deps.Plan1)
require.Nil(t, err)
// Now let's try to create a second subscription
_, err = deps.WorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
Name: "Example Subscription 2",
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, deps.Plan1)
require.Error(t, err)
issues, err := models.AsValidationIssues(err)
require.NoError(t, err)
require.Len(t, issues, 2)
for _, issue := range issues {
require.Equal(t, subscription.ErrOnlySingleSubscriptionAllowed.Code(), issue.Code())
}
})
})
t.Run("Should still error if multi-subscription when relevant items conflict", func(t *testing.T) {
withDeps(t)(func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
ctx = ffx.SetAccessOnContext(ctx, ffx.AccessConfig{
subscription.MultiSubscriptionEnabledFF: true,
})
// Let's create an example subscription
_, err := deps.WorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
Name: "Example Subscription",
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, deps.Plan1)
require.Nil(t, err)
// Now let's try to create a second subscription with the exact same contents (resulting in a conflict on the entitlement)
_, err = deps.WorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
Name: "Example Subscription 2",
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, deps.Plan1)
require.Error(t, err)
issues, err := models.AsValidationIssues(err)
require.NoError(t, err)
require.Len(t, issues, 12) // We'll have two issues per conflicting items (one for each side)
for _, issue := range issues {
require.Equal(t, subscription.ErrOnlySingleSubscriptionItemAllowedAtATime.Code(), issue.Code())
}
})
})
t.Run("Should not error if two overlapping subscriptions don't share relevant items", func(t *testing.T) {
withDeps(t)(func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
ctx = ffx.SetAccessOnContext(ctx, ffx.AccessConfig{
subscription.MultiSubscriptionEnabledFF: true,
})
// Let's set up two plans with different features
plan1 := deps.SubsDeps.PlanHelper.CreatePlan(t, subscriptiontestutils.BuildTestPlanInput(t).
AddPhase(nil, &productcatalog.FlatFeeRateCard{
RateCardMeta: productcatalog.RateCardMeta{
Name: "feature1",
Key: subscriptiontestutils.ExampleFeatureKey,
FeatureKey: lo.ToPtr(subscriptiontestutils.ExampleFeatureKey),
FeatureID: nil,
Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{
Amount: alpacadecimal.NewFromInt(int64(100)),
}),
},
}).
Build())
plan2 := deps.SubsDeps.PlanHelper.CreatePlan(t, subscriptiontestutils.BuildTestPlanInput(t).
AddPhase(nil, &productcatalog.FlatFeeRateCard{
RateCardMeta: productcatalog.RateCardMeta{
Name: "feature2",
Key: subscriptiontestutils.ExampleFeatureKey2,
},
}).Build())
// Let's create two subscriptions with the plans
_, err := deps.WorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, plan1)
require.Nil(t, err)
_, err = deps.WorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, plan2)
require.Nil(t, err)
})
})
}
func TestSubscriptionChangeTrackingAnnotations(t *testing.T) {
// Let's define what deps a test case needs
type testCaseDeps struct {
CurrentTime time.Time
Customer customer.Customer
WorkflowService subscriptionworkflow.Service
Service subscription.Service
DBDeps *subscriptiontestutils.DBDeps
Plan1 subscription.Plan
Plan2 subscription.Plan
SubsDeps *subscriptiontestutils.SubscriptionDependencies
}
withDeps := func(t *testing.T) func(fn func(t *testing.T, deps testCaseDeps)) {
return func(fn func(t *testing.T, deps testCaseDeps)) {
examplePlanInput1 := subscriptiontestutils.GetExamplePlanInput(t)
examplePlanInput2 := subscriptiontestutils.GetExamplePlanInput(t)
examplePlanInput2.Key = "example-plan-2"
examplePlanInput2.Name = "Example Plan 2"
tcDeps := testCaseDeps{
CurrentTime: testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z"),
}
clock.SetTime(tcDeps.CurrentTime)
// Let's build the dependencies
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
// Let's create the two plans
plan1 := deps.PlanHelper.CreatePlan(t, examplePlanInput1)
plan2 := deps.PlanHelper.CreatePlan(t, examplePlanInput2)
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
require.NotNil(t, cust)
tcDeps.Customer = *cust
tcDeps.DBDeps = dbDeps
tcDeps.Service = deps.SubscriptionService
tcDeps.WorkflowService = deps.WorkflowService
tcDeps.Plan1 = plan1
tcDeps.Plan2 = plan2
tcDeps.SubsDeps = &deps
fn(t, tcDeps)
}
}
t.Run("Should set annotations when changing subscription to new plan", func(t *testing.T) {
withDeps(t)(func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create first subscription
sub1, err := deps.WorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
Name: "First Subscription",
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, deps.Plan1)
require.Nil(t, err)
// Change to new plan
curr, new, err := deps.WorkflowService.ChangeToPlan(ctx, sub1.Subscription.NamespacedID, subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Enum: lo.ToPtr(subscription.TimingNextBillingCycle),
},
Name: "Second Subscription",
}, deps.Plan2)
require.Nil(t, err)
// Verify old subscription has superseding subscription ID
currView, err := deps.Service.GetView(ctx, curr.NamespacedID)
require.Nil(t, err)
require.NotNil(t, currView.Subscription.Annotations)
supersedingID := subscription.AnnotationParser.GetSupersedingSubscriptionID(currView.Subscription.Annotations)
require.NotNil(t, supersedingID)
assert.Equal(t, new.Subscription.ID, *supersedingID)
// Verify new subscription has previous subscription ID
newView, err := deps.Service.GetView(ctx, new.Subscription.NamespacedID)
require.Nil(t, err)
require.NotNil(t, newView.Subscription.Annotations)
previousID := subscription.AnnotationParser.GetPreviousSubscriptionID(newView.Subscription.Annotations)
require.NotNil(t, previousID)
assert.Equal(t, curr.ID, *previousID)
})
})
t.Run("Should clean up annotations when restoring subscription that superseded another", func(t *testing.T) {
withDeps(t)(func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create first subscription
sub1, err := deps.WorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
Name: "First Subscription",
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, deps.Plan1)
require.Nil(t, err)
// Change to new plan - this creates sub2 and links sub1->sub2
curr, new, err := deps.WorkflowService.ChangeToPlan(ctx, sub1.Subscription.NamespacedID, subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Enum: lo.ToPtr(subscription.TimingNextBillingCycle),
},
Name: "Second Subscription",
}, deps.Plan2)
require.Nil(t, err)
// Verify annotations are set
currView, err := deps.Service.GetView(ctx, curr.NamespacedID)
require.Nil(t, err)
require.NotNil(t, currView.Subscription.Annotations)
supersedingID := subscription.AnnotationParser.GetSupersedingSubscriptionID(currView.Subscription.Annotations)
require.NotNil(t, supersedingID)
assert.Equal(t, new.Subscription.ID, *supersedingID)
// Restore the original subscription (this deletes sub2)
clock.SetTime(clock.Now().Add(time.Hour))
_, err = deps.WorkflowService.Restore(ctx, curr.NamespacedID)
require.Nil(t, err)
// Verify sub1 no longer has superseding subscription ID
currViewAfter, err := deps.Service.GetView(ctx, curr.NamespacedID)
require.Nil(t, err)
if currViewAfter.Subscription.Annotations != nil {
supersedingIDAfter := subscription.AnnotationParser.GetSupersedingSubscriptionID(currViewAfter.Subscription.Annotations)
assert.Nil(t, supersedingIDAfter)
}
// Verify sub2 is deleted
_, err = deps.Service.GetView(ctx, new.Subscription.NamespacedID)
require.Error(t, err)
require.ErrorAs(t, err, lo.ToPtr(&subscription.SubscriptionNotFoundError{}))
})
})
t.Run("Should clean up annotations when restoring subscription that was superseded", func(t *testing.T) {
withDeps(t)(func(t *testing.T, deps testCaseDeps) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create first subscription
sub1, err := deps.WorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Custom: &deps.CurrentTime,
},
Name: "First Subscription",
},
CustomerID: deps.Customer.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, deps.Plan1)
require.Nil(t, err)
// Change to new plan - this creates sub2 and links sub1->sub2
curr, new, err := deps.WorkflowService.ChangeToPlan(ctx, sub1.Subscription.NamespacedID, subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Enum: lo.ToPtr(subscription.TimingNextBillingCycle),
},
Name: "Second Subscription",
}, deps.Plan2)
require.Nil(t, err)
// Verify sub2 has previous subscription ID
newView, err := deps.Service.GetView(ctx, new.Subscription.NamespacedID)
require.Nil(t, err)
require.NotNil(t, newView.Subscription.Annotations)
previousID := subscription.AnnotationParser.GetPreviousSubscriptionID(newView.Subscription.Annotations)
require.NotNil(t, previousID)
assert.Equal(t, curr.ID, *previousID)
// Restore the original subscription (this deletes sub2)
clock.SetTime(clock.Now().Add(time.Hour))
_, err = deps.WorkflowService.Restore(ctx, curr.NamespacedID)
require.Nil(t, err)
// Verify sub2 is deleted
_, err = deps.Service.GetView(ctx, new.Subscription.NamespacedID)
require.Error(t, err)
require.ErrorAs(t, err, lo.ToPtr(&subscription.SubscriptionNotFoundError{}))
})
})
}
var immediate = subscription.Timing{
Enum: lo.ToPtr(subscription.TimingImmediate),
}
func TestSettlementMode(t *testing.T) {
t.Run("CreateFromPlan uses default settlement mode", func(t *testing.T) {
now := testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z")
clock.SetTime(now)
defer clock.ResetTime()
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
p := deps.PlanHelper.CreatePlan(t, subscriptiontestutils.GetExamplePlanInput(t))
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
subView, err := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{Custom: &now},
Name: "Default Settlement",
},
CustomerID: cust.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, p)
require.NoError(t, err)
assert.Equal(t, productcatalog.CreditThenInvoiceSettlementMode, subView.Subscription.SettlementMode,
"subscription should have credit_then_invoice settlement mode by default")
assert.Equal(t, productcatalog.CreditThenInvoiceSettlementMode, subView.Spec.SettlementMode,
"spec should have credit_then_invoice settlement mode by default")
})
t.Run("CreateFromPlan with CreditOnly plan propagates settlement mode", func(t *testing.T) {
now := testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z")
clock.SetTime(now)
defer clock.ResetTime()
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
planInput := subscriptiontestutils.GetExamplePlanInput(t)
planInput.SettlementMode = productcatalog.CreditOnlySettlementMode
p := deps.PlanHelper.CreatePlan(t, planInput)
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
subView, err := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{Custom: &now},
Name: "CreditOnly Settlement",
},
CustomerID: cust.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, p)
require.NoError(t, err)
assert.Equal(t, productcatalog.CreditOnlySettlementMode, subView.Subscription.SettlementMode,
"subscription should have credit_only settlement mode from plan")
assert.Equal(t, productcatalog.CreditOnlySettlementMode, subView.Spec.SettlementMode,
"spec should have credit_only settlement mode from plan")
})
t.Run("Custom subscription with CreditOnly settlement mode", func(t *testing.T) {
now := testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z")
clock.SetTime(now)
defer clock.ResetTime()
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
spec, err := subscriptiontestutils.BuildTestSubscriptionSpec(t).
AddPhase(nil, subscriptiontestutils.ExampleRateCard1.Clone()).
Build()
require.NoError(t, err)
spec.SettlementMode = productcatalog.CreditOnlySettlementMode
spec.Plan = nil // custom subscription without plan reference
spec.CustomerId = cust.ID
spec.ActiveFrom = now
spec.BillingAnchor = now
sub, err := deps.SubscriptionService.Create(context.Background(), subscriptiontestutils.ExampleNamespace, spec)
require.NoError(t, err)
assert.Equal(t, productcatalog.CreditOnlySettlementMode, sub.SettlementMode,
"custom subscription should have credit_only settlement mode")
// Verify via GetView
view, err := deps.SubscriptionService.GetView(context.Background(), sub.NamespacedID)
require.NoError(t, err)
assert.Equal(t, productcatalog.CreditOnlySettlementMode, view.Subscription.SettlementMode,
"persisted subscription should have credit_only settlement mode")
assert.Equal(t, productcatalog.CreditOnlySettlementMode, view.Spec.SettlementMode,
"spec should have credit_only settlement mode")
})
t.Run("EditRunning cannot change settlement mode", func(t *testing.T) {
now := testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z")
clock.SetTime(now)
defer clock.ResetTime()
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
p := deps.PlanHelper.CreatePlan(t, subscriptiontestutils.GetExamplePlanInput(t))
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
subView, err := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{Custom: &now},
Name: "Immutable Settlement",
},
CustomerID: cust.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, p)
require.NoError(t, err)
assert.Equal(t, productcatalog.CreditThenInvoiceSettlementMode, subView.Subscription.SettlementMode)
// Attempt to change settlement mode via a patch that modifies the spec
tamperPatch := subscriptiontestutils.TestPatch{
ApplyToFn: func(spec *subscription.SubscriptionSpec, c subscription.ApplyContext) error {
spec.SettlementMode = productcatalog.CreditOnlySettlementMode
return nil
},
}
_, err = deps.WorkflowService.EditRunning(
context.Background(),
subView.Subscription.NamespacedID,
[]subscription.Patch{&tamperPatch},
immediate,
)
require.Error(t, err, "editing settlement mode on a running subscription should fail")
assert.Contains(t, err.Error(), "settlement mode",
"error should mention settlement mode")
viewAfter, getErr := deps.SubscriptionService.GetView(context.Background(), subView.Subscription.NamespacedID)
require.NoError(t, getErr)
assert.Equal(t, productcatalog.CreditThenInvoiceSettlementMode, viewAfter.Subscription.SettlementMode,
"settlement mode should remain unchanged after rejected edit")
assert.Equal(t, productcatalog.CreditThenInvoiceSettlementMode, viewAfter.Spec.SettlementMode,
"spec settlement mode should remain unchanged after rejected edit")
})
t.Run("CreateFromPlan with CreditOnly plan is rejected when credits are disabled in feature gate", func(t *testing.T) {
now := testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z")
clock.SetTime(now)
defer clock.ResetTime()
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
planInput := subscriptiontestutils.GetExamplePlanInput(t)
planInput.SettlementMode = productcatalog.CreditOnlySettlementMode
p := deps.PlanHelper.CreatePlan(t, planInput)
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
// credits disabled via feature gate context
ctx := context.WithValue(context.Background(), featuregate.FeatureFlag("om_ff_credits_enabled"), false)
_, err := deps.WorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{Custom: &now},
Name: "CreditOnly Rejected",
},
CustomerID: cust.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, p)
require.ErrorContains(t, err, "validation error: credits are not enabled on this deployment of OpenMeter")
})
t.Run("CreateFromPlan with CreditOnly plan succeeds when credits are enabled in feature gate", func(t *testing.T) {
now := testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z")
clock.SetTime(now)
defer clock.ResetTime()
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
planInput := subscriptiontestutils.GetExamplePlanInput(t)
planInput.SettlementMode = productcatalog.CreditOnlySettlementMode
p := deps.PlanHelper.CreatePlan(t, planInput)
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
// credits enabled via feature gate context
ctx := context.WithValue(context.Background(), featuregate.FeatureFlag("om_ff_credits_enabled"), true)
_, err := deps.WorkflowService.CreateFromPlan(ctx, subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{Custom: &now},
Name: "CreditOnly Allowed",
},
CustomerID: cust.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, p)
require.NoError(t, err)
})
t.Run("ChangeToPlan with CreditOnly plan is rejected when credits are disabled in feature gate", func(t *testing.T) {
now := testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z")
clock.SetTime(now)
defer clock.ResetTime()
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
basePlan := deps.PlanHelper.CreatePlan(t, subscriptiontestutils.GetExamplePlanInput(t))
creditOnlyPlanInput := subscriptiontestutils.GetExamplePlanInput(t)
creditOnlyPlanInput.Key = "credit_only_plan"
creditOnlyPlanInput.SettlementMode = productcatalog.CreditOnlySettlementMode
creditOnlyPlan := deps.PlanHelper.CreatePlan(t, creditOnlyPlanInput)
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
// Create a base subscription on the regular plan
sub, err := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{Custom: &now},
Name: "Base Subscription",
},
CustomerID: cust.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, basePlan)
require.NoError(t, err)
// credits disabled via feature gate context
ctx := context.WithValue(context.Background(), featuregate.FeatureFlag("om_ff_credits_enabled"), false)
_, _, err = deps.WorkflowService.ChangeToPlan(ctx, sub.Subscription.NamespacedID, subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{
Enum: lo.ToPtr(subscription.TimingNextBillingCycle),
},
Name: "Change to CreditOnly",
}, creditOnlyPlan)
require.ErrorContains(t, err, "validation error: credits are not enabled on this deployment of OpenMeter")
})
}
func TestCreateFromPlanSnapshotsUnitConfig(t *testing.T) {
// given:
// - a plan whose usage-based rate card carries a unit_config
// when:
// - a subscription is created from it and reloaded from the database
// then:
// - the persisted subscription item retains the unit_config, proving the snapshot survives
// subscription persistence. Without the column the config
// is dropped on write/read and this reloads as nil.
now := testutils.GetRFC3339Time(t, "2021-01-01T00:00:00Z")
clock.SetTime(now)
defer clock.ResetTime()
dbDeps := subscriptiontestutils.SetupDBDeps(t)
require.NotNil(t, dbDeps)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
deps.FeatureConnector.CreateExampleFeatures(t, deps.ExampleMeterID)
unitConfig := &productcatalog.UnitConfig{
Operation: productcatalog.UnitConfigOperationDivide,
ConversionFactor: alpacadecimal.NewFromInt(1000),
Rounding: productcatalog.UnitConfigRoundingModeCeiling,
}
// ExampleRateCard1 is usage-based with a UnitPrice, which supports unit_config.
rc := subscriptiontestutils.ExampleRateCard1
rc.UnitConfig = unitConfig
p := deps.PlanHelper.CreatePlan(t, subscriptiontestutils.BuildTestPlanInput(t).
AddPhase(nil, &rc).
Build())
cust := deps.CustomerAdapter.CreateExampleCustomer(t)
require.NotNil(t, cust)
subView, err := deps.WorkflowService.CreateFromPlan(context.Background(), subscriptionworkflow.CreateSubscriptionWorkflowInput{
ChangeSubscriptionWorkflowInput: subscriptionworkflow.ChangeSubscriptionWorkflowInput{
Timing: subscription.Timing{Custom: &now},
},
CustomerID: cust.ID,
Namespace: subscriptiontestutils.ExampleNamespace,
}, p)
require.NoError(t, err)
// Reload from the database — exercises the persistence round-trip through the new column.
reloaded, err := deps.SubscriptionService.GetView(context.Background(), subView.Subscription.NamespacedID)
require.NoError(t, err)
items := reloaded.Phases[0].ItemsByKey[subscriptiontestutils.ExampleRateCard1.Key()]
require.Len(t, items, 1)
got := items[0].SubscriptionItem.RateCard.AsMeta().UnitConfig
require.NotNil(t, got, "unit_config must survive subscription persistence; nil means it was dropped on write/read")
assert.True(t, got.Equal(unitConfig), "reloaded unit_config must equal the authored config, got %+v", got)
}