mirror of
https://github.com/bitwarden/server.git
synced 2026-01-31 22:23:18 +08:00
202 lines
7.1 KiB
C#
202 lines
7.1 KiB
C#
|
|
using Bit.Core.Billing.Constants;
|
|||
|
|
using Bit.Core.Billing.Enums;
|
|||
|
|
using Bit.Core.Billing.Extensions;
|
|||
|
|
using Bit.Core.Billing.Pricing;
|
|||
|
|
using Bit.Core.Billing.Services;
|
|||
|
|
using Bit.Core.Billing.Subscriptions.Models;
|
|||
|
|
using Bit.Core.Entities;
|
|||
|
|
using Bit.Core.Exceptions;
|
|||
|
|
using Microsoft.Extensions.Logging;
|
|||
|
|
using OneOf;
|
|||
|
|
using Stripe;
|
|||
|
|
|
|||
|
|
namespace Bit.Core.Billing.Subscriptions.Queries;
|
|||
|
|
|
|||
|
|
using static StripeConstants;
|
|||
|
|
using static Utilities;
|
|||
|
|
|
|||
|
|
public interface IGetBitwardenSubscriptionQuery
|
|||
|
|
{
|
|||
|
|
/// <summary>
|
|||
|
|
/// Retrieves detailed subscription information for a user, including subscription status,
|
|||
|
|
/// cart items, discounts, and billing details.
|
|||
|
|
/// </summary>
|
|||
|
|
/// <param name="user">The user whose subscription information to retrieve.</param>
|
|||
|
|
/// <returns>
|
|||
|
|
/// A <see cref="BitwardenSubscription"/> containing the subscription details, or null if no
|
|||
|
|
/// subscription is found or the subscription status is not recognized.
|
|||
|
|
/// </returns>
|
|||
|
|
/// <remarks>
|
|||
|
|
/// Currently only supports <see cref="User"/> subscribers. Future versions will support all
|
|||
|
|
/// <see cref="ISubscriber"/> types (User and Organization).
|
|||
|
|
/// </remarks>
|
|||
|
|
Task<BitwardenSubscription> Run(User user);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public class GetBitwardenSubscriptionQuery(
|
|||
|
|
ILogger<GetBitwardenSubscriptionQuery> logger,
|
|||
|
|
IPricingClient pricingClient,
|
|||
|
|
IStripeAdapter stripeAdapter) : IGetBitwardenSubscriptionQuery
|
|||
|
|
{
|
|||
|
|
public async Task<BitwardenSubscription> Run(User user)
|
|||
|
|
{
|
|||
|
|
var subscription = await stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, new SubscriptionGetOptions
|
|||
|
|
{
|
|||
|
|
Expand =
|
|||
|
|
[
|
|||
|
|
"customer.discount.coupon.applies_to",
|
|||
|
|
"discounts.coupon.applies_to",
|
|||
|
|
"items.data.price.product",
|
|||
|
|
"test_clock"
|
|||
|
|
]
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
var cart = await GetPremiumCartAsync(subscription);
|
|||
|
|
|
|||
|
|
var baseSubscription = new BitwardenSubscription { Status = subscription.Status, Cart = cart, Storage = user };
|
|||
|
|
|
|||
|
|
switch (subscription.Status)
|
|||
|
|
{
|
|||
|
|
case SubscriptionStatus.Incomplete:
|
|||
|
|
case SubscriptionStatus.IncompleteExpired:
|
|||
|
|
return baseSubscription with { Suspension = subscription.Created.AddHours(23), GracePeriod = 1 };
|
|||
|
|
|
|||
|
|
case SubscriptionStatus.Trialing:
|
|||
|
|
case SubscriptionStatus.Active:
|
|||
|
|
return baseSubscription with
|
|||
|
|
{
|
|||
|
|
NextCharge = subscription.GetCurrentPeriodEnd(),
|
|||
|
|
CancelAt = subscription.CancelAt
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
case SubscriptionStatus.PastDue:
|
|||
|
|
case SubscriptionStatus.Unpaid:
|
|||
|
|
var suspension = await GetSubscriptionSuspensionAsync(stripeAdapter, subscription);
|
|||
|
|
if (suspension == null)
|
|||
|
|
{
|
|||
|
|
return baseSubscription;
|
|||
|
|
}
|
|||
|
|
return baseSubscription with { Suspension = suspension.SuspensionDate, GracePeriod = suspension.GracePeriod };
|
|||
|
|
|
|||
|
|
case SubscriptionStatus.Canceled:
|
|||
|
|
return baseSubscription with { Canceled = subscription.CanceledAt };
|
|||
|
|
|
|||
|
|
default:
|
|||
|
|
{
|
|||
|
|
logger.LogError("Subscription ({SubscriptionID}) has an unmanaged status ({Status})", subscription.Id, subscription.Status);
|
|||
|
|
throw new ConflictException("Subscription is in an invalid state. Please contact support for assistance.");
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private async Task<Cart> GetPremiumCartAsync(
|
|||
|
|
Subscription subscription)
|
|||
|
|
{
|
|||
|
|
var plans = await pricingClient.ListPremiumPlans();
|
|||
|
|
|
|||
|
|
var passwordManagerSeatsItem = subscription.Items.FirstOrDefault(item =>
|
|||
|
|
plans.Any(plan => plan.Seat.StripePriceId == item.Price.Id));
|
|||
|
|
|
|||
|
|
if (passwordManagerSeatsItem == null)
|
|||
|
|
{
|
|||
|
|
throw new ConflictException("Premium subscription does not have a Password Manager line item.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var additionalStorageItem = subscription.Items.FirstOrDefault(item =>
|
|||
|
|
plans.Any(plan => plan.Storage.StripePriceId == item.Price.Id));
|
|||
|
|
|
|||
|
|
var (cartLevelDiscount, productLevelDiscounts) = GetStripeDiscounts(subscription);
|
|||
|
|
|
|||
|
|
var passwordManagerSeats = new CartItem
|
|||
|
|
{
|
|||
|
|
TranslationKey = "premiumMembership",
|
|||
|
|
Quantity = passwordManagerSeatsItem.Quantity,
|
|||
|
|
Cost = GetCost(passwordManagerSeatsItem),
|
|||
|
|
Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(passwordManagerSeatsItem))
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
var additionalStorage = additionalStorageItem != null
|
|||
|
|
? new CartItem
|
|||
|
|
{
|
|||
|
|
TranslationKey = "additionalStorageGB",
|
|||
|
|
Quantity = additionalStorageItem.Quantity,
|
|||
|
|
Cost = GetCost(additionalStorageItem),
|
|||
|
|
Discount = productLevelDiscounts.FirstOrDefault(discount => discount.AppliesTo(additionalStorageItem))
|
|||
|
|
}
|
|||
|
|
: null;
|
|||
|
|
|
|||
|
|
var estimatedTax = await EstimateTaxAsync(subscription);
|
|||
|
|
|
|||
|
|
return new Cart
|
|||
|
|
{
|
|||
|
|
PasswordManager = new PasswordManagerCartItems
|
|||
|
|
{
|
|||
|
|
Seats = passwordManagerSeats,
|
|||
|
|
AdditionalStorage = additionalStorage
|
|||
|
|
},
|
|||
|
|
Cadence = PlanCadenceType.Annually,
|
|||
|
|
Discount = cartLevelDiscount,
|
|||
|
|
EstimatedTax = estimatedTax
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#region Utilities
|
|||
|
|
|
|||
|
|
private async Task<decimal> EstimateTaxAsync(Subscription subscription)
|
|||
|
|
{
|
|||
|
|
try
|
|||
|
|
{
|
|||
|
|
var invoice = await stripeAdapter.CreateInvoicePreviewAsync(new InvoiceCreatePreviewOptions
|
|||
|
|
{
|
|||
|
|
Customer = subscription.Customer.Id,
|
|||
|
|
Subscription = subscription.Id
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return GetCost(invoice.TotalTaxes);
|
|||
|
|
}
|
|||
|
|
catch (StripeException stripeException) when
|
|||
|
|
(stripeException.StripeError.Code == ErrorCodes.InvoiceUpcomingNone)
|
|||
|
|
{
|
|||
|
|
return 0;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private static decimal GetCost(OneOf<SubscriptionItem, List<InvoiceTotalTax>> value) =>
|
|||
|
|
value.Match(
|
|||
|
|
item => (item.Price.UnitAmountDecimal ?? 0) / 100M,
|
|||
|
|
taxes => taxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount) / 100M);
|
|||
|
|
|
|||
|
|
private static (Discount? CartLevel, List<Discount> ProductLevel) GetStripeDiscounts(
|
|||
|
|
Subscription subscription)
|
|||
|
|
{
|
|||
|
|
var discounts = new List<Discount>();
|
|||
|
|
|
|||
|
|
if (subscription.Customer.Discount.IsValid())
|
|||
|
|
{
|
|||
|
|
discounts.Add(subscription.Customer.Discount);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
discounts.AddRange(subscription.Discounts.Where(discount => discount.IsValid()));
|
|||
|
|
|
|||
|
|
var cartLevel = new List<Discount>();
|
|||
|
|
var productLevel = new List<Discount>();
|
|||
|
|
|
|||
|
|
foreach (var discount in discounts)
|
|||
|
|
{
|
|||
|
|
switch (discount)
|
|||
|
|
{
|
|||
|
|
case { Coupon.AppliesTo.Products: null or { Count: 0 } }:
|
|||
|
|
cartLevel.Add(discount);
|
|||
|
|
break;
|
|||
|
|
case { Coupon.AppliesTo.Products.Count: > 0 }:
|
|||
|
|
productLevel.Add(discount);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (cartLevel.FirstOrDefault(), productLevel);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#endregion
|
|||
|
|
}
|