1
0
mirror of https://github.com/bitwarden/server synced 2026-01-15 06:53:26 +00:00

[PM-29604] [PM-29605] [PM-29606] Support premium subscription page redesign (#6821)

* feat(get-subscription): Add EnumMemberJsonConverter

* feat(get-subscription): Add BitwardenDiscount model

* feat(get-subscription): Add Cart model

* feat(get-subscription): Add Storage model

* feat(get-subscription): Add BitwardenSubscription model

* feat(get-subscription): Add DiscountExtensions

* feat(get-subscription): Add error code to StripeConstants

* feat(get-subscription): Add GetBitwardenSubscriptionQuery

* feat(get-subscription): Expose GET /account/billing/vnext/subscription

* feat(reinstate-subscription): Add ReinstateSubscriptionCommand

* feat(reinstate-subscription): Expose POST /account/billing/vnext/subscription/reinstate

* feat(pay-with-paypal-immediately): Add SubscriberId union

* feat(pay-with-paypal-immediately): Add BraintreeService with PayInvoice method

* feat(pay-with-paypal-immediately): Pay PayPal invoice immediately when starting premium subscription

* feat(pay-with-paypal-immediately): Pay invoice with Braintree on invoice.created for subscription cycles only

* fix(update-storage): Always invoice for premium storage update

* fix(update-storage): Move endpoint to subscription path

* docs: Note FF removal POIs

* (format): Run dotnet format
This commit is contained in:
Alex Morask
2026-01-12 10:45:41 -06:00
committed by GitHub
parent 94cd6fbff6
commit cfa8d4a165
27 changed files with 1676 additions and 67 deletions

View File

@@ -22,7 +22,7 @@ public class AccountsController(
IFeatureService featureService,
ILicensingService licensingService) : Controller
{
// TODO: Migrate to Query / AccountBillingVNextController as part of Premium -> Organization upgrade work.
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
[HttpGet("subscription")]
public async Task<SubscriptionResponseModel> GetSubscriptionAsync(
[FromServices] GlobalSettings globalSettings,
@@ -61,7 +61,7 @@ public class AccountsController(
}
}
// TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
[HttpPost("storage")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostStorageAsync([FromBody] StorageRequestModel model)
@@ -118,7 +118,7 @@ public class AccountsController(
user.IsExpired());
}
// TODO: Migrate to Command / AccountBillingVNextController as POST /account/billing/vnext/subscription/reinstate
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
[HttpPost("reinstate-premium")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostReinstateAsync()
@@ -131,10 +131,4 @@ public class AccountsController(
await userService.ReinstatePremiumAsync(user);
}
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
{
var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);
return organizationsClaimingUser.Select(o => o.Id);
}
}

View File

@@ -7,6 +7,8 @@ using Bit.Core.Billing.Licenses.Queries;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Subscriptions.Commands;
using Bit.Core.Billing.Subscriptions.Queries;
using Bit.Core.Entities;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
@@ -21,9 +23,11 @@ namespace Bit.Api.Billing.Controllers.VNext;
public class AccountBillingVNextController(
ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand,
ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand,
IGetBitwardenSubscriptionQuery getBitwardenSubscriptionQuery,
IGetCreditQuery getCreditQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IGetUserLicenseQuery getUserLicenseQuery,
IReinstateSubscriptionCommand reinstateSubscriptionCommand,
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
IUpdatePremiumStorageCommand updatePremiumStorageCommand,
IUpgradePremiumToOrganizationCommand upgradePremiumToOrganizationCommand) : BaseBillingController
@@ -91,10 +95,30 @@ public class AccountBillingVNextController(
return TypedResults.Ok(response);
}
[HttpPut("storage")]
[HttpGet("subscription")]
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
[InjectUser]
public async Task<IResult> UpdateStorageAsync(
public async Task<IResult> GetSubscriptionAsync(
[BindNever] User user)
{
var subscription = await getBitwardenSubscriptionQuery.Run(user);
return TypedResults.Ok(subscription);
}
[HttpPost("subscription/reinstate")]
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
[InjectUser]
public async Task<IResult> ReinstateSubscriptionAsync(
[BindNever] User user)
{
var result = await reinstateSubscriptionCommand.Run(user);
return Handle(result);
}
[HttpPut("subscription/storage")]
[RequireFeature(FeatureFlagKeys.PM29594_UpdateIndividualSubscriptionPage)]
[InjectUser]
public async Task<IResult> UpdateSubscriptionStorageAsync(
[BindNever] User user,
[FromBody] StorageUpdateRequest request)
{

View File

@@ -13,7 +13,6 @@ public class StorageUpdateRequest : IValidatableObject
/// Must be between 0 and the maximum allowed (minus base storage).
/// </summary>
[Required]
[Range(0, 99)]
public short AdditionalStorageGb { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
@@ -22,14 +21,14 @@ public class StorageUpdateRequest : IValidatableObject
{
yield return new ValidationResult(
"Additional storage cannot be negative.",
new[] { nameof(AdditionalStorageGb) });
[nameof(AdditionalStorageGb)]);
}
if (AdditionalStorageGb > 99)
{
yield return new ValidationResult(
"Maximum additional storage is 99 GB.",
new[] { nameof(AdditionalStorageGb) });
[nameof(AdditionalStorageGb)]);
}
}
}

View File

