1
0
mirror of https://github.com/bitwarden/server synced 2026-02-20 11:23:37 +00:00

[PM-29599] create proration preview endpoint (#6858)

* [PM-29599] create proration preview endpoint

* forgot to inject user and fixing stripe errors

* updated proration preview and upgrade to be consistent

also using the correct proration behavior and making the upgrade flow start a trial

* missed using the billing address

* changes to proration behavior

and returning more properties from the proration endpoint

* missed in refactor

* pr feedback
This commit is contained in:
Kyle Denney
2026-02-03 10:08:14 -06:00
committed by GitHub
parent cee89dbe83
commit 4f4ccac2de
16 changed files with 1623 additions and 90 deletions

View File

@@ -59,6 +59,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ICreatePremiumCloudHostedSubscriptionCommand, CreatePremiumCloudHostedSubscriptionCommand>();
services.AddScoped<ICreatePremiumSelfHostedSubscriptionCommand, CreatePremiumSelfHostedSubscriptionCommand>();
services.AddTransient<IPreviewPremiumTaxCommand, PreviewPremiumTaxCommand>();
services.AddScoped<IPreviewPremiumUpgradeProrationCommand, PreviewPremiumUpgradeProrationCommand>();
services.AddScoped<IUpdatePremiumStorageCommand, UpdatePremiumStorageCommand>();
services.AddScoped<IUpgradePremiumToOrganizationCommand, UpgradePremiumToOrganizationCommand>();
}

View File

