From d6a744681a15e8b3685846e0cd724ae36c1c652f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 23:49:44 +0000 Subject: [PATCH 1/6] Initial plan From 702b7f7e4814b0d52b9f837e8a58372a6af37874 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 31 May 2026 23:56:37 +0000 Subject: [PATCH 2/6] Align Stripe plan changes to monthly billing anchor Co-authored-by: niemyjski <1020579+niemyjski@users.noreply.github.com> --- .../Api/Handlers/OrganizationHandler.cs | 30 +++++++++++++++++-- .../Endpoints/OrganizationEndpointTests.cs | 7 +++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs index b2984736f1..156072ea6e 100644 --- a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -467,7 +467,15 @@ public async Task> Handle(ChangeOrganizationPlan messag var subscriptionOptions = new SubscriptionCreateOptions { Customer = customer.Id, - Items = [new SubscriptionItemOptions { Price = model.PlanId }] + Items = [new SubscriptionItemOptions { Price = model.PlanId }], + BillingCycleAnchorConfig = new SubscriptionBillingCycleAnchorConfigOptions + { + DayOfMonth = 1, + Hour = 0, + Minute = 0, + Second = 0 + }, + ProrationBehavior = "create_prorations" }; if (isPaymentMethod) @@ -484,8 +492,24 @@ public async Task> Handle(ChangeOrganizationPlan messag } else { - var update = new SubscriptionUpdateOptions { Items = [] }; - var create = new SubscriptionCreateOptions { Customer = organization.StripeCustomerId, Items = [] }; + var update = new SubscriptionUpdateOptions + { + Items = [], + ProrationBehavior = "create_prorations" + }; + var create = new SubscriptionCreateOptions + { + Customer = organization.StripeCustomerId, + Items = [], + BillingCycleAnchorConfig = new SubscriptionBillingCycleAnchorConfigOptions + { + DayOfMonth = 1, + Hour = 0, + Minute = 0, + Second = 0 + }, + ProrationBehavior = "create_prorations" + }; bool cardUpdated = false; var customerUpdateOptions = new CustomerUpdateOptions { Description = organization.Name }; diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index 13d0c6fbb5..0542e85546 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -1538,6 +1538,12 @@ public async Task ChangePlanAsync_WithNewCustomer_CreatesStripeCustomerAndSubscr var item = Assert.Single(subscription.Items); Assert.Equal(_plans.SmallPlan.Id, item.Price); Assert.Equal("coupon_10", Assert.Single(subscription.Discounts).Coupon); + Assert.Equal("create_prorations", subscription.ProrationBehavior); + Assert.NotNull(subscription.BillingCycleAnchorConfig); + Assert.Equal(1, subscription.BillingCycleAnchorConfig.DayOfMonth); + Assert.Equal(0, subscription.BillingCycleAnchorConfig.Hour); + Assert.Equal(0, subscription.BillingCycleAnchorConfig.Minute); + Assert.Equal(0, subscription.BillingCycleAnchorConfig.Second); var organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); Assert.NotNull(organization); @@ -1592,6 +1598,7 @@ public async Task ChangePlanAsync_WithExistingCustomer_UpdatesPaymentMethodAndSu Assert.Equal("si_active", item.Id); Assert.Equal(_plans.SmallPlan.Id, item.Price); Assert.Equal("coupon_10", Assert.Single(updatedSubscription.Options.Discounts).Coupon); + Assert.Equal("create_prorations", updatedSubscription.Options.ProrationBehavior); Assert.Empty(StripeBillingClient.CreatedSubscriptionOptions); var organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); From a728cf31a8a7681746181c48f4fbd5a85f201774 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:00:12 +0000 Subject: [PATCH 3/6] Deduplicate monthly Stripe anchor configuration Co-authored-by: niemyjski <1020579+niemyjski@users.noreply.github.com> --- .../Api/Handlers/OrganizationHandler.cs | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs index 156072ea6e..8167db307b 100644 --- a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -468,13 +468,7 @@ public async Task> Handle(ChangeOrganizationPlan messag { Customer = customer.Id, Items = [new SubscriptionItemOptions { Price = model.PlanId }], - BillingCycleAnchorConfig = new SubscriptionBillingCycleAnchorConfigOptions - { - DayOfMonth = 1, - Hour = 0, - Minute = 0, - Second = 0 - }, + BillingCycleAnchorConfig = CreateMonthlyBillingCycleAnchorConfig(), ProrationBehavior = "create_prorations" }; @@ -501,13 +495,7 @@ public async Task> Handle(ChangeOrganizationPlan messag { Customer = organization.StripeCustomerId, Items = [], - BillingCycleAnchorConfig = new SubscriptionBillingCycleAnchorConfigOptions - { - DayOfMonth = 1, - Hour = 0, - Minute = 0, - Second = 0 - }, + BillingCycleAnchorConfig = CreateMonthlyBillingCycleAnchorConfig(), ProrationBehavior = "create_prorations" }; bool cardUpdated = false; @@ -648,6 +636,17 @@ public async Task> Handle(ChangeOrganizationPlan messag return new ChangePlanResult { Success = true }; } + private static SubscriptionBillingCycleAnchorConfigOptions CreateMonthlyBillingCycleAnchorConfig() + { + return new SubscriptionBillingCycleAnchorConfigOptions + { + DayOfMonth = 1, + Hour = 0, + Minute = 0, + Second = 0 + }; + } + public async Task> Handle(AddOrganizationUser message) { if (String.IsNullOrEmpty(message.Id) || !message.Context.Request.CanAccessOrganization(message.Id) || String.IsNullOrEmpty(message.Email)) From c16c3b1fc52562111b5442c66bea93380f4431ea Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 25 Jul 2026 20:46:07 -0500 Subject: [PATCH 4/6] Centralize month-aligned Stripe subscription options --- .../Api/Handlers/OrganizationHandler.cs | 35 ++++++++----------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs index 8167db307b..f18f28c161 100644 --- a/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs @@ -464,13 +464,8 @@ public async Task> Handle(ChangeOrganizationPlan messag organization.CardLast4 = model.Last4; await repository.SaveAsync(organization, o => o.ImmediateConsistency().Cache()); - var subscriptionOptions = new SubscriptionCreateOptions - { - Customer = customer.Id, - Items = [new SubscriptionItemOptions { Price = model.PlanId }], - BillingCycleAnchorConfig = CreateMonthlyBillingCycleAnchorConfig(), - ProrationBehavior = "create_prorations" - }; + var subscriptionOptions = CreateMonthAlignedSubscriptionOptions(customer.Id); + subscriptionOptions.Items.Add(new SubscriptionItemOptions { Price = model.PlanId }); if (isPaymentMethod) subscriptionOptions.DefaultPaymentMethod = model.StripeToken; @@ -491,13 +486,7 @@ public async Task> Handle(ChangeOrganizationPlan messag Items = [], ProrationBehavior = "create_prorations" }; - var create = new SubscriptionCreateOptions - { - Customer = organization.StripeCustomerId, - Items = [], - BillingCycleAnchorConfig = CreateMonthlyBillingCycleAnchorConfig(), - ProrationBehavior = "create_prorations" - }; + var create = CreateMonthAlignedSubscriptionOptions(organization.StripeCustomerId); bool cardUpdated = false; var customerUpdateOptions = new CustomerUpdateOptions { Description = organization.Name }; @@ -636,14 +625,20 @@ public async Task> Handle(ChangeOrganizationPlan messag return new ChangePlanResult { Success = true }; } - private static SubscriptionBillingCycleAnchorConfigOptions CreateMonthlyBillingCycleAnchorConfig() + private static SubscriptionCreateOptions CreateMonthAlignedSubscriptionOptions(string customerId) { - return new SubscriptionBillingCycleAnchorConfigOptions + return new SubscriptionCreateOptions { - DayOfMonth = 1, - Hour = 0, - Minute = 0, - Second = 0 + Customer = customerId, + Items = [], + BillingCycleAnchorConfig = new SubscriptionBillingCycleAnchorConfigOptions + { + DayOfMonth = 1, + Hour = 0, + Minute = 0, + Second = 0 + }, + ProrationBehavior = "create_prorations" }; } From 97f4f2eeee142e58ed9d3128aace6ca8b27aa9e6 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 15 Sep 2026 19:23:11 -0500 Subject: [PATCH 5/6] Preserve billing anchor coverage after rebase --- .../Endpoints/OrganizationEndpointTests.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index 0542e85546..2d9ef020a8 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -1599,6 +1599,8 @@ public async Task ChangePlanAsync_WithExistingCustomer_UpdatesPaymentMethodAndSu Assert.Equal(_plans.SmallPlan.Id, item.Price); Assert.Equal("coupon_10", Assert.Single(updatedSubscription.Options.Discounts).Coupon); Assert.Equal("create_prorations", updatedSubscription.Options.ProrationBehavior); + Assert.Null(updatedSubscription.Options.BillingCycleAnchor); + Assert.Null(updatedSubscription.Options.TrialEnd); Assert.Empty(StripeBillingClient.CreatedSubscriptionOptions); var organization = await _organizationRepository.GetByIdAsync(SampleDataService.FREE_ORG_ID); @@ -1610,6 +1612,40 @@ public async Task ChangePlanAsync_WithExistingCustomer_UpdatesPaymentMethodAndSu Assert.Equal(BillingStatus.Active, organization.BillingStatus); } + [Fact] + public async Task ChangePlanAsync_ExistingCustomerWithoutSubscription_CreatesMonthAlignedSubscription() + { + await SetStripeCustomerIdAsync(SampleDataService.FREE_ORG_ID, "cus_existing"); + + var result = await WithBillingEnabledAsync(() => + SendRequestAsAsync(r => r + .AsFreeOrganizationUser() + .Post() + .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "change-plan") + .Content(new ChangePlanRequest + { + PlanId = _plans.SmallPlan.Id, + StripeToken = "pm_card_visa", + Last4 = "4242" + }) + .StatusCodeShouldBeOk() + )); + + Assert.NotNull(result); + Assert.True(result.Success, result.Message); + + var subscription = Assert.Single(StripeBillingClient.CreatedSubscriptionOptions); + Assert.Equal("cus_existing", subscription.Customer); + Assert.Equal(_plans.SmallPlan.Id, Assert.Single(subscription.Items).Price); + Assert.Equal("create_prorations", subscription.ProrationBehavior); + Assert.NotNull(subscription.BillingCycleAnchorConfig); + Assert.Equal(1, subscription.BillingCycleAnchorConfig.DayOfMonth); + Assert.Equal(0, subscription.BillingCycleAnchorConfig.Hour); + Assert.Equal(0, subscription.BillingCycleAnchorConfig.Minute); + Assert.Equal(0, subscription.BillingCycleAnchorConfig.Second); + Assert.Empty(StripeBillingClient.UpdatedSubscriptions); + } + [Fact] public async Task ChangePlanAsync_WithFreePlan_CancelsActiveStripeSubscriptions() { From 43806851fbbf65b84b2b486474598ebb58d526df Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Tue, 15 Sep 2026 19:24:14 -0500 Subject: [PATCH 6/6] Cover yearly subscription anchor options --- .../Endpoints/OrganizationEndpointTests.cs | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs index 2d9ef020a8..c0defc4643 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OrganizationEndpointTests.cs @@ -1504,10 +1504,13 @@ public async Task ChangePlanAsync_PaidPlanWithoutBillingInformation_ReturnsFailu Assert.Equal("Billing information was not set.", result.Message); } - [Fact] - public async Task ChangePlanAsync_WithNewCustomer_CreatesStripeCustomerAndSubscription() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ChangePlanAsync_WithNewCustomer_CreatesStripeCustomerAndSubscription(bool yearly) { // Arrange + var plan = yearly ? _plans.SmallYearlyPlan : _plans.SmallPlan; StripeBillingClient.CustomerToReturn = new Customer { Id = "cus_created" }; // Act @@ -1518,7 +1521,7 @@ public async Task ChangePlanAsync_WithNewCustomer_CreatesStripeCustomerAndSubscr .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "change-plan") .Content(new ChangePlanRequest { - PlanId = _plans.SmallPlan.Id, + PlanId = plan.Id, StripeToken = "tok_visa", Last4 = "4242", CouponId = "coupon_10" @@ -1536,11 +1539,12 @@ public async Task ChangePlanAsync_WithNewCustomer_CreatesStripeCustomerAndSubscr var subscription = Assert.Single(StripeBillingClient.CreatedSubscriptionOptions); Assert.Equal("cus_created", subscription.Customer); var item = Assert.Single(subscription.Items); - Assert.Equal(_plans.SmallPlan.Id, item.Price); + Assert.Equal(plan.Id, item.Price); Assert.Equal("coupon_10", Assert.Single(subscription.Discounts).Coupon); Assert.Equal("create_prorations", subscription.ProrationBehavior); Assert.NotNull(subscription.BillingCycleAnchorConfig); Assert.Equal(1, subscription.BillingCycleAnchorConfig.DayOfMonth); + Assert.Null(subscription.BillingCycleAnchorConfig.Month); Assert.Equal(0, subscription.BillingCycleAnchorConfig.Hour); Assert.Equal(0, subscription.BillingCycleAnchorConfig.Minute); Assert.Equal(0, subscription.BillingCycleAnchorConfig.Second); @@ -1550,7 +1554,7 @@ public async Task ChangePlanAsync_WithNewCustomer_CreatesStripeCustomerAndSubscr Assert.Equal("cus_created", organization.StripeCustomerId); Assert.Equal("sub_created", organization.StripeSubscriptionId); Assert.Equal("4242", organization.CardLast4); - Assert.Equal(_plans.SmallPlan.Id, organization.PlanId); + Assert.Equal(plan.Id, organization.PlanId); Assert.Equal(BillingStatus.Active, organization.BillingStatus); } @@ -1612,9 +1616,12 @@ public async Task ChangePlanAsync_WithExistingCustomer_UpdatesPaymentMethodAndSu Assert.Equal(BillingStatus.Active, organization.BillingStatus); } - [Fact] - public async Task ChangePlanAsync_ExistingCustomerWithoutSubscription_CreatesMonthAlignedSubscription() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ChangePlanAsync_ExistingCustomerWithoutSubscription_CreatesMonthAlignedSubscription(bool yearly) { + var plan = yearly ? _plans.SmallYearlyPlan : _plans.SmallPlan; await SetStripeCustomerIdAsync(SampleDataService.FREE_ORG_ID, "cus_existing"); var result = await WithBillingEnabledAsync(() => @@ -1624,7 +1631,7 @@ public async Task ChangePlanAsync_ExistingCustomerWithoutSubscription_CreatesMon .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "change-plan") .Content(new ChangePlanRequest { - PlanId = _plans.SmallPlan.Id, + PlanId = plan.Id, StripeToken = "pm_card_visa", Last4 = "4242" }) @@ -1636,10 +1643,11 @@ public async Task ChangePlanAsync_ExistingCustomerWithoutSubscription_CreatesMon var subscription = Assert.Single(StripeBillingClient.CreatedSubscriptionOptions); Assert.Equal("cus_existing", subscription.Customer); - Assert.Equal(_plans.SmallPlan.Id, Assert.Single(subscription.Items).Price); + Assert.Equal(plan.Id, Assert.Single(subscription.Items).Price); Assert.Equal("create_prorations", subscription.ProrationBehavior); Assert.NotNull(subscription.BillingCycleAnchorConfig); Assert.Equal(1, subscription.BillingCycleAnchorConfig.DayOfMonth); + Assert.Null(subscription.BillingCycleAnchorConfig.Month); Assert.Equal(0, subscription.BillingCycleAnchorConfig.Hour); Assert.Equal(0, subscription.BillingCycleAnchorConfig.Minute); Assert.Equal(0, subscription.BillingCycleAnchorConfig.Second);