@@ -10,6 +10,7 @@ using Bit.Core.Utilities;
namespace Bit.Api.Models.Response;
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
public class SubscriptionResponseModel : ResponseModel
{

View File

@@ -1,12 +1,13 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Services;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
public class InvoiceCreatedHandler(
IBraintreeService braintreeService,
ILogger<InvoiceCreatedHandler> logger,
IStripeEventService stripeEventService,
IStripeEventUtilityService stripeEventUtilityService,
IProviderEventService providerEventService)
: IInvoiceCreatedHandler
{
@@ -29,9 +30,9 @@ public class InvoiceCreatedHandler(
{
try
{
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer"]);
var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["customer", "parent.subscription_details.subscription"]);
var usingPayPal = invoice.Customer?.Metadata.ContainsKey("btCustomerId") ?? false;
var usingPayPal = invoice.Customer.Metadata.ContainsKey("btCustomerId");
if (usingPayPal && invoice is
{
@@ -39,13 +40,12 @@ public class InvoiceCreatedHandler(
Status: not StripeConstants.InvoiceStatus.Paid,
CollectionMethod: "charge_automatically",
BillingReason:
"subscription_create" or
"subscription_cycle" or
"automatic_pending_invoice_item_invoice",
Parent.SubscriptionDetails: not null
Parent.SubscriptionDetails.Subscription: not null
})
{
await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice);
await braintreeService.PayInvoice(invoice.Parent.SubscriptionDetails.Subscription, invoice);
}
}
catch (Exception exception)

View File

@@ -42,6 +42,7 @@ public static class StripeConstants
public static class ErrorCodes
{
public const string CustomerTaxLocationInvalid = "customer_tax_location_invalid";
public const string InvoiceUpcomingNone = "invoice_upcoming_none";
public const string PaymentMethodMicroDepositVerificationAttemptsExceeded = "payment_method_microdeposit_verification_attempts_exceeded";
public const string PaymentMethodMicroDepositVerificationDescriptorCodeMismatch = "payment_method_microdeposit_verification_descriptor_code_mismatch";
public const string PaymentMethodMicroDepositVerificationTimeout = "payment_method_microdeposit_verification_timeout";
@@ -65,8 +66,10 @@ public static class StripeConstants
public static class MetadataKeys
{
public const string BraintreeCustomerId = "btCustomerId";
public const string BraintreeTransactionId = "btTransactionId";
public const string InvoiceApproved = "invoice_approved";
public const string OrganizationId = "organizationId";
public const string PayPalTransactionId = "btPayPalTransactionId";
public const string PreviousAdditionalStorage = "previous_additional_storage";
public const string PreviousPeriodEndDate = "previous_period_end_date";
public const string PreviousPremiumPriceId = "previous_premium_price_id";

View File

@@ -1,7 +1,11 @@
namespace Bit.Core.Billing.Enums;
using System.Runtime.Serialization;
namespace Bit.Core.Billing.Enums;
public enum PlanCadenceType
{
[EnumMember(Value = "annually")]
Annually,
[EnumMember(Value = "monthly")]
Monthly
}

View File

@@ -0,0 +1,12 @@
using Stripe;
namespace Bit.Core.Billing.Extensions;
public static class DiscountExtensions
{
public static bool AppliesTo(this Discount discount, SubscriptionItem subscriptionItem)
=> discount.Coupon.AppliesTo.Products.Contains(subscriptionItem.Price.Product.Id);
public static bool IsValid(this Discount? discount)
=> discount?.Coupon?.Valid ?? false;
}

View File

@@ -12,8 +12,11 @@ using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations;
using Bit.Core.Billing.Subscriptions.Commands;
using Bit.Core.Billing.Subscriptions.Queries;
using Bit.Core.Billing.Tax.Services;
using Bit.Core.Billing.Tax.Services.Implementations;
using Bit.Core.Services;
using Bit.Core.Services.Implementations;
namespace Bit.Core.Billing.Extensions;
@@ -39,6 +42,9 @@ public static class ServiceCollectionExtensions
services.AddTransient<IGetOrganizationWarningsQuery, GetOrganizationWarningsQuery>();
services.AddTransient<IRestartSubscriptionCommand, RestartSubscriptionCommand>();
services.AddTransient<IPreviewOrganizationTaxCommand, PreviewOrganizationTaxCommand>();
services.AddTransient<IGetBitwardenSubscriptionQuery, GetBitwardenSubscriptionQuery>();
services.AddTransient<IReinstateSubscriptionCommand, ReinstateSubscriptionCommand>();
services.AddTransient<IBraintreeService, BraintreeService>();
}
private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services)

View File

@@ -7,6 +7,7 @@ using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Models;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Platform.Push;
@@ -49,6 +50,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand
public class CreatePremiumCloudHostedSubscriptionCommand(
IBraintreeGateway braintreeGateway,
IBraintreeService braintreeService,
IGlobalSettings globalSettings,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
@@ -300,6 +302,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
ValidateLocation = ValidateTaxLocationTiming.Immediately
}
};
return await stripeAdapter.UpdateCustomerAsync(customer.Id, options);
}
@@ -351,14 +354,18 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions);
if (usingPayPal)
if (!usingPayPal)
{
await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
{
AutoAdvance = false
});
return subscription;
}
var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
{
AutoAdvance = false
});
await braintreeService.PayInvoice(new UserId(userId), invoice);
return subscription;
}
}

View File

@@ -1,4 +1,5 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
@@ -10,6 +11,8 @@ using Stripe;
namespace Bit.Core.Billing.Premium.Commands;
using static StripeConstants;
/// <summary>
/// Updates the storage allocation for a premium user's subscription.
/// Handles both increases and decreases in storage in an idempotent manner.
@@ -34,14 +37,14 @@ public class UpdatePremiumStorageCommand(
{
public Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb) => HandleAsync<None>(async () =>
{
if (!user.Premium)
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
{
return new BadRequest("User does not have a premium subscription.");
}
if (!user.MaxStorageGb.HasValue)
{
return new BadRequest("No access to storage.");
return new BadRequest("User has no access to storage.");
}
// Fetch all premium plans and the user's subscription to find which plan they're on
@@ -54,7 +57,7 @@ public class UpdatePremiumStorageCommand(
if (passwordManagerItem == null)
{
return new BadRequest("Premium subscription item not found.");
return new Conflict("Premium subscription does not have a Password Manager line item.");
}
var premiumPlan = premiumPlans.First(p => p.Seat.StripePriceId == passwordManagerItem.Price.Id);
@@ -66,20 +69,20 @@ public class UpdatePremiumStorageCommand(
return new BadRequest("Additional storage cannot be negative.");
}
var newTotalStorageGb = (short)(baseStorageGb + additionalStorageGb);
var maxStorageGb = (short)(baseStorageGb + additionalStorageGb);
if (newTotalStorageGb > 100)
if (maxStorageGb > 100)
{
return new BadRequest("Maximum storage is 100 GB.");
}
// Idempotency check: if user already has the requested storage, return success
if (user.MaxStorageGb == newTotalStorageGb)
if (user.MaxStorageGb == maxStorageGb)
{
return new None();
}
var remainingStorage = user.StorageBytesRemaining(newTotalStorageGb);
var remainingStorage = user.StorageBytesRemaining(maxStorageGb);
if (remainingStorage < 0)
{
return new BadRequest(
@@ -124,21 +127,18 @@ public class UpdatePremiumStorageCommand(
});
}
// Update subscription with prorations
// Storage is billed annually, so we create prorations and invoice immediately
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
{
Items = subscriptionItemOptions,
ProrationBehavior = Core.Constants.CreateProrations
ProrationBehavior = ProrationBehavior.AlwaysInvoice
};
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, subscriptionUpdateOptions);
// Update the user's max storage
user.MaxStorageGb = newTotalStorageGb;
user.MaxStorageGb = maxStorageGb;
await userService.SaveUserAsync(user);
// No payment intent needed - the subscription update will automatically create and finalize the invoice
return new None();
});
}

View File

@@ -0,0 +1,42 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Microsoft.Extensions.Logging;
using OneOf.Types;
using Stripe;
namespace Bit.Core.Billing.Subscriptions.Commands;
using static StripeConstants;
public interface IReinstateSubscriptionCommand
{
Task<BillingCommandResult<None>> Run(ISubscriber subscriber);
}
public class ReinstateSubscriptionCommand(
ILogger<ReinstateSubscriptionCommand> logger,
IStripeAdapter stripeAdapter) : BaseBillingCommand<ReinstateSubscriptionCommand>(logger), IReinstateSubscriptionCommand
{
public Task<BillingCommandResult<None>> Run(ISubscriber subscriber) => HandleAsync<None>(async () =>
{
var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId);
if (subscription is not
{
Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active,
CancelAt: not null
})
{
return new BadRequest("Subscription is not pending cancellation.");
}
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = false
});
return new None();
});
}