@@ -0,0 +1,166 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Premium.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Billing.Premium.Commands;
/// <summary>
/// Previews the proration details for upgrading a Premium user subscription to an Organization
/// plan by using the Stripe API to create an invoice preview, prorated, for the upgrade.
/// </summary>
public interface IPreviewPremiumUpgradeProrationCommand
{
/// <summary>
/// Calculates the tax, total cost, and proration credit for upgrading a Premium subscription to an Organization plan.
/// </summary>
/// <param name="user">The user with an active Premium subscription.</param>
/// <param name="targetPlanType">The target organization plan type.</param>
/// <param name="billingAddress">The billing address for tax calculation.</param>
/// <returns>The proration details for the upgrade including costs, credits, tax, and time remaining.</returns>
Task<BillingCommandResult<PremiumUpgradeProration>> Run(
User user,
PlanType targetPlanType,
BillingAddress billingAddress);
}
public class PreviewPremiumUpgradeProrationCommand(
ILogger<PreviewPremiumUpgradeProrationCommand> logger,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter)
: BaseBillingCommand<PreviewPremiumUpgradeProrationCommand>(logger),
IPreviewPremiumUpgradeProrationCommand
{
public Task<BillingCommandResult<PremiumUpgradeProration>> Run(
User user,
PlanType targetPlanType,
BillingAddress billingAddress) => HandleAsync<PremiumUpgradeProration>(async () =>
{
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
{
return new BadRequest("User does not have an active Premium subscription.");
}
var currentSubscription = await stripeAdapter.GetSubscriptionAsync(
user.GatewaySubscriptionId,
new SubscriptionGetOptions { Expand = ["customer"] });
var premiumPlans = await pricingClient.ListPremiumPlans();
var passwordManagerItem = currentSubscription.Items.Data.FirstOrDefault(i =>
premiumPlans.Any(p => p.Seat.StripePriceId == i.Price.Id));
if (passwordManagerItem == null)
{
return new BadRequest("Premium subscription password manager item not found.");
}
var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
var targetPlan = await pricingClient.GetPlanOrThrow(targetPlanType);
var subscriptionItems = new List<InvoiceSubscriptionDetailsItemOptions>();
var storageItem = currentSubscription.Items.Data.FirstOrDefault(i =>
i.Price.Id == usersPremiumPlan.Storage.StripePriceId);
// Delete the storage item if it exists for this user's plan
if (storageItem != null)
{
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
{
Id = storageItem.Id,
Deleted = true
});
}
// Hardcode seats to 1 for upgrade flow
if (targetPlan.HasNonSeatBasedPasswordManagerPlan())
{
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
{
Id = passwordManagerItem.Id,
Price = targetPlan.PasswordManager.StripePlanId,
Quantity = 1
});
}
else
{
subscriptionItems.Add(new InvoiceSubscriptionDetailsItemOptions
{
Id = passwordManagerItem.Id,
Price = targetPlan.PasswordManager.StripeSeatPlanId,
Quantity = 1
});
}
var options = new InvoiceCreatePreviewOptions
{
AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true },
Customer = user.GatewayCustomerId,
Subscription = user.GatewaySubscriptionId,
CustomerDetails = new InvoiceCustomerDetailsOptions
{
Address = new AddressOptions
{
Country = billingAddress.Country,
PostalCode = billingAddress.PostalCode
}
},
SubscriptionDetails = new InvoiceSubscriptionDetailsOptions
{
Items = subscriptionItems,
ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice
}
};
var invoicePreview = await stripeAdapter.CreateInvoicePreviewAsync(options);
var proration = GetProration(invoicePreview, passwordManagerItem);
return proration;
});
private static PremiumUpgradeProration GetProration(Invoice invoicePreview, SubscriptionItem passwordManagerItem) => new()
{
NewPlanProratedAmount = GetNewPlanProratedAmountFromInvoice(invoicePreview),
Credit = GetProrationCreditFromInvoice(invoicePreview),
Tax = Convert.ToDecimal(invoicePreview.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount)) / 100,
Total = Convert.ToDecimal(invoicePreview.Total) / 100,
// Use invoice periodEnd here instead of UtcNow so that testing with Stripe time clocks works correctly. And if there is no test clock,
// (like in production), the previewInvoice's periodEnd is the same as UtcNow anyway because of the proration behavior (always_invoice)
NewPlanProratedMonths = CalculateNewPlanProratedMonths(invoicePreview.PeriodEnd, passwordManagerItem.CurrentPeriodEnd)
};
private static decimal GetProrationCreditFromInvoice(Invoice invoicePreview)
{
// Extract proration credit from negative line items (credits are negative in Stripe)
var prorationCredit = invoicePreview.Lines?.Data?
.Where(line => line.Amount < 0)
.Sum(line => Math.Abs(line.Amount)) ?? 0; // Return the credit as positive number
return Convert.ToDecimal(prorationCredit) / 100;
}
private static decimal GetNewPlanProratedAmountFromInvoice(Invoice invoicePreview)
{
// The target plan's prorated upgrade amount should be the only positive-valued line item
var proratedTotal = invoicePreview.Lines?.Data?
.Where(line => line.Amount > 0)
.Sum(line => line.Amount) ?? 0;
return Convert.ToDecimal(proratedTotal) / 100;
}
private static int CalculateNewPlanProratedMonths(DateTime invoicePeriodEnd, DateTime currentPeriodEnd)
{
var daysInProratedPeriod = (currentPeriodEnd - invoicePeriodEnd).TotalDays;
// Round to nearest month (30-day periods)
// 1-14 days = 1 month, 15-44 days = 1 month, 45-74 days = 2 months, etc.
// Minimum is always 1 month (never returns 0)
// Use MidpointRounding.AwayFromZero to round 0.5 up to 1
var months = (int)Math.Round(daysInProratedPeriod / 30, MidpointRounding.AwayFromZero);
return Math.Max(1, months);
}
}

View File

