| package e2e |
|
|
| import ( |
| "net/http" |
| "testing" |
| "time" |
|
|
| "github.com/samber/lo" |
| "github.com/stretchr/testify/assert" |
| "github.com/stretchr/testify/require" |
|
|
| v3sdk "github.com/openmeterio/openmeter/api/v3/client" |
| ) |
|
|
| |
| |
| |
| |
| func TestV3SubscriptionAddonAttach(t *testing.T) { |
| c := newV3Client(t) |
|
|
| |
|
|
| customerKey := uniqueKey("sub_addon_customer") |
| customer, err := c.Customers.Create(t.Context(), v3sdk.CreateCustomerRequest{ |
| Key: customerKey, |
| Name: "Subscription Addon Test Customer", |
| Currency: lo.ToPtr("USD"), |
| PrimaryEmail: lo.ToPtr("test-" + customerKey + "@test.com"), |
| UsageAttribution: &v3sdk.CustomerUsageAttribution{ |
| SubjectKeys: []string{customerKey}, |
| }, |
| }) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotNil(t, customer) |
|
|
| |
| |
| |
|
|
| planBody := validPlanRequest("sub_addon_plan") |
| plan, err := c.Plans.Create(t.Context(), planBody) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotNil(t, plan) |
| require.NotEmpty(t, plan.Phases, "plan must have at least one phase to attach an addon") |
|
|
| addonBody := validAddonRequest("sub_addon") |
| addon, err := c.Addons.Create(t.Context(), addonBody) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotNil(t, addon) |
|
|
| _, err = c.Addons.Publish(t.Context(), addon.ID) |
| c.requireStatus(http.StatusOK, err) |
|
|
| _, err = c.PlanAddons.Create(t.Context(), plan.ID, validPlanAddonRequest(plan.Phases[0].Key, addon.ID)) |
| c.requireStatus(http.StatusCreated, err) |
|
|
| _, err = c.Plans.Publish(t.Context(), plan.ID) |
| c.requireStatus(http.StatusOK, err) |
|
|
| |
|
|
| subBody := v3sdk.SubscriptionCreate{ |
| Customer: v3sdk.SubscriptionChangeCustomer{ID: &customer.ID}, |
| Plan: v3sdk.SubscriptionChangePlan{ID: &plan.ID}, |
| } |
|
|
| sub, err := c.Subscriptions.Create(t.Context(), subBody) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotNil(t, sub) |
| subscriptionID := sub.ID |
|
|
| |
|
|
| var subAddonID string |
|
|
| t.Run("Should attach addon with immediate timing and return 201", func(t *testing.T) { |
| timing := lo.Must(v3sdk.SubscriptionEditTimingFromEnum(v3sdk.SubscriptionEditTimingEnumImmediate)) |
|
|
| subAddon, err := c.Subscriptions.CreateAddon(t.Context(), subscriptionID, v3sdk.CreateSubscriptionAddonRequest{ |
| Addon: v3sdk.AddonReference{ID: addon.ID}, |
| Quantity: 1, |
| Timing: timing, |
| }) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotNil(t, subAddon) |
|
|
| assert.NotEmpty(t, subAddon.ID) |
| assert.Equal(t, addon.ID, subAddon.Addon.ID) |
| assert.EqualValues(t, 1, subAddon.Quantity) |
| |
| |
| assert.NotNil(t, subAddon.RateCards, "rate_cards must not be null") |
| for i, rc := range subAddon.RateCards { |
| assert.NotNil(t, rc.AffectedSubscriptionItemIds, "rate_cards[%d].affected_subscription_item_ids must not be null", i) |
| } |
| |
| require.NotNil(t, subAddon.Timeline) |
| require.NotEmpty(t, subAddon.Timeline) |
| assert.EqualValues(t, 1, subAddon.Timeline[0].Quantity) |
|
|
| subAddonID = subAddon.ID |
| }) |
|
|
| t.Run("Should return 409 when attaching the same addon twice", func(t *testing.T) { |
| require.NotEmpty(t, subAddonID, "first attach must have succeeded") |
|
|
| timing := lo.Must(v3sdk.SubscriptionEditTimingFromEnum(v3sdk.SubscriptionEditTimingEnumImmediate)) |
|
|
| _, err := c.Subscriptions.CreateAddon(t.Context(), subscriptionID, v3sdk.CreateSubscriptionAddonRequest{ |
| Addon: v3sdk.AddonReference{ID: addon.ID}, |
| Quantity: 1, |
| Timing: timing, |
| }) |
| requireProblem(t, err, http.StatusConflict) |
| }) |
|
|
| t.Run("Should reject invalid quantity 0", func(t *testing.T) { |
| timing := lo.Must(v3sdk.SubscriptionEditTimingFromEnum(v3sdk.SubscriptionEditTimingEnumImmediate)) |
|
|
| _, err := c.Subscriptions.CreateAddon(t.Context(), subscriptionID, v3sdk.CreateSubscriptionAddonRequest{ |
| Addon: v3sdk.AddonReference{ID: addon.ID}, |
| Quantity: 0, |
| Timing: timing, |
| }) |
| |
| |
| apiErr, ok := v3sdk.AsAPIError(err) |
| require.True(t, ok, "expected APIError, got %T: %v", err, err) |
| assert.GreaterOrEqual(t, apiErr.StatusCode, http.StatusBadRequest, "expected 4xx for quantity=0, got %d", apiErr.StatusCode) |
| assert.Less(t, apiErr.StatusCode, http.StatusInternalServerError, "expected 4xx not 5xx for quantity=0") |
| }) |
|
|
| t.Run("Should get the attached addon and surface rate_cards / timeline arrays", func(t *testing.T) { |
| require.NotEmpty(t, subAddonID) |
|
|
| subAddon, err := c.Subscriptions.GetAddon(t.Context(), subscriptionID, subAddonID) |
| c.requireStatus(http.StatusOK, err) |
| require.NotNil(t, subAddon) |
|
|
| assert.Equal(t, subAddonID, subAddon.ID) |
| assert.NotNil(t, subAddon.RateCards, "GET: rate_cards must not be null") |
| assert.NotNil(t, subAddon.Timeline, "GET: timeline must not be null") |
| for i, rc := range subAddon.RateCards { |
| assert.NotNil(t, rc.AffectedSubscriptionItemIds, "GET: rate_cards[%d].affected_subscription_item_ids must not be null", i) |
| } |
| }) |
|
|
| t.Run("Should list subscription addons and find the attached addon", func(t *testing.T) { |
| require.NotEmpty(t, subAddonID) |
|
|
| page, err := c.Subscriptions.ListAddons(t.Context(), subscriptionID, v3sdk.SubscriptionAddonListParams{ |
| Page: &v3sdk.PageParams{Size: lo.ToPtr(100)}, |
| }) |
| c.requireStatus(http.StatusOK, err) |
| require.NotNil(t, page) |
|
|
| found := false |
| for _, sa := range page.Data { |
| if sa.ID == subAddonID { |
| found = true |
| assert.NotNil(t, sa.RateCards, "LIST: rate_cards must not be null") |
| assert.NotNil(t, sa.Timeline, "LIST: timeline must not be null") |
| break |
| } |
| } |
| assert.True(t, found, "attached subscription addon not found in list") |
| }) |
| } |
|
|
| |
| |
| |
| |
| func TestV3SubscriptionAddonNextBillingCycle(t *testing.T) { |
| c := newV3Client(t) |
|
|
| |
|
|
| customerKey := uniqueKey("sub_addon_nbc_customer") |
| customer, err := c.Customers.Create(t.Context(), v3sdk.CreateCustomerRequest{ |
| Key: customerKey, |
| Name: "Next Billing Cycle Test Customer", |
| Currency: lo.ToPtr("USD"), |
| PrimaryEmail: lo.ToPtr("test-" + customerKey + "@test.com"), |
| UsageAttribution: &v3sdk.CustomerUsageAttribution{ |
| SubjectKeys: []string{customerKey}, |
| }, |
| }) |
| c.requireStatus(http.StatusCreated, err) |
|
|
| |
|
|
| plan, err := c.Plans.Create(t.Context(), validPlanRequest("sub_addon_nbc_plan")) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotEmpty(t, plan.Phases) |
|
|
| addon, err := c.Addons.Create(t.Context(), validAddonRequest("sub_addon_nbc")) |
| c.requireStatus(http.StatusCreated, err) |
|
|
| _, err = c.Addons.Publish(t.Context(), addon.ID) |
| c.requireStatus(http.StatusOK, err) |
|
|
| _, err = c.PlanAddons.Create(t.Context(), plan.ID, validPlanAddonRequest(plan.Phases[0].Key, addon.ID)) |
| c.requireStatus(http.StatusCreated, err) |
|
|
| _, err = c.Plans.Publish(t.Context(), plan.ID) |
| c.requireStatus(http.StatusOK, err) |
|
|
| |
| |
| anchor := time.Now().Add(-time.Second) |
| subBody := v3sdk.SubscriptionCreate{ |
| BillingAnchor: &anchor, |
| Customer: v3sdk.SubscriptionChangeCustomer{ID: &customer.ID}, |
| Plan: v3sdk.SubscriptionChangePlan{ID: &plan.ID}, |
| } |
|
|
| sub, err := c.Subscriptions.Create(t.Context(), subBody) |
| c.requireStatus(http.StatusCreated, err) |
|
|
| t.Run("Should accept next_billing_cycle timing and return 201 with quantity 0", func(t *testing.T) { |
| timing := lo.Must(v3sdk.SubscriptionEditTimingFromEnum(v3sdk.SubscriptionEditTimingEnumNextBillingCycle)) |
|
|
| subAddon, err := c.Subscriptions.CreateAddon(t.Context(), sub.ID, v3sdk.CreateSubscriptionAddonRequest{ |
| Addon: v3sdk.AddonReference{ID: addon.ID}, |
| Quantity: 1, |
| Timing: timing, |
| }) |
| c.requireStatus(http.StatusCreated, err) |
| require.NotNil(t, subAddon) |
|
|
| |
| |
| assert.EqualValues(t, 0, subAddon.Quantity, "current quantity must be 0 for future-active addon") |
| require.NotEmpty(t, subAddon.Timeline) |
| assert.EqualValues(t, 1, subAddon.Timeline[0].Quantity) |
| assert.True(t, subAddon.Timeline[0].ActiveFrom.After(time.Now()), "next_billing_cycle timing must produce a future active_from") |
| }) |
| } |
|
|