View File

@@ -0,0 +1,61 @@
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Bit.Core.Utilities;
using Stripe;
namespace Bit.Core.Billing.Subscriptions.Models;
/// <summary>
/// The type of discounts Bitwarden supports.
/// </summary>
public enum BitwardenDiscountType
{
[EnumMember(Value = "amount-off")]
AmountOff,
[EnumMember(Value = "percent-off")]
PercentOff
}
/// <summary>
/// A record representing a discount applied to a Bitwarden subscription.
/// </summary>
public record BitwardenDiscount
{
/// <summary>
/// The type of the discount.
/// </summary>
[JsonConverter(typeof(EnumMemberJsonConverter<BitwardenDiscountType>))]
public required BitwardenDiscountType Type { get; init; }
/// <summary>
/// The value of the discount.
/// </summary>
public required decimal Value { get; init; }
public static implicit operator BitwardenDiscount(Discount? discount)
{
if (discount is not
{
Coupon.Valid: true
})
{
return null!;
}
return discount.Coupon switch
{
{ AmountOff: > 0 } => new BitwardenDiscount
{
Type = BitwardenDiscountType.AmountOff,
Value = discount.Coupon.AmountOff.Value
},
{ PercentOff: > 0 } => new BitwardenDiscount
{
Type = BitwardenDiscountType.PercentOff,
Value = discount.Coupon.PercentOff.Value
},
_ => null!
};
}
}

View File

@@ -0,0 +1,52 @@
namespace Bit.Core.Billing.Subscriptions.Models;
public record BitwardenSubscription
{
/// <summary>
/// The status of the subscription.
/// </summary>
public required string Status { get; init; }
/// <summary>
/// The subscription's cart, including line items, any discounts, and estimated tax.
/// </summary>
public required Cart Cart { get; init; }
/// <summary>
/// The amount of storage available and used for the subscription.
/// <remarks>Allowed Subscribers: User, Organization</remarks>
/// </summary>
public Storage? Storage { get; init; }
/// <summary>
/// If the subscription is pending cancellation, the date at which the
/// subscription will be canceled.
/// <remarks>Allowed Statuses: 'trialing', 'active'</remarks>
/// </summary>
public DateTime? CancelAt { get; init; }
/// <summary>
/// The date the subscription was canceled.
/// <remarks>Allowed Statuses: 'canceled'</remarks>
/// </summary>
public DateTime? Canceled { get; init; }
/// <summary>
/// The date of the next charge for the subscription.
/// <remarks>Allowed Statuses: 'trialing', 'active'</remarks>
/// </summary>
public DateTime? NextCharge { get; init; }
/// <summary>
/// The date the subscription will be or was suspended due to lack of payment.
/// <remarks>Allowed Statuses: 'incomplete', 'incomplete_expired', 'past_due', 'unpaid'</remarks>
/// </summary>
public DateTime? Suspension { get; init; }
/// <summary>
/// The number of days after the subscription goes 'past_due' the subscriber has to resolve their
/// open invoices before the subscription is suspended.
/// <remarks>Allowed Statuses: 'past_due'</remarks>
/// </summary>
public int? GracePeriod { get; init; }
}

View File

@@ -0,0 +1,83 @@
using System.Text.Json.Serialization;
using Bit.Core.Billing.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.Billing.Subscriptions.Models;
public record CartItem
{
/// <summary>
/// The client-side translation key for the name of the cart item.
/// </summary>
public required string TranslationKey { get; init; }
/// <summary>
/// The quantity of the cart item.
/// </summary>
public required long Quantity { get; init; }
/// <summary>
/// The unit-cost of the cart item.
/// </summary>
public required decimal Cost { get; init; }
/// <summary>
/// An optional discount applied specifically to this cart item.
/// </summary>
public BitwardenDiscount? Discount { get; init; }
}
public record PasswordManagerCartItems
{
/// <summary>
/// The Password Manager seats in the cart.
/// </summary>
public required CartItem Seats { get; init; }
/// <summary>
/// The additional storage in the cart.
/// </summary>
public CartItem? AdditionalStorage { get; init; }
}
public record SecretsManagerCartItems
{
/// <summary>
/// The Secrets Manager seats in the cart.
/// </summary>
public required CartItem Seats { get; init; }
/// <summary>
/// The additional service accounts in the cart.
/// </summary>
public CartItem? AdditionalServiceAccounts { get; init; }
}
public record Cart
{
/// <summary>
/// The Password Manager items in the cart.
/// </summary>
public required PasswordManagerCartItems PasswordManager { get; init; }
/// <summary>
/// The Secrets Manager items in the cart.
/// </summary>
public SecretsManagerCartItems? SecretsManager { get; init; }
/// <summary>
/// The cart's billing cadence.
/// </summary>
[JsonConverter(typeof(EnumMemberJsonConverter<PlanCadenceType>))]
public PlanCadenceType Cadence { get; init; }
/// <summary>
/// An optional discount applied to the entire cart.
/// </summary>
public BitwardenDiscount? Discount { get; init; }
/// <summary>
/// The estimated tax for the cart.
/// </summary>
public required decimal EstimatedTax { get; init; }
}

View File

@@ -0,0 +1,52 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Utilities;
using OneOf;
namespace Bit.Core.Billing.Subscriptions.Models;
public record Storage
{
private const double _bytesPerGibibyte = 1073741824D;
/// <summary>
/// The amount of storage the subscriber has available.
/// </summary>
public required short Available { get; init; }
/// <summary>
/// The amount of storage the subscriber has used.
/// </summary>
public required double Used { get; init; }
/// <summary>
/// The amount of storage the subscriber has used, formatted as a human-readable string.
/// </summary>
public required string ReadableUsed { get; init; }
public static implicit operator Storage(User user) => From(user);
public static implicit operator Storage(Organization organization) => From(organization);
private static Storage From(OneOf<User, Organization> subscriber)
{
var maxStorageGB = subscriber.Match(
user => user.MaxStorageGb,
organization => organization.MaxStorageGb);
if (maxStorageGB == null)
{
return null!;
}
var storage = subscriber.Match(
user => user.Storage,
organization => organization.Storage);
return new Storage
{
Available = maxStorageGB.Value,
Used = Math.Round((storage ?? 0) / _bytesPerGibibyte, 2),
ReadableUsed = CoreHelpers.ReadableBytesSize(storage ?? 0)
};
}
}

View File

