mirror of
https://github.com/bitwarden/server
synced 2025-12-06 00:03:34 +00:00
[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 <cokeke@bitwarden.com> * Refactor the code Signed-off-by: Cy Okeke <cokeke@bitwarden.com> * Refactor for thr update change frequency * Add Automatic modal opening * catch for organization paying with PayPal --------- Signed-off-by: Cy Okeke <cokeke@bitwarden.com>
This commit is contained in:
@@ -382,4 +382,35 @@ public class OrganizationBillingController(
|
||||
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("change-frequency")]
|
||||
[SelfHosted(NotSelfHostedOnly = true)]
|
||||
public async Task<IResult> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="organization">The Organization whose subscription to update.</param>
|
||||
/// <param name="newPlanType">The Stripe price/plan for the new Password Manager and secrets manager.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when the <paramref name="organization"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="BillingException">Thrown when no payment method is found for the customer, no plan IDs are provided, or subscription update fails.</exception>
|
||||
Task UpdateSubscriptionPlanFrequency(Organization organization, PlanType newPlanType);
|
||||
}
|
||||
|
||||
@@ -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<SubscriptionItemOptions>();
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user