1
0
mirror of https://github.com/bitwarden/server synced 2026-01-15 23:13:56 +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]);
}