1
0
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:
cyprain-okeke
2025-07-22 15:57:58 +01:00
committed by GitHub
parent f4e1e2f1f7
commit 8a5823bff7
6 changed files with 143 additions and 0 deletions

View File

@@ -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();
}
}

View File

@@ -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; }
}

View File

@@ -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)

View File

@@ -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";
}
}

View File

@@ -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);
}

View File

@@ -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
}