@@ -0,0 +1,43 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Exceptions;
using OneOf;
using Stripe;
namespace Bit.Core.Billing.Subscriptions.Models;
using static StripeConstants;
public record UserId(Guid Value);
public record OrganizationId(Guid Value);
public record ProviderId(Guid Value);
public class SubscriberId : OneOfBase<UserId, OrganizationId, ProviderId>
{
private SubscriberId(OneOf<UserId, OrganizationId, ProviderId> input) : base(input) { }
public static implicit operator SubscriberId(UserId value) => new(value);
public static implicit operator SubscriberId(OrganizationId value) => new(value);
public static implicit operator SubscriberId(ProviderId value) => new(value);
public static implicit operator SubscriberId(Subscription subscription)
{
if (subscription.Metadata.TryGetValue(MetadataKeys.UserId, out var userIdValue)
&& Guid.TryParse(userIdValue, out var userId))
{
return new UserId(userId);
}
if (subscription.Metadata.TryGetValue(MetadataKeys.OrganizationId, out var organizationIdValue)
&& Guid.TryParse(organizationIdValue, out var organizationId))
{
return new OrganizationId(organizationId);
}
return subscription.Metadata.TryGetValue(MetadataKeys.ProviderId, out var providerIdValue) &&
Guid.TryParse(providerIdValue, out var providerId)
? new ProviderId(providerId)
: throw new ConflictException("Subscription does not have a valid subscriber ID");
}
}

View File

@@ -0,0 +1,201 @@
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
}

View File

@@ -0,0 +1,11 @@
using Bit.Core.Billing.Subscriptions.Models;
using Stripe;
namespace Bit.Core.Services;
public interface IBraintreeService
{
Task PayInvoice(
SubscriberId subscriberId,
Invoice invoice);
}

View File

@@ -0,0 +1,107 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Models;
using Bit.Core.Exceptions;
using Bit.Core.Settings;
using Braintree;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Core.Services.Implementations;
using static StripeConstants;
public class BraintreeService(
IBraintreeGateway braintreeGateway,
IGlobalSettings globalSettings,
ILogger<BraintreeService> logger,
IMailService mailService,
IStripeAdapter stripeAdapter) : IBraintreeService
{
private readonly ConflictException _problemPayingInvoice = new("There was a problem paying for your invoice. Please contact customer support.");
public async Task PayInvoice(
SubscriberId subscriberId,
Invoice invoice)
{
if (invoice.Customer == null)
{
logger.LogError("Invoice's ({InvoiceID}) `customer` property must be expanded to be paid with Braintree",
invoice.Id);
throw _problemPayingInvoice;
}
if (!invoice.Customer.Metadata.TryGetValue(MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId))
{
logger.LogError(
"Cannot pay invoice ({InvoiceID}) with Braintree for Customer ({CustomerID}) that does not have a Braintree Customer ID",
invoice.Id, invoice.Customer.Id);
throw _problemPayingInvoice;
}
if (invoice is not
{
AmountDue: > 0,
Status: not InvoiceStatus.Paid,
CollectionMethod: CollectionMethod.ChargeAutomatically
})
{
logger.LogWarning("Attempted to pay invoice ({InvoiceID}) with Braintree that is not eligible for payment", invoice.Id);
return;
}
var amount = Math.Round(invoice.AmountDue / 100M, 2);
var idKey = subscriberId.Match(
_ => "user_id",
_ => "organization_id",
_ => "provider_id");
var idValue = subscriberId.Match(
userId => userId.Value,
organizationId => organizationId.Value,
providerId => providerId.Value);
var request = new TransactionRequest
{
Amount = amount,
CustomerId = braintreeCustomerId,
Options = new TransactionOptionsRequest
{
SubmitForSettlement = true,
PayPal = new TransactionOptionsPayPalRequest
{
CustomField = $"{idKey}:{idValue},region:{globalSettings.BaseServiceUri.CloudRegion}"
}
},
CustomFields = new Dictionary<string, string>
{
[idKey] = idValue.ToString(),
["region"] = globalSettings.BaseServiceUri.CloudRegion
}
};
var result = await braintreeGateway.Transaction.SaleAsync(request);
if (!result.IsSuccess())
{
if (invoice.AttemptCount < 4)
{
await mailService.SendPaymentFailedAsync(invoice.Customer.Email, amount, true);
}
return;
}
await stripeAdapter.UpdateInvoiceAsync(invoice.Id, new InvoiceUpdateOptions
{
Metadata = new Dictionary<string, string>
{
[MetadataKeys.BraintreeTransactionId] = result.Target.Id,
[MetadataKeys.PayPalTransactionId] = result.Target.PayPalDetails.AuthorizationId
}
});
await stripeAdapter.PayInvoiceAsync(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true });
}
}

View File

@@ -995,6 +995,7 @@ public class UserService : UserManager<User>, IUserService
await SaveUserAsync(user);
}
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
public async Task<string> AdjustStorageAsync(User user, short storageAdjustmentGb)
{
if (user == null)
@@ -1040,6 +1041,7 @@ public class UserService : UserManager<User>, IUserService
await _paymentService.CancelSubscriptionAsync(user, eop);
}
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
public async Task ReinstatePremiumAsync(User user)
{
await _paymentService.ReinstateSubscriptionAsync(user);

View File

@@ -0,0 +1,52 @@
using System.Reflection;
using System.Runtime.Serialization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Bit.Core.Utilities;
/// <summary>
/// A custom JSON converter for enum types that respects the <see cref="EnumMemberAttribute"/> when serializing and deserializing.
/// </summary>
/// <typeparam name="T">The enum type to convert. Must be a struct and implement Enum.</typeparam>
/// <remarks>
/// This converter builds lookup dictionaries at initialization to efficiently map between enum values and their
/// string representations. If an enum value has an <see cref="EnumMemberAttribute"/>, the attribute's Value
/// property is used as the JSON string; otherwise, the enum's ToString() value is used.
/// </remarks>
public class EnumMemberJsonConverter<T> : JsonConverter<T> where T : struct, Enum
{
private readonly Dictionary<T, string> _enumToString = new();
private readonly Dictionary<string, T> _stringToEnum = new();
public EnumMemberJsonConverter()
{
var type = typeof(T);
var values = Enum.GetValues<T>();
foreach (var value in values)
{
var fieldInfo = type.GetField(value.ToString());
var attribute = fieldInfo?.GetCustomAttribute<EnumMemberAttribute>();
var stringValue = attribute?.Value ?? value.ToString();
_enumToString[value] = stringValue;
_stringToEnum[stringValue] = value;
}
}
public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var stringValue = reader.GetString();
if (!string.IsNullOrEmpty(stringValue) && _stringToEnum.TryGetValue(stringValue, out var enumValue))
{
return enumValue;
}
throw new JsonException($"Unable to convert '{stringValue}' to {typeof(T).Name}");
}
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
=> writer.WriteStringValue(_enumToString[value]);
}

View File

