openmeter / subscription /addon /diff /apply_test.go
Leon4gr45's picture
Upload folder using huggingface_hub (part 6)
d6f631f verified
Raw
History Blame Contribute Delete
35.3 kB
package addondiff_test
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
"github.com/alpacahq/alpacadecimal"
"github.com/samber/lo"
"github.com/stretchr/testify/require"
"github.com/openmeterio/openmeter/openmeter/app"
"github.com/openmeterio/openmeter/openmeter/productcatalog"
"github.com/openmeterio/openmeter/openmeter/subscription"
subscriptionaddon "github.com/openmeterio/openmeter/openmeter/subscription/addon"
addondiff "github.com/openmeterio/openmeter/openmeter/subscription/addon/diff"
subscriptiontestutils "github.com/openmeterio/openmeter/openmeter/subscription/testutils"
"github.com/openmeterio/openmeter/openmeter/taxcode"
"github.com/openmeterio/openmeter/pkg/clock"
"github.com/openmeterio/openmeter/pkg/datetime"
"github.com/openmeterio/openmeter/pkg/models"
"github.com/openmeterio/openmeter/pkg/timeutil"
)
var now, _ = time.Parse(time.RFC3339, "2025-04-01T00:00:00Z")
func TestApply(t *testing.T) {
type tcDeps struct {
deps subscriptiontestutils.SubscriptionDependencies
}
// TODO: we could write purer tests here (without depending on the services) but this is simply more convenient for now
runWithDeps := func(t *testing.T, fn func(t *testing.T, deps *tcDeps)) {
clock.FreezeTime(now.Add(time.Millisecond))
defer func() {
clock.UnFreeze()
clock.ResetTime()
}()
dbDeps := subscriptiontestutils.SetupDBDeps(t)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
fn(t, &tcDeps{
deps: deps,
})
}
t.Run("Should return nil when there's no quantities listed", func(t *testing.T) {
runWithDeps(t, func(t *testing.T, deps *tcDeps) {
p, a := subscriptiontestutils.CreatePlanWithAddon(
t,
deps.deps,
subscriptiontestutils.GetExamplePlanInput(t),
subscriptiontestutils.GetExampleAddonInput(t, productcatalog.EffectivePeriod{
EffectiveFrom: &now,
EffectiveTo: nil,
}),
)
subView := subscriptiontestutils.CreateSubscriptionFromPlan(t, &deps.deps, p, now)
subsAdd := subscriptiontestutils.CreateAddonForSubscription(t, &deps.deps, subView.Subscription.NamespacedID, a.NamespacedID, models.CadencedModel{
ActiveFrom: now,
ActiveTo: nil,
})
// Let's manually overwrite the quantity, it will be fine now
subsAdd.Quantities = timeutil.NewTimeline([]timeutil.Timed[subscriptionaddon.SubscriptionAddonQuantity]{})
diffable, err := addondiff.GetDiffableFromAddon(subView, subsAdd)
require.NoError(t, err)
require.Nil(t, diffable)
})
})
t.Run("Should add the new item to the subscription", func(t *testing.T) {
runWithDeps(t, func(t *testing.T, deps *tcDeps) {
p, a := subscriptiontestutils.CreatePlanWithAddon(
t,
deps.deps,
subscriptiontestutils.GetExamplePlanInput(t),
subscriptiontestutils.BuildAddonForTesting(t,
productcatalog.EffectivePeriod{
EffectiveFrom: &now,
EffectiveTo: nil,
},
productcatalog.AddonInstanceTypeSingle,
subscriptiontestutils.ExampleAddonRateCard2.Clone(),
),
)
subView := subscriptiontestutils.CreateSubscriptionFromPlan(t, &deps.deps, p, now)
subsAdd := subscriptiontestutils.CreateAddonForSubscription(t, &deps.deps, subView.Subscription.NamespacedID, a.NamespacedID, models.CadencedModel{
ActiveFrom: now,
ActiveTo: nil,
})
diffable, err := addondiff.GetDiffableFromAddon(subView, subsAdd)
require.NoError(t, err)
spec := subView.Spec
err = spec.Apply(diffable.GetApplies(), subscription.ApplyContext{CurrentTime: now})
require.NoError(t, err)
// New item should be present in all phases
for _, p := range spec.GetSortedPhases() {
b, _ := json.MarshalIndent(p, "", " ")
itemHistory, ok := p.ItemsByKey[subscriptiontestutils.ExampleAddonRateCard2.Key()]
require.True(t, ok, "item history missing in phase %s, got %+v, \nfull: %s", p.PhaseKey, lo.Keys(p.ItemsByKey), string(b))
// It should have a single entry in all phases
require.Len(t, itemHistory, 1)
item := itemHistory[0]
// It should last for the entire phase
pCad, _ := spec.GetPhaseCadence(p.PhaseKey)
cad := item.GetCadence(pCad)
require.True(t, cad.Equal(pCad))
// It should not have any overrides
require.Nil(t, item.ActiveFromOverrideRelativeToPhaseStart)
require.Nil(t, item.ActiveToOverrideRelativeToPhaseStart)
// It should not have any owner annotations
require.Empty(t, subscription.AnnotationParser.ListOwnerSubSystems(item.Annotations))
// It should have the proper RateCard info
require.True(t, subscriptiontestutils.ExampleAddonRateCard2.Equal(item.RateCard))
}
})
})
t.Run("Should add multiple instances of new item to the subscription", func(t *testing.T) {
runWithDeps(t, func(t *testing.T, deps *tcDeps) {
exampleAddonRateCard3, ok := (subscriptiontestutils.ExampleAddonRateCard3.Clone()).(*productcatalog.FlatFeeRateCard)
require.True(t, ok, "expected FlatFeeRateCard, got %T", exampleAddonRateCard3)
p, a := subscriptiontestutils.CreatePlanWithAddon(
t,
deps.deps,
subscriptiontestutils.GetExamplePlanInput(t),
subscriptiontestutils.BuildAddonForTesting(t,
productcatalog.EffectivePeriod{
EffectiveFrom: &now,
EffectiveTo: nil,
},
productcatalog.AddonInstanceTypeSingle,
exampleAddonRateCard3,
),
)
subView := subscriptiontestutils.CreateSubscriptionFromPlan(t, &deps.deps, p, now)
subsAdd := subscriptiontestutils.CreateAddonForSubscription(t, &deps.deps, subView.Subscription.NamespacedID, a.NamespacedID, models.CadencedModel{
ActiveFrom: now,
ActiveTo: nil,
})
// Let's just overwrite the quantity, it will be fine now
val := subsAdd.Quantities.GetAt(0).GetValue()
val.Quantity = 3
subsAdd.Quantities = timeutil.NewTimeline([]timeutil.Timed[subscriptionaddon.SubscriptionAddonQuantity]{
val.AsTimed(),
})
diffable, err := addondiff.GetDiffableFromAddon(subView, subsAdd)
require.NoError(t, err)
spec := subView.Spec
err = spec.Apply(diffable.GetApplies(), subscription.ApplyContext{CurrentTime: now})
require.NoError(t, err)
// New item should be present in all phases
for _, p := range spec.GetSortedPhases() {
b, _ := json.MarshalIndent(p, "", " ")
itemHistory, ok := p.ItemsByKey[exampleAddonRateCard3.Key()]
require.True(t, ok, "item history missing in phase %s, got %+v, \nfull: %s", p.PhaseKey, lo.Keys(p.ItemsByKey), string(b))
// It should have a single entry in all phases
require.Len(t, itemHistory, 1)
item := itemHistory[0]
// It should last for the entire phase
pCad, _ := spec.GetPhaseCadence(p.PhaseKey)
cad := item.GetCadence(pCad)
require.True(t, cad.Equal(pCad))
// It should not have any overrides
require.Nil(t, item.ActiveFromOverrideRelativeToPhaseStart)
require.Nil(t, item.ActiveToOverrideRelativeToPhaseStart)
// It should not have any owner annotations
require.Empty(t, subscription.AnnotationParser.ListOwnerSubSystems(item.Annotations))
pr, _ := item.RateCard.AsMeta().Price.AsFlat()
require.Equal(t, int64(300), pr.Amount.IntPart())
// It should have the proper RateCard info, which is price * quantity + bool access
compareRateCardsWithAmountChange(t, exampleAddonRateCard3, 100*3, item.RateCard, "phase %s", p.PhaseKey)
}
})
})
t.Run("Should add item for defined cadence of the addon", func(t *testing.T) {
runWithDeps(t, func(t *testing.T, deps *tcDeps) {
effPer := productcatalog.EffectivePeriod{
EffectiveFrom: lo.ToPtr(now),
}
p, a := subscriptiontestutils.CreatePlanWithAddon(
t,
deps.deps,
subscriptiontestutils.GetExamplePlanInput(t),
subscriptiontestutils.BuildAddonForTesting(t,
effPer,
productcatalog.AddonInstanceTypeSingle,
subscriptiontestutils.ExampleAddonRateCard3.Clone(),
),
)
subView := subscriptiontestutils.CreateSubscriptionFromPlan(t, &deps.deps, p, now)
subAddCadence := models.CadencedModel{
ActiveFrom: now.AddDate(0, 0, 3),
ActiveTo: lo.ToPtr(now.AddDate(0, 1, 8)),
}
subsAdd := subscriptiontestutils.CreateAddonForSubscription(t, &deps.deps, subView.Subscription.NamespacedID, a.NamespacedID, subAddCadence)
diffable, err := addondiff.GetDiffableFromAddon(subView, subsAdd)
require.NoError(t, err)
spec := subView.Spec
err = spec.Apply(diffable.GetApplies(), subscription.ApplyContext{CurrentTime: now})
require.NoError(t, err)
// We should have items in the first 2 phases, then none in later phases
for pIdx, p := range spec.GetSortedPhases() {
if pIdx >= 2 {
require.NotContains(t, p.ItemsByKey, subscriptiontestutils.ExampleAddonRateCard3.Key())
continue
}
b, _ := json.MarshalIndent(p, "", " ")
items, ok := p.ItemsByKey[subscriptiontestutils.ExampleAddonRateCard3.Key()]
require.True(t, ok, "item missing in phase %s, got %+v, \nfull: %s", p.PhaseKey, lo.Keys(p.ItemsByKey), b)
require.Len(t, items, 1)
item := items[0]
pCad, err := spec.GetPhaseCadence(p.PhaseKey)
require.NoError(t, err)
cad := item.GetCadence(pCad)
// Items should always fall in the effective period
require.True(t, effPer.AsPeriod().IsSupersetOf(cad.AsPeriod()), "phase %s: item %s not in effective period %s", p.PhaseKey, cad.AsPeriod(), effPer.AsPeriod())
// And should always be in the phase
require.True(t, pCad.AsPeriod().IsSupersetOf(cad.AsPeriod()), "phase %s: item %s not in phase %s", p.PhaseKey, cad.AsPeriod(), pCad.AsPeriod())
// Now lets be exact on how this should look
switch pIdx {
case 0:
require.True(t, cad.ActiveFrom.Equal(subAddCadence.ActiveFrom))
require.True(t, cad.ActiveTo.Equal(*pCad.ActiveTo))
case 1:
require.True(t, cad.ActiveFrom.Equal(pCad.ActiveFrom))
require.True(t, cad.ActiveTo.Equal(*subAddCadence.ActiveTo))
}
}
})
})
t.Run("Should add a boolean entitlement to an existing item without entitlement with proper annotations", func(t *testing.T) {
runWithDeps(t, func(t *testing.T, deps *tcDeps) {
env := buildSubAndAddon(
t,
&deps.deps,
subscriptiontestutils.BuildTestPlanInput(t).
AddPhase(nil, subscriptiontestutils.ExampleRateCard5ForAddons.Clone()).
Build(),
subscriptiontestutils.BuildAddonForTesting(t, productcatalog.EffectivePeriod{
EffectiveFrom: &now,
}, productcatalog.AddonInstanceTypeMultiple, subscriptiontestutils.ExampleAddonRateCard6.Clone()),
models.CadencedModel{
ActiveFrom: now,
ActiveTo: nil,
},
)
spec := env.subView.Spec
// Let's apply the diff
err := spec.Apply(env.diffable.GetApplies(), subscription.ApplyContext{CurrentTime: now})
require.NoError(t, err)
// Let's assert that we have a boolean entitlement and the item has the proper annotations
items := spec.Phases["test_phase_1"].ItemsByKey[subscriptiontestutils.ExampleRateCard5ForAddons.Key()]
require.Len(t, items, 1)
item := items[0]
require.Equal(t, 1, subscription.AnnotationParser.GetBooleanEntitlementCount(item.Annotations))
})
})
t.Run("Should increment boolean entitlement count annotation", func(t *testing.T) {
runWithDeps(t, func(t *testing.T, deps *tcDeps) {
env := buildSubAndAddon(
t,
&deps.deps,
subscriptiontestutils.BuildTestPlanInput(t).
AddPhase(nil, subscriptiontestutils.ExampleRateCard4ForAddons.Clone()).
Build(),
subscriptiontestutils.BuildAddonForTesting(t, productcatalog.EffectivePeriod{
EffectiveFrom: &now,
}, productcatalog.AddonInstanceTypeMultiple, subscriptiontestutils.ExampleAddonRateCard6.Clone()),
models.CadencedModel{
ActiveFrom: now,
ActiveTo: nil,
},
)
spec := env.subView.Spec
item := spec.Phases["test_phase_1"].ItemsByKey[subscriptiontestutils.ExampleAddonRateCard6.Key()][0]
require.Equal(t, 1, subscription.AnnotationParser.GetBooleanEntitlementCount(item.Annotations))
// Let's apply the diff
err := spec.Apply(env.diffable.GetApplies(), subscription.ApplyContext{CurrentTime: now})
require.NoError(t, err)
// Let's assert that we have a boolean entitlement and the item has the proper annotations
items := spec.Phases["test_phase_1"].ItemsByKey[subscriptiontestutils.ExampleAddonRateCard6.Key()]
require.Len(t, items, 1)
item = items[0]
require.Equal(t, 2, subscription.AnnotationParser.GetBooleanEntitlementCount(item.Annotations))
})
})
t.Run("Should add a new item for new key with boolean entitlement with proper annotations", func(t *testing.T) {
runWithDeps(t, func(t *testing.T, deps *tcDeps) {
env := buildSubAndAddon(
t,
&deps.deps,
subscriptiontestutils.BuildTestPlanInput(t).
AddPhase(nil, subscriptiontestutils.ExampleRateCard2.Clone()).
Build(),
subscriptiontestutils.BuildAddonForTesting(t, productcatalog.EffectivePeriod{
EffectiveFrom: &now,
}, productcatalog.AddonInstanceTypeMultiple, subscriptiontestutils.ExampleAddonRateCard6.Clone()),
models.CadencedModel{
ActiveFrom: now,
ActiveTo: nil,
},
)
spec := env.subView.Spec
// Let's apply the diff
err := spec.Apply(env.diffable.GetApplies(), subscription.ApplyContext{CurrentTime: now})
require.NoError(t, err)
// Let's assert that we have a boolean entitlement and the item has the proper annotations
items := spec.Phases["test_phase_1"].ItemsByKey[subscriptiontestutils.ExampleAddonRateCard6.Key()]
require.Len(t, items, 1)
item := items[0]
require.Equal(t, 1, subscription.AnnotationParser.GetBooleanEntitlementCount(item.Annotations))
})
})
t.Run("Should update an existing Item that fills its entire phase", func(t *testing.T) {
runWithDeps(t, func(t *testing.T, deps *tcDeps) {
effPer := productcatalog.EffectivePeriod{
EffectiveFrom: lo.ToPtr(now.AddDate(0, 0, 0)),
EffectiveTo: nil,
}
p, a := subscriptiontestutils.CreatePlanWithAddon(
t,
deps.deps,
subscriptiontestutils.BuildTestPlanInput(t).
AddPhase(lo.ToPtr(datetime.MustParseDuration(t, "P1M")), subscriptiontestutils.ExampleRateCard3ForAddons.Clone()).
AddPhase(nil, subscriptiontestutils.ExampleRateCard3ForAddons.Clone()).
Build(),
subscriptiontestutils.BuildAddonForTesting(t,
effPer,
productcatalog.AddonInstanceTypeSingle,
subscriptiontestutils.ExampleAddonRateCard4.Clone(),
),
)
subView := subscriptiontestutils.CreateSubscriptionFromPlan(t, &deps.deps, p, now)
taxCodeID := lookupTaxCodeID(t, deps.deps, "txcd_10000000")
rc3 := withTaxCodeID(&subscriptiontestutils.ExampleRateCard3ForAddons, taxCodeID)
subsAdd := subscriptiontestutils.CreateAddonForSubscription(t, &deps.deps, subView.Subscription.NamespacedID, a.NamespacedID, models.CadencedModel{
ActiveFrom: *effPer.EffectiveFrom,
ActiveTo: effPer.EffectiveTo,
})
diffable, err := addondiff.GetDiffableFromAddon(subView, subsAdd)
require.NoError(t, err)
spec := subView.Spec
err = spec.Apply(diffable.GetApplies(), subscription.ApplyContext{CurrentTime: now})
require.NoError(t, err)
// We should have the updated items in both phases
for _, p := range spec.GetSortedPhases() {
items, ok := p.ItemsByKey[subscriptiontestutils.ExampleAddonRateCard4.Key()]
require.True(t, ok, "item missing in phase %s, got %+v", p.PhaseKey, lo.Keys(p.ItemsByKey))
require.Len(t, items, 1)
item := items[0]
pc, err := spec.GetPhaseCadence(p.PhaseKey)
require.NoError(t, err)
cad := item.GetCadence(pc)
require.True(t, cad.ActiveFrom.Equal(pc.ActiveFrom))
compareRateCardsWithAmountChange(t, rc3, 100*2, item.RateCard, "phase %s", p.PhaseKey)
}
})
})
t.Run("Should partially update an existing item in accordance with the cadence", func(t *testing.T) {
runWithDeps(t, func(t *testing.T, deps *tcDeps) {
effPer := productcatalog.EffectivePeriod{
EffectiveFrom: lo.ToPtr(now),
EffectiveTo: nil,
}
p, a := subscriptiontestutils.CreatePlanWithAddon(
t,
deps.deps,
subscriptiontestutils.BuildTestPlanInput(t).
AddPhase(lo.ToPtr(datetime.MustParseDuration(t, "P1M")), subscriptiontestutils.ExampleRateCard3ForAddons.Clone()).
AddPhase(nil, subscriptiontestutils.ExampleRateCard3ForAddons.Clone()).
Build(),
subscriptiontestutils.BuildAddonForTesting(t,
effPer,
productcatalog.AddonInstanceTypeSingle,
subscriptiontestutils.ExampleAddonRateCard4.Clone(),
),
)
subView := subscriptiontestutils.CreateSubscriptionFromPlan(t, &deps.deps, p, now)
taxCodeID := lookupTaxCodeID(t, deps.deps, "txcd_10000000")
rc3 := withTaxCodeID(&subscriptiontestutils.ExampleRateCard3ForAddons, taxCodeID)
subsAdd := subscriptiontestutils.CreateAddonForSubscription(t, &deps.deps, subView.Subscription.NamespacedID, a.NamespacedID, models.CadencedModel{
ActiveFrom: now.AddDate(0, 0, 5),
ActiveTo: effPer.EffectiveTo,
})
diffable, err := addondiff.GetDiffableFromAddon(subView, subsAdd)
require.NoError(t, err)
spec := subView.Spec
err = spec.Apply(diffable.GetApplies(), subscription.ApplyContext{CurrentTime: now})
require.NoError(t, err)
// Let's check the first phase looks as we should
p1 := spec.GetSortedPhases()[0]
b, _ := json.MarshalIndent(p1, "", " ")
require.Contains(t, p1.ItemsByKey, subscriptiontestutils.ExampleAddonRateCard4.Key(), "looked for: %s, full: %s", subscriptiontestutils.ExampleAddonRateCard4.Key(), string(b))
require.Len(t, p1.ItemsByKey[subscriptiontestutils.ExampleAddonRateCard4.Key()], 2)
items := p1.ItemsByKey[subscriptiontestutils.ExampleAddonRateCard4.Key()]
pCad, err := spec.GetPhaseCadence(p1.PhaseKey)
require.NoError(t, err)
// First item should be from 1 to 5, second from 5 to end of phase
cad1 := items[0].GetCadence(pCad)
require.True(t, cad1.ActiveFrom.Equal(pCad.ActiveFrom))
require.True(t, cad1.ActiveTo.Equal(now.AddDate(0, 0, 5)))
cad2 := items[1].GetCadence(pCad)
require.True(t, cad2.ActiveFrom.Equal(now.AddDate(0, 0, 5)))
require.True(t, cad2.ActiveTo.Equal(*pCad.ActiveTo))
// First item should have the original rate card
compareRateCardsWithAmountChange(t, rc3, 100, items[0].RateCard, "phase %s", p1.PhaseKey)
// Second item should have the updated rate card
compareRateCardsWithAmountChange(t, rc3, 100*2, items[1].RateCard, "phase %s", p1.PhaseKey)
})
})
t.Run("Should partially update an existing item in accordance with the cadence - but for multi instance", func(t *testing.T) {
runWithDeps(t, func(t *testing.T, deps *tcDeps) {
effPer := productcatalog.EffectivePeriod{
EffectiveFrom: lo.ToPtr(now),
EffectiveTo: nil,
}
p, a := subscriptiontestutils.CreatePlanWithAddon(
t,
deps.deps,
subscriptiontestutils.BuildTestPlanInput(t).
AddPhase(lo.ToPtr(datetime.MustParseDuration(t, "P1M")), subscriptiontestutils.ExampleRateCard3ForAddons.Clone()).
AddPhase(nil, subscriptiontestutils.ExampleRateCard3ForAddons.Clone()).
Build(),
subscriptiontestutils.BuildAddonForTesting(t,
effPer,
productcatalog.AddonInstanceTypeSingle,
subscriptiontestutils.ExampleAddonRateCard4.Clone(),
),
)
subView := subscriptiontestutils.CreateSubscriptionFromPlan(t, &deps.deps, p, now)
taxCodeID := lookupTaxCodeID(t, deps.deps, "txcd_10000000")
rc3 := withTaxCodeID(&subscriptiontestutils.ExampleRateCard3ForAddons, taxCodeID)
subsAdd := subscriptiontestutils.CreateAddonForSubscription(t, &deps.deps, subView.Subscription.NamespacedID, a.NamespacedID, models.CadencedModel{
ActiveFrom: now.AddDate(0, 0, 5),
ActiveTo: effPer.EffectiveTo,
})
// Lets just overwrite the quantity, it will be fine now
val := subsAdd.Quantities.GetAt(0).GetValue()
val.Quantity = 4
subsAdd.Quantities = timeutil.NewTimeline([]timeutil.Timed[subscriptionaddon.SubscriptionAddonQuantity]{
val.AsTimed(),
})
diffable, err := addondiff.GetDiffableFromAddon(subView, subsAdd)
require.NoError(t, err)
spec := subView.Spec
err = spec.Apply(diffable.GetApplies(), subscription.ApplyContext{CurrentTime: now})
require.NoError(t, err)
// Let's check the first phase looks as we should
{
p1 := spec.GetSortedPhases()[0]
require.Contains(t, p1.ItemsByKey, subscriptiontestutils.ExampleAddonRateCard4.Key())
require.Len(t, p1.ItemsByKey[subscriptiontestutils.ExampleAddonRateCard4.Key()], 2)
items := p1.ItemsByKey[subscriptiontestutils.ExampleAddonRateCard4.Key()]
pCad, err := spec.GetPhaseCadence(p1.PhaseKey)
require.NoError(t, err)
// First item should be from 1 to 5, second from 5 to end of phase
cad1 := items[0].GetCadence(pCad)
require.True(t, cad1.ActiveFrom.Equal(pCad.ActiveFrom))
require.True(t, cad1.ActiveTo.Equal(now.AddDate(0, 0, 5)))
cad2 := items[1].GetCadence(pCad)
require.True(t, cad2.ActiveFrom.Equal(now.AddDate(0, 0, 5)))
require.True(t, cad2.ActiveTo.Equal(*pCad.ActiveTo))
// First item should have the original rate card
compareRateCardsWithAmountChange(t, rc3, 100, items[0].RateCard, "phase %s", p1.PhaseKey)
// Second item should have the updated rate card
compareRateCardsWithAmountChange(t, rc3, 100*(1+4), items[1].RateCard, "phase %s", p1.PhaseKey)
}
// Lets check the second phase
{
p2 := spec.GetSortedPhases()[1]
require.Contains(t, p2.ItemsByKey, subscriptiontestutils.ExampleAddonRateCard4.Key())
items := p2.ItemsByKey[subscriptiontestutils.ExampleAddonRateCard4.Key()]
require.Len(t, items, 1)
item := items[0]
pCad, err := spec.GetPhaseCadence(p2.PhaseKey)
require.NoError(t, err)
cad := item.GetCadence(pCad)
require.True(t, cad.ActiveFrom.Equal(pCad.ActiveFrom))
require.Nil(t, cad.ActiveTo)
compareRateCardsWithAmountChange(t, rc3, 100*(1+4), item.RateCard, "phase %s", p2.PhaseKey)
}
})
})
t.Run("Should guarantee access is continuous across changing items", func(t *testing.T) {
runWithDeps(t, func(t *testing.T, deps *tcDeps) {
t0 := now
clock.FreezeTime(t0.Add(time.Millisecond)) // Addons are written so that effective period is exclusive on both ends :)
defer clock.UnFreeze()
t1 := now.AddDate(0, 0, 5)
t2 := t1.AddDate(0, 1, 5)
t3 := t2.AddDate(0, 0, 6)
t4 := t3.AddDate(0, 0, 9)
effPer := productcatalog.EffectivePeriod{
EffectiveFrom: &now,
}
subAddCadence := models.CadencedModel{
ActiveFrom: t1,
ActiveTo: lo.ToPtr(t4),
}
p, a := subscriptiontestutils.CreatePlanWithAddon(
t,
deps.deps,
subscriptiontestutils.BuildTestPlanInput(t).
AddPhase(nil, subscriptiontestutils.ExampleRateCard3ForAddons.Clone()).
Build(),
subscriptiontestutils.BuildAddonForTesting(t,
effPer,
productcatalog.AddonInstanceTypeSingle,
subscriptiontestutils.ExampleAddonRateCard4.Clone(),
),
)
subView := subscriptiontestutils.CreateSubscriptionFromPlan(t, &deps.deps, p, now)
_, err := deps.deps.SubscriptionService.GetView(context.Background(), subView.Subscription.NamespacedID)
require.NoError(t, err)
subsAdd := subscriptiontestutils.CreateAddonForSubscription(t, &deps.deps, subView.Subscription.NamespacedID, a.NamespacedID, subAddCadence)
diffable, err := addondiff.GetDiffableFromAddon(subView, subsAdd)
require.NoError(t, err)
spec := subView.Spec
// Let's just manipulate the spec object directly
ogItem := spec.Phases["test_phase_1"].ItemsByKey[subscriptiontestutils.ExampleRateCard3ForAddons.Key()][0]
spec.Phases["test_phase_1"].ItemsByKey[subscriptiontestutils.ExampleRateCard3ForAddons.Key()] = []*subscription.SubscriptionItemSpec{
// First, before gap
{
CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{
CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{
PhaseKey: spec.Phases["test_phase_1"].PhaseKey,
ItemKey: subscriptiontestutils.ExampleRateCard3ForAddons.Key(),
RateCard: subscriptiontestutils.ExampleRateCard3ForAddons.Clone(),
},
CreateSubscriptionItemCustomerInput: subscription.CreateSubscriptionItemCustomerInput{
ActiveToOverrideRelativeToPhaseStart: lo.ToPtr(datetime.ISODurationBetween(t0, t2)),
},
Annotations: ogItem.Annotations,
},
},
// Second, after gap
{
CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{
CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{
PhaseKey: spec.Phases["test_phase_1"].PhaseKey,
ItemKey: subscriptiontestutils.ExampleRateCard3ForAddons.Key(),
RateCard: subscriptiontestutils.ExampleRateCard3ForAddons.Clone(),
},
CreateSubscriptionItemCustomerInput: subscription.CreateSubscriptionItemCustomerInput{
ActiveFromOverrideRelativeToPhaseStart: lo.ToPtr(datetime.ISODurationBetween(t0, t3)),
},
Annotations: ogItem.Annotations,
},
},
}
err = spec.Apply(diffable.GetApplies(), subscription.ApplyContext{CurrentTime: now})
require.NoError(t, err)
// Now let's make sure that we have 5 items:
key := subscriptiontestutils.ExampleRateCard3ForAddons.Key()
phase := spec.GetSortedPhases()[0]
pCad, err := spec.GetPhaseCadence(phase.PhaseKey)
require.NoError(t, err)
require.Contains(t, phase.ItemsByKey, key)
require.Len(t, phase.ItemsByKey[key], 5)
items := phase.ItemsByKey[key]
// [t0-t1]: ExampleRateCard3ForAddons
require.True(t, items[0].GetCadence(pCad).ActiveFrom.Equal(t0))
require.True(t, items[0].GetCadence(pCad).ActiveTo.Equal(t1))
compareRateCardsWithAmountChange(t, &subscriptiontestutils.ExampleRateCard3ForAddons, 100, items[0].RateCard, "phase %s", phase.PhaseKey)
// [t1-t2]: ExampleRateCard3ForAddons + ExampleAddonRateCard4
require.True(t, items[1].GetCadence(pCad).ActiveFrom.Equal(t1))
require.True(t, items[1].GetCadence(pCad).ActiveTo.Equal(t2))
compareRateCardsWithAmountChange(t, &subscriptiontestutils.ExampleRateCard3ForAddons, 100*2, items[1].RateCard, "phase %s", phase.PhaseKey)
require.Equal(t, ogItem.Annotations, items[1].Annotations, "annotations should be the same")
// [t2-t3]: ExampleAddonRateCard4
require.True(t, items[2].GetCadence(pCad).ActiveFrom.Equal(t2))
require.True(t, items[2].GetCadence(pCad).ActiveTo.Equal(t3))
compareRateCardsWithAmountChange(t, subsAdd.RateCards[0].AddonRateCard.RateCard.(*productcatalog.FlatFeeRateCard), 100, items[2].RateCard, "phase %s", phase.PhaseKey)
// [t3-t4]: ExampleRateCard3ForAddons + ExampleAddonRateCard4
require.True(t, items[3].GetCadence(pCad).ActiveFrom.Equal(t3))
require.True(t, items[3].GetCadence(pCad).ActiveTo.Equal(t4))
compareRateCardsWithAmountChange(t, &subscriptiontestutils.ExampleRateCard3ForAddons, 100*2, items[3].RateCard, "phase %s", phase.PhaseKey)
require.Equal(t, ogItem.Annotations, items[3].Annotations, "annotations should be the same")
// [t4-open]: ExampleRateCard3ForAddons
require.True(t, items[4].GetCadence(pCad).ActiveFrom.Equal(t4))
require.Nil(t, items[4].GetCadence(pCad).ActiveTo)
compareRateCardsWithAmountChange(t, &subscriptiontestutils.ExampleRateCard3ForAddons, 100, items[4].RateCard, "phase %s", phase.PhaseKey)
})
})
t.Run("Should create multiple rate cards in the addon", func(t *testing.T) {
runWithDeps(t, func(t *testing.T, deps *tcDeps) {
// we'll reuse this for subsadd cadence too
effPer := productcatalog.EffectivePeriod{
EffectiveFrom: lo.ToPtr(now),
EffectiveTo: nil,
}
pl, a := subscriptiontestutils.CreatePlanWithAddon(
t,
deps.deps,
subscriptiontestutils.BuildTestPlanInput(t).
AddPhase(nil, subscriptiontestutils.ExampleRateCard3ForAddons.Clone()).
Build(),
subscriptiontestutils.BuildAddonForTesting(t,
effPer,
productcatalog.AddonInstanceTypeMultiple,
subscriptiontestutils.ExampleAddonRateCard4.Clone(),
subscriptiontestutils.ExampleAddonRateCard3.Clone(),
),
)
subView := subscriptiontestutils.CreateSubscriptionFromPlan(t, &deps.deps, pl, now)
taxCodeID := lookupTaxCodeID(t, deps.deps, "txcd_10000000")
rc3 := withTaxCodeID(&subscriptiontestutils.ExampleRateCard3ForAddons, taxCodeID)
subAdd := subscriptiontestutils.CreateAddonForSubscription(t, &deps.deps, subView.Subscription.NamespacedID, a.NamespacedID, models.CadencedModel{
ActiveFrom: *effPer.EffectiveFrom,
ActiveTo: effPer.EffectiveTo,
})
diffable, err := addondiff.GetDiffableFromAddon(subView, subAdd)
require.NoError(t, err)
spec := subView.Spec
err = spec.Apply(diffable.GetApplies(), subscription.ApplyContext{CurrentTime: now})
require.NoError(t, err)
p, ok := spec.Phases["test_phase_1"]
require.True(t, ok)
pCad, err := spec.GetPhaseCadence(p.PhaseKey)
require.NoError(t, err)
// Should update ExampleRateCard3ForAddons with ExampleAddonRateCard4
require.Contains(t, p.ItemsByKey, subscriptiontestutils.ExampleRateCard3ForAddons.Key())
items := p.ItemsByKey[subscriptiontestutils.ExampleRateCard3ForAddons.Key()]
require.Len(t, items, 1)
item := items[0]
require.True(t, item.GetCadence(pCad).ActiveFrom.Equal(now))
require.Nil(t, item.GetCadence(pCad).ActiveTo)
compareRateCardsWithAmountChange(t, rc3, 100*2, item.RateCard, "phase %s", p.PhaseKey)
// Should create ExampleAddonRateCard3
require.Contains(t, p.ItemsByKey, subscriptiontestutils.ExampleAddonRateCard3.Key())
items = p.ItemsByKey[subscriptiontestutils.ExampleAddonRateCard3.Key()]
require.Len(t, items, 1)
item = items[0]
require.True(t, item.GetCadence(pCad).ActiveFrom.Equal(now))
require.Nil(t, item.GetCadence(pCad).ActiveTo)
compareRateCardsWithAmountChange(t, &subscriptiontestutils.ExampleAddonRateCard3, 100, item.RateCard, "phase %s", p.PhaseKey)
})
})
}
func TestApplyWithMultiInstance(t *testing.T) {
type tcDeps struct {
deps subscriptiontestutils.SubscriptionDependencies
}
runWithDeps := func(t *testing.T, fn func(t *testing.T, deps *tcDeps)) {
clock.FreezeTime(now.Add(time.Millisecond))
defer func() {
clock.UnFreeze()
clock.ResetTime()
}()
dbDeps := subscriptiontestutils.SetupDBDeps(t)
defer dbDeps.Cleanup(t)
deps := subscriptiontestutils.NewService(t, dbDeps)
fn(t, &tcDeps{
deps: deps,
})
}
t.Run("Should create items in phase according to quantities", func(t *testing.T) {
runWithDeps(t, func(t *testing.T, deps *tcDeps) {
t0 := now
t1 := t0.AddDate(0, 0, 1)
t2 := t1.AddDate(0, 0, 1)
effPer := productcatalog.EffectivePeriod{
EffectiveFrom: lo.ToPtr(now),
EffectiveTo: nil,
}
pl, a := subscriptiontestutils.CreatePlanWithAddon(
t,
deps.deps,
subscriptiontestutils.BuildTestPlanInput(t).
AddPhase(nil, subscriptiontestutils.ExampleRateCard3ForAddons.Clone()).
Build(),
subscriptiontestutils.BuildAddonForTesting(t,
effPer,
productcatalog.AddonInstanceTypeMultiple,
subscriptiontestutils.ExampleAddonRateCard4.Clone(),
),
)
subView := subscriptiontestutils.CreateSubscriptionFromPlan(t, &deps.deps, pl, now)
taxCodeID := lookupTaxCodeID(t, deps.deps, "txcd_10000000")
rc3 := withTaxCodeID(&subscriptiontestutils.ExampleRateCard3ForAddons, taxCodeID)
subAdd := subscriptiontestutils.CreateMultiInstanceAddonForSubscription(t, &deps.deps, subView.Subscription.NamespacedID, a.NamespacedID, []subscriptionaddon.CreateSubscriptionAddonQuantityInput{
{
ActiveFrom: t1,
Quantity: 1,
},
{
ActiveFrom: t2,
Quantity: 2,
},
})
diffable, err := addondiff.GetDiffableFromAddon(subView, subAdd)
require.NoError(t, err)
spec := subView.Spec
err = spec.Apply(diffable.GetApplies(), subscription.ApplyContext{CurrentTime: now})
require.NoError(t, err)
p, ok := spec.Phases["test_phase_1"]
require.True(t, ok)
pCad, err := spec.GetPhaseCadence(p.PhaseKey)
require.NoError(t, err)
require.Contains(t, p.ItemsByKey, subscriptiontestutils.ExampleAddonRateCard4.Key())
require.Len(t, p.ItemsByKey[subscriptiontestutils.ExampleAddonRateCard4.Key()], 3)
items := p.ItemsByKey[subscriptiontestutils.ExampleAddonRateCard4.Key()]
// First item: from t0 to t1, ExampleRateCard3ForAddons
require.True(t, items[0].GetCadence(pCad).ActiveFrom.Equal(t0))
require.True(t, items[0].GetCadence(pCad).ActiveTo.Equal(t1))
compareRateCardsWithAmountChange(t, rc3, 100*1, items[0].RateCard, "phase %s", p.PhaseKey)
// Second item: from t1 to t2, ExampleRateCard3ForAddons + ExampleAddonRateCard4
require.True(t, items[1].GetCadence(pCad).ActiveFrom.Equal(t1))
require.True(t, items[1].GetCadence(pCad).ActiveTo.Equal(t2))
compareRateCardsWithAmountChange(t, rc3, 100*2, items[1].RateCard, "phase %s", p.PhaseKey)
// Third item: from t2 to open, ExampleRateCard3ForAddons + 2 x ExampleAddonRateCard4
require.True(t, items[2].GetCadence(pCad).ActiveFrom.Equal(t2))
require.Nil(t, items[2].GetCadence(pCad).ActiveTo)
compareRateCardsWithAmountChange(t, rc3, 100*3, items[2].RateCard, "phase %s", p.PhaseKey)
})
})
}
// lookupTaxCodeID resolves the TaxCode entity ID for a given Stripe tax code from the database.
// This is used to set the expected TaxCodeID on test rate cards after plan creation resolves them.
func lookupTaxCodeID(t *testing.T, deps subscriptiontestutils.SubscriptionDependencies, stripeCode string) *string {
t.Helper()
tc, err := deps.TaxCodeService.GetTaxCodeByAppMapping(context.Background(), taxcode.GetTaxCodeByAppMappingInput{
Namespace: subscriptiontestutils.ExampleNamespace,
AppType: app.AppTypeStripe,
TaxCode: stripeCode,
})
require.NoError(t, err)
return &tc.ID
}
// withTaxCodeID clones a FlatFeeRateCard and sets the TaxCodeID on its TaxConfig.
func withTaxCodeID(rc *productcatalog.FlatFeeRateCard, taxCodeID *string) *productcatalog.FlatFeeRateCard {
clone := rc.Clone().(*productcatalog.FlatFeeRateCard)
if clone.TaxConfig != nil {
clone.TaxConfig.TaxCodeID = taxCodeID
}
return clone
}
func compareRateCardsWithAmountChange(t *testing.T, baseTarget *productcatalog.FlatFeeRateCard, targetAmount int, value productcatalog.RateCard, msgAndArgs ...interface{}) {
t.Helper()
targetMeta := baseTarget.RateCardMeta.Clone()
targetMeta.Price = productcatalog.NewPriceFrom(productcatalog.FlatPrice{
Amount: alpacadecimal.NewFromInt(int64(targetAmount)),
PaymentTerm: productcatalog.InAdvancePaymentTerm,
})
target := &productcatalog.FlatFeeRateCard{
RateCardMeta: targetMeta,
BillingCadence: baseTarget.BillingCadence,
}
b1, _ := json.MarshalIndent(value, "", " ")
b2, _ := json.MarshalIndent(target, "", " ")
msgX := fmt.Sprintf("item %s not equal to target %s", b1, b2)
left := target.Clone()
left.SetFeature(nil, left.GetFeatureKey())
right := value.Clone()
right.SetFeature(nil, right.GetFeatureKey())
if len(msgAndArgs) > 0 {
customMsg := fmt.Sprintf(msgAndArgs[0].(string), msgAndArgs[1:]...)
require.True(t, left.Equal(right), "%s: %s", customMsg, msgX)
} else {
require.True(t, left.Equal(right), msgX)
}
}