mirror of
https://github.com/bitwarden/server
synced 2026-02-12 06:23:28 +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:
@@ -1,8 +1,9 @@
|
||||
using Bit.Api.Billing.Attributes;
|
||||
using Bit.Api.Billing.Models.Requests.Tax;
|
||||
using Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Organizations.Commands;
|
||||
using Bit.Core.Billing.Premium.Commands;
|
||||
using Bit.Core.Entities;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
@@ -10,10 +11,11 @@ using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
namespace Bit.Api.Billing.Controllers;
|
||||
|
||||
[Authorize("Application")]
|
||||
[Route("billing/tax")]
|
||||
public class TaxController(
|
||||
[Route("billing/preview-invoice")]
|
||||
public class PreviewInvoiceController(
|
||||
IPreviewOrganizationTaxCommand previewOrganizationTaxCommand,
|
||||
IPreviewPremiumTaxCommand previewPremiumTaxCommand) : BaseBillingController
|
||||
IPreviewPremiumTaxCommand previewPremiumTaxCommand,
|
||||
IPreviewPremiumUpgradeProrationCommand previewPremiumUpgradeProrationCommand) : BaseBillingController
|
||||
{
|
||||
[HttpPost("organizations/subscriptions/purchase")]
|
||||
public async Task<IResult> PreviewOrganizationSubscriptionPurchaseTaxAsync(
|
||||
@@ -21,11 +23,7 @@ public class TaxController(
|
||||
{
|
||||
var (purchase, billingAddress) = request.ToDomain();
|
||||
var result = await previewOrganizationTaxCommand.Run(purchase, billingAddress);
|
||||
return Handle(result.Map(pair => new
|
||||
{
|
||||
pair.Tax,
|
||||
pair.Total
|
||||
}));
|
||||
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
|
||||
}
|
||||
|
||||
[HttpPost("organizations/{organizationId:guid}/subscription/plan-change")]
|
||||
@@ -36,11 +34,7 @@ public class TaxController(
|
||||
{
|
||||
var (planChange, billingAddress) = request.ToDomain();
|
||||
var result = await previewOrganizationTaxCommand.Run(organization, planChange, billingAddress);
|
||||
return Handle(result.Map(pair => new
|
||||
{
|
||||
pair.Tax,
|
||||
pair.Total
|
||||
}));
|
||||
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
|
||||
}
|
||||
|
||||
[HttpPut("organizations/{organizationId:guid}/subscription/update")]
|
||||
@@ -51,11 +45,7 @@ public class TaxController(
|
||||
{
|
||||
var update = request.ToDomain();
|
||||
var result = await previewOrganizationTaxCommand.Run(organization, update);
|
||||
return Handle(result.Map(pair => new
|
||||
{
|
||||
pair.Tax,
|
||||
pair.Total
|
||||
}));
|
||||
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
|
||||
}
|
||||
|
||||
[HttpPost("premium/subscriptions/purchase")]
|
||||
@@ -64,10 +54,29 @@ public class TaxController(
|
||||
{
|
||||
var (purchase, billingAddress) = request.ToDomain();
|
||||
var result = await previewPremiumTaxCommand.Run(purchase, billingAddress);
|
||||
return Handle(result.Map(pair => new
|
||||
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
|
||||
}
|
||||
|
||||
[HttpPost("premium/subscriptions/upgrade")]
|
||||
[InjectUser]
|
||||
public async Task<IResult> PreviewPremiumUpgradeProrationAsync(
|
||||
[BindNever] User user,
|
||||
[FromBody] PreviewPremiumUpgradeProrationRequest request)
|
||||
{
|
||||
var (planType, billingAddress) = request.ToDomain();
|
||||
|
||||
var result = await previewPremiumUpgradeProrationCommand.Run(
|
||||
user,
|
||||
planType,
|
||||
billingAddress);
|
||||
|
||||
return Handle(result.Map(proration => new
|
||||
{
|
||||
pair.Tax,
|
||||
pair.Total
|
||||
proration.NewPlanProratedAmount,
|
||||
proration.Credit,
|
||||
proration.Tax,
|
||||
proration.Total,
|
||||
proration.NewPlanProratedMonths
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -132,8 +132,8 @@ public class AccountBillingVNextController(
|
||||
[BindNever] User user,
|
||||
[FromBody] UpgradePremiumToOrganizationRequest request)
|
||||
{
|
||||
var (organizationName, key, planType) = request.ToDomain();
|
||||
var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType);
|
||||
var (organizationName, key, planType, billingAddress) = request.ToDomain();
|
||||
var result = await upgradePremiumToOrganizationCommand.Run(user, organizationName, key, planType, billingAddress);
|
||||
return Handle(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.Billing.Enums;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Premium;
|
||||
@@ -14,24 +15,30 @@ public class UpgradePremiumToOrganizationRequest
|
||||
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public ProductTierType Tier { get; set; }
|
||||
public required ProductTierType TargetProductTierType { get; set; }
|
||||
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public PlanCadenceType Cadence { get; set; }
|
||||
public required MinimalBillingAddressRequest BillingAddress { get; set; }
|
||||
|
||||
private PlanType PlanType =>
|
||||
Tier switch
|
||||
private PlanType PlanType
|
||||
{
|
||||
get
|
||||
{
|
||||
ProductTierType.Families => PlanType.FamiliesAnnually,
|
||||
ProductTierType.Teams => Cadence == PlanCadenceType.Monthly
|
||||
? PlanType.TeamsMonthly
|
||||
: PlanType.TeamsAnnually,
|
||||
ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly
|
||||
? PlanType.EnterpriseMonthly
|
||||
: PlanType.EnterpriseAnnually,
|
||||
_ => throw new InvalidOperationException("Cannot upgrade to an Organization subscription that isn't Families, Teams or Enterprise.")
|
||||
};
|
||||
if (TargetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise))
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot upgrade Premium subscription to {TargetProductTierType} plan.");
|
||||
}
|
||||
|
||||
public (string OrganizationName, string Key, PlanType PlanType) ToDomain() => (OrganizationName, Key, PlanType);
|
||||
return TargetProductTierType switch
|
||||
{
|
||||
ProductTierType.Families => PlanType.FamiliesAnnually,
|
||||
ProductTierType.Teams => PlanType.TeamsAnnually,
|
||||
ProductTierType.Enterprise => PlanType.EnterpriseAnnually,
|
||||
_ => throw new InvalidOperationException($"Unexpected ProductTierType: {TargetProductTierType}")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public (string OrganizationName, string Key, PlanType PlanType, Core.Billing.Payment.Models.BillingAddress BillingAddress) ToDomain() =>
|
||||
(OrganizationName, Key, PlanType, BillingAddress.ToDomain());
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Tax;
|
||||
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
|
||||
public record PreviewOrganizationSubscriptionPlanChangeTaxRequest
|
||||
{
|
||||
@@ -4,7 +4,7 @@ using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Tax;
|
||||
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
|
||||
public record PreviewOrganizationSubscriptionPurchaseTaxRequest
|
||||
{
|
||||
@@ -1,7 +1,7 @@
|
||||
using Bit.Api.Billing.Models.Requests.Organizations;
|
||||
using Bit.Core.Billing.Organizations.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Tax;
|
||||
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
|
||||
public class PreviewOrganizationSubscriptionUpdateTaxRequest
|
||||
{
|
||||
@@ -2,7 +2,7 @@
|
||||
using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.Tax;
|
||||
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
|
||||
public record PreviewPremiumSubscriptionPurchaseTaxRequest
|
||||
{
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bit.Api.Billing.Models.Requests.Payment;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
|
||||
namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;
|
||||
|
||||
public record PreviewPremiumUpgradeProrationRequest
|
||||
{
|
||||
[Required]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public required ProductTierType TargetProductTierType { get; set; }
|
||||
|
||||
[Required]
|
||||
public required MinimalBillingAddressRequest BillingAddress { get; set; }
|
||||
|
||||
private PlanType PlanType
|
||||
{
|
||||
get
|
||||
{
|
||||
if (TargetProductTierType is not (ProductTierType.Families or ProductTierType.Teams or ProductTierType.Enterprise))
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot upgrade Premium subscription to {TargetProductTierType} plan.");
|
||||
}
|
||||
|
||||
return TargetProductTierType switch
|
||||
{
|
||||
ProductTierType.Families => PlanType.FamiliesAnnually,
|
||||
ProductTierType.Teams => PlanType.TeamsAnnually,
|
||||
ProductTierType.Enterprise => PlanType.EnterpriseAnnually,
|
||||
_ => throw new InvalidOperationException($"Unexpected ProductTierType: {TargetProductTierType}")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public (PlanType, BillingAddress) ToDomain() =>
|
||||
(PlanType, BillingAddress.ToDomain());
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
36
src/Core/Billing/Premium/Models/PremiumUpgradeProration.cs
Normal file
36
src/Core/Billing/Premium/Models/PremiumUpgradeProration.cs
Normal 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user