@@ -3,6 +3,8 @@ using Bit.Api.Billing.Models.Requests.Storage;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Licenses.Queries;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Subscriptions.Commands;
using Bit.Core.Billing.Subscriptions.Queries;
using Bit.Core.Entities;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Http;
@@ -29,9 +31,11 @@ public class AccountBillingVNextControllerTests
_sut = new AccountBillingVNextController(
Substitute.For<Core.Billing.Payment.Commands.ICreateBitPayInvoiceForCreditCommand>(),
Substitute.For<Core.Billing.Premium.Commands.ICreatePremiumCloudHostedSubscriptionCommand>(),
Substitute.For<IGetBitwardenSubscriptionQuery>(),
Substitute.For<Core.Billing.Payment.Queries.IGetCreditQuery>(),
Substitute.For<Core.Billing.Payment.Queries.IGetPaymentMethodQuery>(),
_getUserLicenseQuery,
Substitute.For<IReinstateSubscriptionCommand>(),
Substitute.For<Core.Billing.Payment.Commands.IUpdatePaymentMethodCommand>(),
_updatePremiumStorageCommand,
_upgradePremiumToOrganizationCommand);
@@ -63,7 +67,7 @@ public class AccountBillingVNextControllerTests
.Returns(new BillingCommandResult<None>(new None()));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
@@ -83,7 +87,7 @@ public class AccountBillingVNextControllerTests
.Returns(new BadRequest(errorMessage));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
// Assert
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
@@ -103,7 +107,7 @@ public class AccountBillingVNextControllerTests
.Returns(new BadRequest(errorMessage));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
// Assert
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
@@ -123,7 +127,7 @@ public class AccountBillingVNextControllerTests
.Returns(new BadRequest(errorMessage));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
// Assert
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
@@ -143,7 +147,7 @@ public class AccountBillingVNextControllerTests
.Returns(new BadRequest(errorMessage));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
// Assert
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
@@ -163,7 +167,7 @@ public class AccountBillingVNextControllerTests
.Returns(new BadRequest(errorMessage));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
// Assert
var badRequestResult = Assert.IsAssignableFrom<IResult>(result);
@@ -182,7 +186,7 @@ public class AccountBillingVNextControllerTests
.Returns(new BillingCommandResult<None>(new None()));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
@@ -201,7 +205,7 @@ public class AccountBillingVNextControllerTests
.Returns(new BillingCommandResult<None>(new None()));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
@@ -220,7 +224,7 @@ public class AccountBillingVNextControllerTests
.Returns(new BillingCommandResult<None>(new None()));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);
@@ -239,7 +243,7 @@ public class AccountBillingVNextControllerTests
.Returns(new BillingCommandResult<None>(new None()));
// Act
var result = await _sut.UpdateStorageAsync(user, request);
var result = await _sut.UpdateSubscriptionStorageAsync(user, request);
// Assert
var okResult = Assert.IsAssignableFrom<IResult>(result);

View File

@@ -8,6 +8,7 @@ using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Premium.Commands;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Models;
using Bit.Core.Entities;
using Bit.Core.Platform.Push;
using Bit.Core.Services;
@@ -29,6 +30,7 @@ namespace Bit.Core.Test.Billing.Premium.Commands;
public class CreatePremiumCloudHostedSubscriptionCommandTests
{
private readonly IBraintreeGateway _braintreeGateway = Substitute.For<IBraintreeGateway>();
private readonly IBraintreeService _braintreeService = Substitute.For<IBraintreeService>();
private readonly IGlobalSettings _globalSettings = Substitute.For<IGlobalSettings>();
private readonly ISetupIntentCache _setupIntentCache = Substitute.For<ISetupIntentCache>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
@@ -59,6 +61,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
_command = new CreatePremiumCloudHostedSubscriptionCommand(
_braintreeGateway,
_braintreeService,
_globalSettings,
_setupIntentCache,
_stripeAdapter,
@@ -235,11 +238,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "cust_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
mockCustomer.Metadata = new Dictionary<string, string>
{
[Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123"
};
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active";
mockSubscription.LatestInvoiceId = "in_123";
var mockInvoice = Substitute.For<Invoice>();
@@ -258,6 +265,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
await _stripeAdapter.Received(1).CreateCustomerAsync(Arg.Any<CustomerCreateOptions>());
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
await _subscriberService.Received(1).CreateBraintreeCustomer(user, paymentMethod.Token);
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
Arg.Is<InvoiceUpdateOptions>(opts => opts.AutoAdvance == false));
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
await _userService.Received(1).SaveUserAsync(user);
await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id);
}
@@ -456,11 +466,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "cust_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
mockCustomer.Metadata = new Dictionary<string, string>
{
[Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123"
};
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "incomplete";
mockSubscription.LatestInvoiceId = "in_123";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
@@ -487,6 +501,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
Assert.True(result.IsT0);
Assert.True(user.Premium);
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
Arg.Is<InvoiceUpdateOptions>(opts => opts.AutoAdvance == false));
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
}
[Theory, BitAutoData]
@@ -559,11 +576,15 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
var mockCustomer = Substitute.For<StripeCustomer>();
mockCustomer.Id = "cust_123";
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
mockCustomer.Metadata = new Dictionary<string, string>();
mockCustomer.Metadata = new Dictionary<string, string>
{
[Core.Billing.Utilities.BraintreeCustomerIdKey] = "bt_customer_123"
};
var mockSubscription = Substitute.For<StripeSubscription>();
mockSubscription.Id = "sub_123";
mockSubscription.Status = "active"; // PayPal + active doesn't match pattern
mockSubscription.LatestInvoiceId = "in_123";
mockSubscription.Items = new StripeList<SubscriptionItem>
{
Data =
@@ -590,6 +611,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
Assert.True(result.IsT0);
Assert.False(user.Premium);
Assert.Null(user.PremiumExpirationDate);
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
Arg.Is<InvoiceUpdateOptions>(opts => opts.AutoAdvance == false));
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
}
[Theory, BitAutoData]

View File