@@ -27,12 +27,14 @@ public interface IUpgradePremiumToOrganizationCommand
/// <param name="organizationName">The name for the new organization.</param>
/// <param name="key">The encrypted organization key for the owner.</param>
/// <param name="targetPlanType">The target organization plan type to upgrade to.</param>
/// <param name="billingAddress">The billing address for tax calculation.</param>
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
Task<BillingCommandResult<None>> Run(
User user,
string organizationName,
string key,
PlanType targetPlanType);
PlanType targetPlanType,
Payment.Models.BillingAddress billingAddress);
}
public class UpgradePremiumToOrganizationCommand(
@@ -50,7 +52,8 @@ public class UpgradePremiumToOrganizationCommand(
User user,
string organizationName,
string key,
PlanType targetPlanType) => HandleAsync<None>(async () =>
PlanType targetPlanType,
Payment.Models.BillingAddress billingAddress) => HandleAsync<None>(async () =>
{
// Validate that the user has an active Premium subscription
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
@@ -73,7 +76,7 @@ public class UpgradePremiumToOrganizationCommand(
if (passwordManagerItem == null)
{
return new BadRequest("Premium subscription item not found.");
return new BadRequest("Premium subscription password manager item not found.");
}
var usersPremiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
@@ -84,13 +87,6 @@ public class UpgradePremiumToOrganizationCommand(
// Build the list of subscription item updates
var subscriptionItemOptions = new List<SubscriptionItemOptions>();
// Delete the user's specific password manager item
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Id = passwordManagerItem.Id,
Deleted = true
});
// Delete the storage item if it exists for this user's plan
var storageItem = currentSubscription.Items.Data.FirstOrDefault(i =>
i.Price.Id == usersPremiumPlan.Storage.StripePriceId);
@@ -109,6 +105,7 @@ public class UpgradePremiumToOrganizationCommand(
{
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Id = passwordManagerItem.Id,
Price = targetPlan.PasswordManager.StripePlanId,
Quantity = 1
});
@@ -117,6 +114,7 @@ public class UpgradePremiumToOrganizationCommand(
{
subscriptionItemOptions.Add(new SubscriptionItemOptions
{
Id = passwordManagerItem.Id,
Price = targetPlan.PasswordManager.StripeSeatPlanId,
Quantity = seats
});
@@ -129,7 +127,9 @@ public class UpgradePremiumToOrganizationCommand(
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
{
Items = subscriptionItemOptions,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
ProrationBehavior = StripeConstants.ProrationBehavior.AlwaysInvoice,
BillingCycleAnchor = SubscriptionBillingCycleAnchor.Unchanged,
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.OrganizationId] = organizationId.ToString(),
@@ -144,7 +144,7 @@ public class UpgradePremiumToOrganizationCommand(
Name = organizationName,
BillingEmail = user.Email,
PlanType = targetPlan.Type,
Seats = (short)seats,
Seats = seats,
MaxCollections = targetPlan.PasswordManager.MaxCollections,
MaxStorageGb = targetPlan.PasswordManager.BaseStorageGb,
UsePolicies = targetPlan.HasPolicies,
@@ -174,6 +174,16 @@ public class UpgradePremiumToOrganizationCommand(
GatewaySubscriptionId = currentSubscription.Id
};
// Update customer billing address for tax calculation
await stripeAdapter.UpdateCustomerAsync(user.GatewayCustomerId, new CustomerUpdateOptions
{
Address = new AddressOptions
{
Country = billingAddress.Country,
PostalCode = billingAddress.PostalCode
}
});
// Update the subscription in Stripe
await stripeAdapter.UpdateSubscriptionAsync(currentSubscription.Id, subscriptionUpdateOptions);

View File

@@ -0,0 +1,36 @@
namespace Bit.Core.Billing.Premium.Models;
/// <summary>
/// Represents the proration details for upgrading a Premium user subscription to an Organization plan.
/// </summary>
public class PremiumUpgradeProration
{
/// <summary>
/// The prorated cost for the new organization plan, calculated from now until the end of the current billing period.
/// This represents what the user will pay for the upgraded plan for the remainder of the period.
/// </summary>
public decimal NewPlanProratedAmount { get; set; }
/// <summary>
/// The credit amount for the unused portion of the current Premium subscription.
/// This credit is applied against the cost of the new organization plan.
/// </summary>
public decimal Credit { get; set; }
/// <summary>
/// The tax amount calculated for the upgrade transaction.
/// </summary>
public decimal Tax { get; set; }
/// <summary>
/// The total amount due for the upgrade after applying the credit and adding tax.
/// </summary>
public decimal Total { get; set; }
/// <summary>
/// The number of months the user will be charged for the new organization plan in the prorated billing period.
/// Calculated by rounding the days remaining in the current billing cycle to the nearest month.
/// Minimum value is 1 month (never returns 0).
/// </summary>
public int NewPlanProratedMonths { get; set; }
}