| package subscription_test |
|
|
| import ( |
| "encoding/json" |
| "net/http" |
| "testing" |
| "time" |
|
|
| "github.com/alpacahq/alpacadecimal" |
| "github.com/samber/lo" |
| "github.com/stretchr/testify/require" |
|
|
| "github.com/openmeterio/openmeter/openmeter/productcatalog" |
| "github.com/openmeterio/openmeter/openmeter/subscription" |
| "github.com/openmeterio/openmeter/pkg/currencyx" |
| "github.com/openmeterio/openmeter/pkg/datetime" |
| "github.com/openmeterio/openmeter/pkg/framework/commonhttp" |
| "github.com/openmeterio/openmeter/pkg/models" |
| "github.com/openmeterio/openmeter/pkg/slicesx" |
| ) |
|
|
| func TestSubscriptionErrors(t *testing.T) { |
| t.Run("ErrSubscriptionBillingPeriodQueriedBeforeSubscriptionStart", func(t *testing.T) { |
| queriedAt := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) |
| subscriptionStart := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) |
|
|
| err := subscription.NewErrSubscriptionBillingPeriodQueriedBeforeSubscriptionStart(queriedAt, subscriptionStart) |
|
|
| require.True(t, subscription.IsValidationIssueWithCode(err, subscription.ErrCodeSubscriptionBillingPeriodQueriedBeforeSubscriptionStart)) |
|
|
| issues, err := models.AsValidationIssues(err) |
| require.NoError(t, err) |
|
|
| exts := issues.AsErrorExtensions() |
| require.Len(t, exts, 1) |
|
|
| ext := exts[0] |
| require.Equal(t, subscription.ErrCodeSubscriptionBillingPeriodQueriedBeforeSubscriptionStart, ext["code"]) |
| require.Equal(t, "billing period queried before subscription start", ext["message"]) |
| require.Equal(t, subscriptionStart, ext["subscription_start"]) |
| require.Equal(t, queriedAt, ext["queried_at"]) |
| }) |
| } |
|
|
| func TestSubscriptionSpecValidation(t *testing.T) { |
| t.Run("Should be a valid subscription", func(t *testing.T) { |
| spec := subscription.SubscriptionSpec{ |
| CreateSubscriptionPlanInput: subscription.CreateSubscriptionPlanInput{ |
| Plan: &subscription.PlanRef{ |
| Key: "test", |
| Version: 1, |
| }, |
| BillingCadence: datetime.MustParseDuration(t, "P1M"), |
| ProRatingConfig: productcatalog.ProRatingConfig{ |
| Mode: productcatalog.ProRatingModeProratePrices, |
| }, |
| }, |
| CreateSubscriptionCustomerInput: subscription.CreateSubscriptionCustomerInput{ |
| Name: "test", |
| CustomerId: "test", |
| Currency: currencyx.Code("USD"), |
| ActiveFrom: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), |
| BillingAnchor: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), |
| }, |
| Phases: map[string]*subscription.SubscriptionPhaseSpec{ |
| "phase1": { |
| CreateSubscriptionPhasePlanInput: subscription.CreateSubscriptionPhasePlanInput{ |
| PhaseKey: "phase1", |
| StartAfter: datetime.MustParseDuration(t, "P0D"), |
| Name: "phase1", |
| }, |
| CreateSubscriptionPhaseCustomerInput: subscription.CreateSubscriptionPhaseCustomerInput{}, |
| ItemsByKey: map[string][]*subscription.SubscriptionItemSpec{ |
| "item1": { |
| { |
| CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{ |
| CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{ |
| PhaseKey: "phase1", |
| ItemKey: "item1", |
| RateCard: &productcatalog.UsageBasedRateCard{ |
| RateCardMeta: productcatalog.RateCardMeta{ |
| Key: "item1", |
| Name: "item1", |
| FeatureKey: lo.ToPtr("item1"), |
| FeatureID: lo.ToPtr("item1"), |
| Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(100), |
| }), |
| EntitlementTemplate: func() *productcatalog.EntitlementTemplate { |
| et := productcatalog.EntitlementTemplate{} |
| et.FromMetered(productcatalog.MeteredEntitlementTemplate{ |
| IssueAfterReset: lo.ToPtr(10.0), |
| IssueAfterResetPriority: lo.ToPtr(uint8(1)), |
| UsagePeriod: datetime.MustParseDuration(t, "P1M"), |
| }) |
|
|
| return &et |
| }(), |
| }, |
| BillingCadence: datetime.MustParseDuration(t, "P1M"), |
| }, |
| }, |
| CreateSubscriptionItemCustomerInput: subscription.CreateSubscriptionItemCustomerInput{}, |
| }, |
| }, |
| }, |
| }, |
| }, |
| }, |
| } |
|
|
| err := spec.Validate() |
| require.NoError(t, err) |
| }) |
|
|
| t.Run("Should have expected errors", func(t *testing.T) { |
| spec := subscription.SubscriptionSpec{ |
| CreateSubscriptionPlanInput: subscription.CreateSubscriptionPlanInput{ |
| Plan: &subscription.PlanRef{ |
| Key: "test", |
| Version: 1, |
| }, |
| BillingCadence: datetime.MustParseDuration(t, "P1M"), |
| ProRatingConfig: productcatalog.ProRatingConfig{ |
| Mode: productcatalog.ProRatingModeProratePrices, |
| }, |
| }, |
| CreateSubscriptionCustomerInput: subscription.CreateSubscriptionCustomerInput{ |
| Name: "test", |
| CustomerId: "test", |
| Currency: currencyx.Code("USD"), |
| ActiveFrom: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), |
| BillingAnchor: time.Date(2025, 1, 1, 5, 0, 0, 0, time.UTC), |
| }, |
| Phases: map[string]*subscription.SubscriptionPhaseSpec{ |
| "phase1": { |
| CreateSubscriptionPhasePlanInput: subscription.CreateSubscriptionPhasePlanInput{ |
| PhaseKey: "phase1", |
| StartAfter: datetime.MustParseDuration(t, "P0D"), |
| Name: "phase1", |
| }, |
| CreateSubscriptionPhaseCustomerInput: subscription.CreateSubscriptionPhaseCustomerInput{}, |
| ItemsByKey: map[string][]*subscription.SubscriptionItemSpec{ |
| "item1": { |
| { |
| CreateSubscriptionItemInput: subscription.CreateSubscriptionItemInput{ |
| CreateSubscriptionItemPlanInput: subscription.CreateSubscriptionItemPlanInput{ |
| PhaseKey: "phase1", |
| ItemKey: "item1", |
| RateCard: &productcatalog.UsageBasedRateCard{ |
| RateCardMeta: productcatalog.RateCardMeta{ |
| Key: "item1", |
| Name: "item1", |
| FeatureKey: lo.ToPtr("badkey"), |
| FeatureID: lo.ToPtr("badid"), |
| Price: productcatalog.NewPriceFrom(productcatalog.UnitPrice{ |
| Amount: alpacadecimal.NewFromFloat(100), |
| }), |
| EntitlementTemplate: func() *productcatalog.EntitlementTemplate { |
| et := productcatalog.EntitlementTemplate{} |
| et.FromMetered(productcatalog.MeteredEntitlementTemplate{ |
| |
| IssueAfterResetPriority: lo.ToPtr(uint8(1)), |
| UsagePeriod: datetime.MustParseDuration(t, "P1M"), |
| }) |
|
|
| return &et |
| }(), |
| }, |
| BillingCadence: datetime.MustParseDuration(t, "P1M"), |
| }, |
| }, |
| CreateSubscriptionItemCustomerInput: subscription.CreateSubscriptionItemCustomerInput{}, |
| }, |
| }, |
| }, |
| }, |
| }, |
| "phase2": { |
| CreateSubscriptionPhasePlanInput: subscription.CreateSubscriptionPhasePlanInput{ |
| PhaseKey: "phase2", |
| StartAfter: datetime.MustParseDuration(t, "-P2D"), |
| Name: "phase2", |
| }, |
| CreateSubscriptionPhaseCustomerInput: subscription.CreateSubscriptionPhaseCustomerInput{}, |
| ItemsByKey: map[string][]*subscription.SubscriptionItemSpec{}, |
| }, |
| }, |
| } |
|
|
| specValidateError := spec.Validate() |
| err := specValidateError |
| require.Error(t, err) |
|
|
| issues, err := models.AsValidationIssues(err) |
| require.NoError(t, err) |
|
|
| exts := issues.AsErrorExtensions() |
|
|
| byts, err := json.MarshalIndent(exts, "", " ") |
| require.NoError(t, err) |
|
|
| require.Len(t, issues, 5, "got %s", string(byts)) |
|
|
| models.RequireValidationIssuesMatch(t, models.ValidationIssues{ |
| models.NewValidationIssue( |
| subscription.ErrCodeSubscriptionPhaseStartAfterIsNegative, |
| "subscription phase start after cannot be negative", |
| models.WithField( |
| models.NewFieldSelectorGroup( |
| models.NewFieldSelector("phases"), |
| models.NewFieldSelector("phase2"), |
| models.NewFieldSelector("startAfter"), |
| ), |
| ), |
| commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), |
| ), |
| models.NewValidationIssue( |
| subscription.ErrCodeSubscriptionPhaseHasNoItems, |
| "subscription phase must have at least one item", |
| models.WithField( |
| models.NewFieldSelectorGroup( |
| models.NewFieldSelector("phases"), |
| models.NewFieldSelector("phase2"), |
| models.NewFieldSelector("items"), |
| ), |
| ), |
| subscription.AllowedDuringApplyingToSpecError(), |
| commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), |
| ), |
| models.NewValidationIssue( |
| productcatalog.ErrCodeEntitlementTemplateInvalidIssueAfterResetWithPriority, |
| "invalid entitlement template as issue after reset is required if issue after reset priority is set", |
| models.WithField( |
| models.NewFieldSelectorGroup( |
| models.NewFieldSelector("phases"), |
| models.NewFieldSelector("phase1"), |
| models.NewFieldSelector("itemsByKey"), |
| models.NewFieldSelector("item1").WithExpression(models.NewFieldArrIndex(0)), |
| models.NewFieldSelector("entitlementTemplate"), |
| models.NewFieldSelector("issueAfterReset"), |
| ), |
| ), |
| models.WithComponent("rateCard"), |
| models.WithWarningSeverity(), |
| commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), |
| ), |
| models.NewValidationIssue( |
| productcatalog.ErrCodeEntitlementTemplateIssueAfterResetRequired, |
| "issueAfterReset is required for metered entitlement templates", |
| models.WithField( |
| models.NewFieldSelectorGroup( |
| models.NewFieldSelector("phases"), |
| models.NewFieldSelector("phase1"), |
| models.NewFieldSelector("itemsByKey"), |
| models.NewFieldSelector("item1").WithExpression(models.NewFieldArrIndex(0)), |
| models.NewFieldSelector("entitlementTemplate"), |
| models.NewFieldSelector("issueAfterReset"), |
| ), |
| ), |
| models.WithComponent("rateCard"), |
| models.WithWarningSeverity(), |
| commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), |
| ), |
| models.NewValidationIssue( |
| productcatalog.ErrCodeRateCardKeyFeatureKeyMismatch, |
| "rate card key must match feature key", |
| models.WithField( |
| models.NewFieldSelectorGroup( |
| models.NewFieldSelector("phases"), |
| models.NewFieldSelector("phase1"), |
| models.NewFieldSelector("itemsByKey"), |
| models.NewFieldSelector("item1").WithExpression(models.NewFieldArrIndex(0)), |
| models.NewFieldSelector("key"), |
| ), |
| ), |
| models.WithComponent("rateCard"), |
| commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), |
| ), |
| }, issues) |
|
|
| |
| |
| |
| |
| |
| |
|
|
| t.Run("MapSubscriptionSpecValidationIssueFieldSelectors", func(t *testing.T) { |
| ogErr := specValidateError |
| require.Error(t, ogErr) |
| issues, err := models.AsValidationIssues(ogErr) |
| require.NoError(t, err) |
|
|
| mapped, err := slicesx.MapWithErr(issues, func(issue models.ValidationIssue) (models.ValidationIssue, error) { |
| return subscription.MapSubscriptionSpecValidationIssueField(issue) |
| }) |
|
|
| require.NoError(t, err) |
|
|
| models.RequireValidationIssuesMatch(t, models.ValidationIssues{ |
| models.NewValidationIssue( |
| subscription.ErrCodeSubscriptionPhaseStartAfterIsNegative, |
| "subscription phase start after cannot be negative", |
| models.WithField( |
| models.NewFieldSelectorGroup( |
| models.NewFieldSelector("phases").WithExpression(models.NewFieldAttrValue("key", "phase2")), |
| models.NewFieldSelector("startAfter"), |
| ), |
| ), |
| commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), |
| ), |
| models.NewValidationIssue( |
| subscription.ErrCodeSubscriptionPhaseHasNoItems, |
| "subscription phase must have at least one item", |
| models.WithField( |
| models.NewFieldSelectorGroup( |
| models.NewFieldSelector("phases").WithExpression(models.NewFieldAttrValue("key", "phase2")), |
| models.NewFieldSelector("items"), |
| ), |
| ), |
| subscription.AllowedDuringApplyingToSpecError(), |
| commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), |
| ), |
| models.NewValidationIssue( |
| productcatalog.ErrCodeEntitlementTemplateInvalidIssueAfterResetWithPriority, |
| "invalid entitlement template as issue after reset is required if issue after reset priority is set", |
| models.WithField( |
| models.NewFieldSelectorGroup( |
| models.NewFieldSelector("phases").WithExpression(models.NewFieldAttrValue("key", "phase1")), |
| models.NewFieldSelector("itemsByKey"), |
| models.NewFieldSelector("item1").WithExpression(models.NewFieldArrIndex(0)), |
| models.NewFieldSelector("entitlementTemplate"), |
| models.NewFieldSelector("issueAfterReset"), |
| ), |
| ), |
| models.WithComponent("rateCard"), |
| models.WithWarningSeverity(), |
| commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), |
| ), |
| models.NewValidationIssue( |
| productcatalog.ErrCodeEntitlementTemplateIssueAfterResetRequired, |
| "issueAfterReset is required for metered entitlement templates", |
| models.WithField( |
| models.NewFieldSelectorGroup( |
| models.NewFieldSelector("phases").WithExpression(models.NewFieldAttrValue("key", "phase1")), |
| models.NewFieldSelector("itemsByKey"), |
| models.NewFieldSelector("item1").WithExpression(models.NewFieldArrIndex(0)), |
| models.NewFieldSelector("entitlementTemplate"), |
| models.NewFieldSelector("issueAfterReset"), |
| ), |
| ), |
| models.WithComponent("rateCard"), |
| models.WithWarningSeverity(), |
| commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), |
| ), |
| models.NewValidationIssue( |
| productcatalog.ErrCodeRateCardKeyFeatureKeyMismatch, |
| "rate card key must match feature key", |
| models.WithField( |
| models.NewFieldSelectorGroup( |
| models.NewFieldSelector("phases").WithExpression(models.NewFieldAttrValue("key", "phase1")), |
| models.NewFieldSelector("itemsByKey"), |
| models.NewFieldSelector("item1").WithExpression(models.NewFieldArrIndex(0)), |
| models.NewFieldSelector("key"), |
| ), |
| ), |
| models.WithComponent("rateCard"), |
| commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest), |
| ), |
| }, mapped) |
|
|
| t.Run("Should not map already mapped issue", func(t *testing.T) { |
| iss := models.NewValidationIssue( |
| productcatalog.ErrCodeRateCardKeyFeatureKeyMismatch, |
| "rate card key must match feature key", |
| models.WithField( |
| models.NewFieldSelector("phases").WithExpression(models.NewFieldAttrValue("key", "phase1")), |
| models.NewFieldSelector("itemsByKey"), |
| models.NewFieldSelector("item1").WithExpression(models.NewFieldArrIndex(0)), |
| models.NewFieldSelector("key"), |
| ), |
| ) |
|
|
| require.Equal(t, "$.phases[?(@.key=='phase1')].itemsByKey.item1[0].key", iss.Field().JSONPath()) |
|
|
| mapped, err := subscription.MapSubscriptionSpecValidationIssueField(iss) |
| require.NoError(t, err) |
|
|
| models.RequireValidationIssuesMatch(t, |
| models.ValidationIssues{iss}, |
| models.ValidationIssues{mapped}, |
| ) |
| }) |
| }) |
| }) |
| } |
|
|