@@ -18,13 +18,11 @@ public class UpdatePremiumStorageCommandTests
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly IUserService _userService = Substitute.For<IUserService>();
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
private readonly PremiumPlan _premiumPlan;
private readonly UpdatePremiumStorageCommand _command;
public UpdatePremiumStorageCommandTests()
{
// Setup default premium plan with standard pricing
_premiumPlan = new PremiumPlan
var premiumPlan = new PremiumPlan
{
Name = "Premium",
Available = true,
@@ -32,7 +30,7 @@ public class UpdatePremiumStorageCommandTests
Seat = new PremiumPurchasable { Price = 10M, StripePriceId = "price_premium", Provided = 1 },
Storage = new PremiumPurchasable { Price = 4M, StripePriceId = "price_storage", Provided = 1 }
};
_pricingClient.ListPremiumPlans().Returns(new List<PremiumPlan> { _premiumPlan });
_pricingClient.ListPremiumPlans().Returns([premiumPlan]);
_command = new UpdatePremiumStorageCommand(
_stripeAdapter,
@@ -43,18 +41,19 @@ public class UpdatePremiumStorageCommandTests
private Subscription CreateMockSubscription(string subscriptionId, int? storageQuantity = null)
{
var items = new List<SubscriptionItem>();
// Always add the seat item
items.Add(new SubscriptionItem
var items = new List<SubscriptionItem>
{
Id = "si_seat",
Price = new Price { Id = "price_premium" },
Quantity = 1
});
// Always add the seat item
new()
{
Id = "si_seat",
Price = new Price { Id = "price_premium" },
Quantity = 1
}
};
// Add storage item if quantity is provided
if (storageQuantity.HasValue && storageQuantity.Value > 0)
if (storageQuantity is > 0)
{
items.Add(new SubscriptionItem
{
@@ -142,7 +141,7 @@ public class UpdatePremiumStorageCommandTests
// Assert
Assert.True(result.IsT1);
var badRequest = result.AsT1;
Assert.Equal("No access to storage.", badRequest.Response);
Assert.Equal("User has no access to storage.", badRequest.Response);
}
[Theory, BitAutoData]
@@ -216,7 +215,7 @@ public class UpdatePremiumStorageCommandTests
opts.Items.Count == 1 &&
opts.Items[0].Id == "si_storage" &&
opts.Items[0].Quantity == 9 &&
opts.ProrationBehavior == "create_prorations"));
opts.ProrationBehavior == "always_invoice"));
// Verify user was saved
await _userService.Received(1).SaveUserAsync(Arg.Is<User>(u =>
@@ -233,7 +232,7 @@ public class UpdatePremiumStorageCommandTests
user.Storage = 500L * 1024 * 1024;
user.GatewaySubscriptionId = "sub_123";
var subscription = CreateMockSubscription("sub_123", null);
var subscription = CreateMockSubscription("sub_123");
_stripeAdapter.GetSubscriptionAsync("sub_123").Returns(subscription);
// Act

View File

@@ -0,0 +1,607 @@
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Models;
using Bit.Core.Billing.Subscriptions.Queries;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Subscriptions.Queries;
using static StripeConstants;
public class GetBitwardenSubscriptionQueryTests
{
private readonly ILogger<GetBitwardenSubscriptionQuery> _logger = Substitute.For<ILogger<GetBitwardenSubscriptionQuery>>();
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
private readonly IStripeAdapter _stripeAdapter = Substitute.For<IStripeAdapter>();
private readonly GetBitwardenSubscriptionQuery _query;
public GetBitwardenSubscriptionQueryTests()
{
_query = new GetBitwardenSubscriptionQuery(
_logger,
_pricingClient,
_stripeAdapter);
}
[Fact]
public async Task Run_IncompleteStatus_ReturnsBitwardenSubscriptionWithSuspension()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Incomplete);
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Equal(SubscriptionStatus.Incomplete, result.Status);
Assert.NotNull(result.Suspension);
Assert.Equal(subscription.Created.AddHours(23), result.Suspension);
Assert.Equal(1, result.GracePeriod);
Assert.Null(result.NextCharge);
Assert.Null(result.CancelAt);
}
[Fact]
public async Task Run_IncompleteExpiredStatus_ReturnsBitwardenSubscriptionWithSuspension()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.IncompleteExpired);
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Equal(SubscriptionStatus.IncompleteExpired, result.Status);
Assert.NotNull(result.Suspension);
Assert.Equal(subscription.Created.AddHours(23), result.Suspension);
Assert.Equal(1, result.GracePeriod);
}
[Fact]
public async Task Run_TrialingStatus_ReturnsBitwardenSubscriptionWithNextCharge()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Trialing);
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Equal(SubscriptionStatus.Trialing, result.Status);
Assert.NotNull(result.NextCharge);
Assert.Equal(subscription.Items.First().CurrentPeriodEnd, result.NextCharge);
Assert.Null(result.Suspension);
Assert.Null(result.GracePeriod);
}
[Fact]
public async Task Run_ActiveStatus_ReturnsBitwardenSubscriptionWithNextCharge()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active);
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Equal(SubscriptionStatus.Active, result.Status);
Assert.NotNull(result.NextCharge);
Assert.Equal(subscription.Items.First().CurrentPeriodEnd, result.NextCharge);
Assert.Null(result.Suspension);
Assert.Null(result.GracePeriod);
}
[Fact]
public async Task Run_ActiveStatusWithCancelAt_ReturnsCancelAt()
{
var user = CreateUser();
var cancelAt = DateTime.UtcNow.AddMonths(1);
var subscription = CreateSubscription(SubscriptionStatus.Active, cancelAt: cancelAt);
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Equal(SubscriptionStatus.Active, result.Status);
Assert.Equal(cancelAt, result.CancelAt);
}
[Fact]
public async Task Run_PastDueStatus_WithOpenInvoices_ReturnsSuspension()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.PastDue, collectionMethod: "charge_automatically");
var premiumPlans = CreatePremiumPlans();
var openInvoice = CreateInvoice();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
_stripeAdapter.SearchInvoiceAsync(Arg.Any<InvoiceSearchOptions>())
.Returns([openInvoice]);
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Equal(SubscriptionStatus.PastDue, result.Status);
Assert.NotNull(result.Suspension);
Assert.Equal(openInvoice.Created.AddDays(14), result.Suspension);
Assert.Equal(14, result.GracePeriod);
}
[Fact]
public async Task Run_PastDueStatus_WithoutOpenInvoices_ReturnsNoSuspension()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.PastDue);
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
_stripeAdapter.SearchInvoiceAsync(Arg.Any<InvoiceSearchOptions>())
.Returns([]);
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Equal(SubscriptionStatus.PastDue, result.Status);
Assert.Null(result.Suspension);
Assert.Null(result.GracePeriod);
}
[Fact]
public async Task Run_UnpaidStatus_WithOpenInvoices_ReturnsSuspension()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Unpaid, collectionMethod: "charge_automatically");
var premiumPlans = CreatePremiumPlans();
var openInvoice = CreateInvoice();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
_stripeAdapter.SearchInvoiceAsync(Arg.Any<InvoiceSearchOptions>())
.Returns([openInvoice]);
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Equal(SubscriptionStatus.Unpaid, result.Status);
Assert.NotNull(result.Suspension);
Assert.Equal(14, result.GracePeriod);
}
[Fact]
public async Task Run_CanceledStatus_ReturnsCanceledDate()
{
var user = CreateUser();
var canceledAt = DateTime.UtcNow.AddDays(-5);
var subscription = CreateSubscription(SubscriptionStatus.Canceled, canceledAt: canceledAt);
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Equal(SubscriptionStatus.Canceled, result.Status);
Assert.Equal(canceledAt, result.Canceled);
Assert.Null(result.Suspension);
Assert.Null(result.NextCharge);
}
[Fact]
public async Task Run_UnmanagedStatus_ThrowsConflictException()
{
var user = CreateUser();
var subscription = CreateSubscription("unmanaged_status");
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
await Assert.ThrowsAsync<ConflictException>(() => _query.Run(user));
}
[Fact]
public async Task Run_WithAdditionalStorage_IncludesStorageInCart()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: true);
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.NotNull(result.Cart.PasswordManager.AdditionalStorage);
Assert.Equal("additionalStorageGB", result.Cart.PasswordManager.AdditionalStorage.TranslationKey);
Assert.Equal(2, result.Cart.PasswordManager.AdditionalStorage.Quantity);
Assert.NotNull(result.Storage);
}
[Fact]
public async Task Run_WithoutAdditionalStorage_ExcludesStorageFromCart()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: false);
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Null(result.Cart.PasswordManager.AdditionalStorage);
Assert.NotNull(result.Storage);
}
[Fact]
public async Task Run_WithCartLevelDiscount_IncludesDiscountInCart()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active);
subscription.Customer.Discount = CreateDiscount(discountType: "cart");
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.NotNull(result.Cart.Discount);
Assert.Equal(BitwardenDiscountType.PercentOff, result.Cart.Discount.Type);
Assert.Equal(20, result.Cart.Discount.Value);
}
[Fact]
public async Task Run_WithProductLevelDiscount_IncludesDiscountInCartItem()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active);
var productDiscount = CreateDiscount(discountType: "product", productId: "prod_premium_seat");
subscription.Discounts = [productDiscount];
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.NotNull(result.Cart.PasswordManager.Seats.Discount);
Assert.Equal(BitwardenDiscountType.PercentOff, result.Cart.PasswordManager.Seats.Discount.Type);
}
[Fact]
public async Task Run_WithoutMaxStorageGb_ReturnsNullStorage()
{
var user = CreateUser();
user.MaxStorageGb = null;
var subscription = CreateSubscription(SubscriptionStatus.Active);
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Null(result.Storage);
}
[Fact]
public async Task Run_CalculatesStorageCorrectly()
{
var user = CreateUser();
user.Storage = 5368709120; // 5 GB in bytes
user.MaxStorageGb = 10;
var subscription = CreateSubscription(SubscriptionStatus.Active, includeStorage: true);
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.NotNull(result.Storage);
Assert.Equal(10, result.Storage.Available);
Assert.Equal(5.0, result.Storage.Used);
Assert.NotEmpty(result.Storage.ReadableUsed);
}
[Fact]
public async Task Run_TaxEstimation_WithInvoiceUpcomingNoneError_ReturnsZeroTax()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active);
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.ThrowsAsync(new StripeException { StripeError = new StripeError { Code = ErrorCodes.InvoiceUpcomingNone } });
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Equal(0, result.Cart.EstimatedTax);
}
[Fact]
public async Task Run_MissingPasswordManagerSeatsItem_ThrowsConflictException()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active);
subscription.Items = new StripeList<SubscriptionItem>
{
Data = []
};
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
await Assert.ThrowsAsync<ConflictException>(() => _query.Run(user));
}
[Fact]
public async Task Run_IncludesEstimatedTax()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active);
var premiumPlans = CreatePremiumPlans();
var invoice = CreateInvoicePreview(totalTax: 500); // $5.00 tax
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(invoice);
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Equal(5.0m, result.Cart.EstimatedTax);
}
[Fact]
public async Task Run_SetsCadenceToAnnually()
{
var user = CreateUser();
var subscription = CreateSubscription(SubscriptionStatus.Active);
var premiumPlans = CreatePremiumPlans();
_stripeAdapter.GetSubscriptionAsync(user.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
.Returns(subscription);
_pricingClient.ListPremiumPlans().Returns(premiumPlans);
_stripeAdapter.CreateInvoicePreviewAsync(Arg.Any<InvoiceCreatePreviewOptions>())
.Returns(CreateInvoicePreview());
var result = await _query.Run(user);
Assert.NotNull(result);
Assert.Equal(PlanCadenceType.Annually, result.Cart.Cadence);
}
#region Helper Methods
private static User CreateUser()
{
return new User
{
Id = Guid.NewGuid(),
GatewaySubscriptionId = "sub_test123",
MaxStorageGb = 1,
Storage = 1073741824 // 1 GB in bytes
};
}
private static Subscription CreateSubscription(
string status,
bool includeStorage = false,
DateTime? cancelAt = null,
DateTime? canceledAt = null,
string collectionMethod = "charge_automatically")
{
var currentPeriodEnd = DateTime.UtcNow.AddMonths(1);
var items = new List<SubscriptionItem>
{
new()
{
Id = "si_premium_seat",
Price = new Price
{
Id = "price_premium_seat",
UnitAmountDecimal = 1000,
Product = new Product { Id = "prod_premium_seat" }
},
Quantity = 1,
CurrentPeriodStart = DateTime.UtcNow,
CurrentPeriodEnd = currentPeriodEnd
}
};
if (includeStorage)
{
items.Add(new SubscriptionItem
{
Id = "si_storage",
Price = new Price
{
Id = "price_storage",
UnitAmountDecimal = 400,
Product = new Product { Id = "prod_storage" }
},
Quantity = 2,
CurrentPeriodStart = DateTime.UtcNow,
CurrentPeriodEnd = currentPeriodEnd
});
}
return new Subscription
{
Id = "sub_test123",
Status = status,
Created = DateTime.UtcNow.AddMonths(-1),
Customer = new Customer
{
Id = "cus_test123",
Discount = null
},
Items = new StripeList<SubscriptionItem>
{
Data = items
},
CancelAt = cancelAt,
CanceledAt = canceledAt,
CollectionMethod = collectionMethod,
Discounts = []
};
}
private static List<Bit.Core.Billing.Pricing.Premium.Plan> CreatePremiumPlans()
{
return
[
new()
{
Name = "Premium",
Available = true,
Seat = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "price_premium_seat",
Price = 10.0m,
Provided = 1
},
Storage = new Bit.Core.Billing.Pricing.Premium.Purchasable
{
StripePriceId = "price_storage",
Price = 4.0m,
Provided = 1
}
}
];
}
private static Invoice CreateInvoice()
{
return new Invoice
{
Id = "in_test123",
Created = DateTime.UtcNow.AddDays(-10),
PeriodEnd = DateTime.UtcNow.AddDays(-5),
Attempted = true,
Status = "open"
};
}
private static Invoice CreateInvoicePreview(long totalTax = 0)
{
var taxes = totalTax > 0
? new List<InvoiceTotalTax> { new() { Amount = totalTax } }
: new List<InvoiceTotalTax>();
return new Invoice
{
Id = "in_preview",
TotalTaxes = taxes
};
}
private static Discount CreateDiscount(string discountType = "cart", string? productId = null)
{
var coupon = new Coupon
{
Valid = true,
PercentOff = 20,
AppliesTo = discountType == "product" && productId != null
? new CouponAppliesTo { Products = [productId] }
: new CouponAppliesTo { Products = [] }
};
return new Discount
{
Coupon = coupon
};
}
#endregion
}

