From 8a5823bff73763c02d334c46f724df45757cbf3d Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 22 Jul 2025 15:57:58 +0100 Subject: [PATCH] [PM 18701]Optional payment modal after signup (#6014) * Add endpoint to swap plan frequency * Add endpoint to swap plan frequency * Resolve pr comments Signed-off-by: Cy Okeke * Refactor the code Signed-off-by: Cy Okeke * Refactor for thr update change frequency * Add Automatic modal opening * catch for organization paying with PayPal --------- Signed-off-by: Cy Okeke --- .../OrganizationBillingController.cs | 31 +++++++++ .../Requests/ChangePlanFrequencyRequest.cs | 10 +++ .../OrganizationWarningsQuery.cs | 15 ++++ src/Core/Billing/Constants/StripeConstants.cs | 7 ++ .../Services/IOrganizationBillingService.cs | 12 ++++ .../Services/OrganizationBillingService.cs | 68 +++++++++++++++++++ 6 files changed, 143 insertions(+) create mode 100644 src/Api/Billing/Models/Requests/ChangePlanFrequencyRequest.cs diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 50a302f6d2..b9db8d81f9 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -382,4 +382,35 @@ public class OrganizationBillingController( return TypedResults.Ok(response); } + + + [HttpPost("change-frequency")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task ChangePlanSubscriptionFrequencyAsync( + [FromRoute] Guid organizationId, + [FromBody] ChangePlanFrequencyRequest request) + { + if (!await currentContext.EditSubscription(organizationId)) + { + return Error.Unauthorized(); + } + + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) + { + return Error.NotFound(); + } + + if (organization.PlanType == request.NewPlanType) + { + return Error.BadRequest("Organization is already on the requested plan frequency."); + } + + await organizationBillingService.UpdateSubscriptionPlanFrequency( + organization, + request.NewPlanType); + + return TypedResults.Ok(); + } } diff --git a/src/Api/Billing/Models/Requests/ChangePlanFrequencyRequest.cs b/src/Api/Billing/Models/Requests/ChangePlanFrequencyRequest.cs new file mode 100644 index 0000000000..88fff85cb3 --- /dev/null +++ b/src/Api/Billing/Models/Requests/ChangePlanFrequencyRequest.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Enums; + +namespace Bit.Api.Billing.Models.Requests; + +public class ChangePlanFrequencyRequest +{ + [Required] + public PlanType NewPlanType { get; set; } +} diff --git a/src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs b/src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs index f6a0e5b1e6..7fbdf3c2b0 100644 --- a/src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs +++ b/src/Api/Billing/Queries/Organizations/OrganizationWarningsQuery.cs @@ -12,6 +12,7 @@ using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Services; using Stripe; +using static Bit.Core.Billing.Utilities; using FreeTrialWarning = Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.FreeTrialWarning; using InactiveSubscriptionWarning = Bit.Api.Billing.Models.Responses.Organizations.OrganizationWarningsResponse.InactiveSubscriptionWarning; @@ -100,6 +101,20 @@ public class OrganizationWarningsQuery( Provider? provider, Subscription subscription) { + if (organization.Enabled && subscription.Status is StripeConstants.SubscriptionStatus.Trialing) + { + var isStripeCustomerWithoutPayment = + subscription.Customer.InvoiceSettings.DefaultPaymentMethodId is null; + var isBraintreeCustomer = + subscription.Customer.Metadata.ContainsKey(BraintreeCustomerIdKey); + var hasNoPaymentMethod = isStripeCustomerWithoutPayment && !isBraintreeCustomer; + + if (hasNoPaymentMethod && await currentContext.OrganizationOwner(organization.Id)) + { + return new InactiveSubscriptionWarning { Resolution = "add_payment_method_optional_trial" }; + } + } + if (organization.Enabled || subscription.Status is not StripeConstants.SubscriptionStatus.Unpaid and not StripeConstants.SubscriptionStatus.Canceled) diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 6ecfb4d28b..7b4cb3baed 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -118,4 +118,11 @@ public static class StripeConstants public const string Deferred = "deferred"; public const string Immediately = "immediately"; } + + public static class MissingPaymentMethodBehaviorOptions + { + public const string CreateInvoice = "create_invoice"; + public const string Cancel = "cancel"; + public const string Pause = "pause"; + } } diff --git a/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs index f35bafdd29..d34bd86e7b 100644 --- a/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/IOrganizationBillingService.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Tax.Models; @@ -44,4 +45,15 @@ public interface IOrganizationBillingService Organization organization, TokenizedPaymentSource tokenizedPaymentSource, TaxInformation taxInformation); + + /// + /// Updates the subscription with new plan frequencies and changes the collection method to charge_automatically if a valid payment method exists. + /// Validates that the customer has a payment method attached before switching to automatic charging. + /// Handles both Password Manager and Secrets Manager subscription items separately to ensure billing interval compatibility. + /// + /// The Organization whose subscription to update. + /// The Stripe price/plan for the new Password Manager and secrets manager. + /// Thrown when the is . + /// Thrown when no payment method is found for the customer, no plan IDs are provided, or subscription update fails. + Task UpdateSubscriptionPlanFrequency(Organization organization, PlanType newPlanType); } diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 3fce618500..f32e835dbf 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -145,6 +145,55 @@ public class OrganizationBillingService( { await subscriberService.UpdatePaymentSource(organization, tokenizedPaymentSource); await subscriberService.UpdateTaxInformation(organization, taxInformation); + await UpdateMissingPaymentMethodBehaviourAsync(organization); + } + } + + public async Task UpdateSubscriptionPlanFrequency( + Organization organization, PlanType newPlanType) + { + ArgumentNullException.ThrowIfNull(organization); + + var subscription = await subscriberService.GetSubscriptionOrThrow(organization); + var subscriptionItems = subscription.Items.Data; + + var newPlan = await pricingClient.GetPlanOrThrow(newPlanType); + var oldPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + // Build the subscription update options + var subscriptionItemOptions = new List(); + foreach (var item in subscriptionItems) + { + var subscriptionItemOption = new SubscriptionItemOptions + { + Id = item.Id, + Quantity = item.Quantity, + Price = item.Price.Id == oldPlan.SecretsManager.StripeSeatPlanId ? newPlan.SecretsManager.StripeSeatPlanId : newPlan.PasswordManager.StripeSeatPlanId + }; + + subscriptionItemOptions.Add(subscriptionItemOption); + } + var updateOptions = new SubscriptionUpdateOptions + { + Items = subscriptionItemOptions, + ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations + }; + + try + { + // Update the subscription in Stripe + await stripeAdapter.SubscriptionUpdateAsync(subscription.Id, updateOptions); + organization.PlanType = newPlan.Type; + await organizationRepository.ReplaceAsync(organization); + } + catch (StripeException stripeException) + { + logger.LogError(stripeException, "Failed to update subscription plan for subscriber ({SubscriberID}): {Error}", + organization.Id, stripeException.Message); + + throw new BillingException( + message: "An error occurred while updating the subscription plan", + innerException: stripeException); } } @@ -545,5 +594,24 @@ public class OrganizationBillingService( return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); } + private async Task UpdateMissingPaymentMethodBehaviourAsync(Organization organization) + { + var subscription = await subscriberService.GetSubscriptionOrThrow(organization); + if (subscription.TrialSettings?.EndBehavior?.MissingPaymentMethod == StripeConstants.MissingPaymentMethodBehaviorOptions.Cancel) + { + var options = new SubscriptionUpdateOptions + { + TrialSettings = new SubscriptionTrialSettingsOptions + { + EndBehavior = new SubscriptionTrialSettingsEndBehaviorOptions + { + MissingPaymentMethod = StripeConstants.MissingPaymentMethodBehaviorOptions.CreateInvoice + } + } + }; + await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, options); + } + } + #endregion }