View File

@@ -0,0 +1,219 @@
using System.Runtime.Serialization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Bit.Core.Utilities;
using Xunit;
namespace Bit.Core.Test.Utilities;
public class EnumMemberJsonConverterTests
{
[Fact]
public void Serialize_WithEnumMemberAttribute_UsesAttributeValue()
{
// Arrange
var obj = new EnumConverterTestObject
{
Status = EnumConverterTestStatus.InProgress
};
const string expectedJsonString = "{\"Status\":\"in_progress\"}";
// Act
var jsonString = JsonSerializer.Serialize(obj);
// Assert
Assert.Equal(expectedJsonString, jsonString);
}
[Fact]
public void Serialize_WithoutEnumMemberAttribute_UsesEnumName()
{
// Arrange
var obj = new EnumConverterTestObject
{
Status = EnumConverterTestStatus.Pending
};
const string expectedJsonString = "{\"Status\":\"Pending\"}";
// Act
var jsonString = JsonSerializer.Serialize(obj);
// Assert
Assert.Equal(expectedJsonString, jsonString);
}
[Fact]
public void Serialize_MultipleValues_SerializesCorrectly()
{
// Arrange
var obj = new EnumConverterTestObjectWithMultiple
{
Status1 = EnumConverterTestStatus.Active,
Status2 = EnumConverterTestStatus.InProgress,
Status3 = EnumConverterTestStatus.Pending
};
const string expectedJsonString = "{\"Status1\":\"active\",\"Status2\":\"in_progress\",\"Status3\":\"Pending\"}";
// Act
var jsonString = JsonSerializer.Serialize(obj);
// Assert
Assert.Equal(expectedJsonString, jsonString);
}
[Fact]
public void Deserialize_WithEnumMemberAttribute_ReturnsCorrectEnumValue()
{
// Arrange
const string json = "{\"Status\":\"in_progress\"}";
// Act
var obj = JsonSerializer.Deserialize<EnumConverterTestObject>(json);
// Assert
Assert.Equal(EnumConverterTestStatus.InProgress, obj.Status);
}
[Fact]
public void Deserialize_WithoutEnumMemberAttribute_ReturnsCorrectEnumValue()
{
// Arrange
const string json = "{\"Status\":\"Pending\"}";
// Act
var obj = JsonSerializer.Deserialize<EnumConverterTestObject>(json);
// Assert
Assert.Equal(EnumConverterTestStatus.Pending, obj.Status);
}
[Fact]
public void Deserialize_MultipleValues_DeserializesCorrectly()
{
// Arrange
const string json = "{\"Status1\":\"active\",\"Status2\":\"in_progress\",\"Status3\":\"Pending\"}";
// Act
var obj = JsonSerializer.Deserialize<EnumConverterTestObjectWithMultiple>(json);
// Assert
Assert.Equal(EnumConverterTestStatus.Active, obj.Status1);
Assert.Equal(EnumConverterTestStatus.InProgress, obj.Status2);
Assert.Equal(EnumConverterTestStatus.Pending, obj.Status3);
}
[Fact]
public void Deserialize_InvalidEnumString_ThrowsJsonException()
{
// Arrange
const string json = "{\"Status\":\"invalid_value\"}";
// Act & Assert
var exception = Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<EnumConverterTestObject>(json));
Assert.Contains("Unable to convert 'invalid_value' to EnumConverterTestStatus", exception.Message);
}
[Fact]
public void Deserialize_EmptyString_ThrowsJsonException()
{
// Arrange
const string json = "{\"Status\":\"\"}";
// Act & Assert
var exception = Assert.Throws<JsonException>(() => JsonSerializer.Deserialize<EnumConverterTestObject>(json));
Assert.Contains("Unable to convert '' to EnumConverterTestStatus", exception.Message);
}
[Fact]
public void RoundTrip_WithEnumMemberAttribute_PreservesValue()
{
// Arrange
var originalObj = new EnumConverterTestObject
{
Status = EnumConverterTestStatus.Completed
};
// Act
var json = JsonSerializer.Serialize(originalObj);
var deserializedObj = JsonSerializer.Deserialize<EnumConverterTestObject>(json);
// Assert
Assert.Equal(originalObj.Status, deserializedObj.Status);
}
[Fact]
public void RoundTrip_WithoutEnumMemberAttribute_PreservesValue()
{
// Arrange
var originalObj = new EnumConverterTestObject
{
Status = EnumConverterTestStatus.Pending
};
// Act
var json = JsonSerializer.Serialize(originalObj);
var deserializedObj = JsonSerializer.Deserialize<EnumConverterTestObject>(json);
// Assert
Assert.Equal(originalObj.Status, deserializedObj.Status);
}
[Fact]
public void Serialize_AllEnumValues_ProducesExpectedStrings()
{
// Arrange & Act & Assert
Assert.Equal("\"Pending\"", JsonSerializer.Serialize(EnumConverterTestStatus.Pending, CreateOptions()));
Assert.Equal("\"active\"", JsonSerializer.Serialize(EnumConverterTestStatus.Active, CreateOptions()));
Assert.Equal("\"in_progress\"", JsonSerializer.Serialize(EnumConverterTestStatus.InProgress, CreateOptions()));
Assert.Equal("\"completed\"", JsonSerializer.Serialize(EnumConverterTestStatus.Completed, CreateOptions()));
}
[Fact]
public void Deserialize_AllEnumValues_ReturnsCorrectEnums()
{
// Arrange & Act & Assert
Assert.Equal(EnumConverterTestStatus.Pending, JsonSerializer.Deserialize<EnumConverterTestStatus>("\"Pending\"", CreateOptions()));
Assert.Equal(EnumConverterTestStatus.Active, JsonSerializer.Deserialize<EnumConverterTestStatus>("\"active\"", CreateOptions()));
Assert.Equal(EnumConverterTestStatus.InProgress, JsonSerializer.Deserialize<EnumConverterTestStatus>("\"in_progress\"", CreateOptions()));
Assert.Equal(EnumConverterTestStatus.Completed, JsonSerializer.Deserialize<EnumConverterTestStatus>("\"completed\"", CreateOptions()));
}
private static JsonSerializerOptions CreateOptions()
{
var options = new JsonSerializerOptions();
options.Converters.Add(new EnumMemberJsonConverter<EnumConverterTestStatus>());
return options;
}
}
public class EnumConverterTestObject
{
[JsonConverter(typeof(EnumMemberJsonConverter<EnumConverterTestStatus>))]
public EnumConverterTestStatus Status { get; set; }
}
public class EnumConverterTestObjectWithMultiple
{
[JsonConverter(typeof(EnumMemberJsonConverter<EnumConverterTestStatus>))]
public EnumConverterTestStatus Status1 { get; set; }
[JsonConverter(typeof(EnumMemberJsonConverter<EnumConverterTestStatus>))]
public EnumConverterTestStatus Status2 { get; set; }
[JsonConverter(typeof(EnumMemberJsonConverter<EnumConverterTestStatus>))]
public EnumConverterTestStatus Status3 { get; set; }
}
public enum EnumConverterTestStatus
{
Pending, // No EnumMemberAttribute
[EnumMember(Value = "active")]
Active,
[EnumMember(Value = "in_progress")]
InProgress,
[EnumMember(Value = "completed")]
Completed
}