Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
86e2320
feat(get-subscription): Add EnumMemberJsonConverter
amorask-bitwarden Jan 7, 2026
574ae6e
feat(get-subscription): Add BitwardenDiscount model
amorask-bitwarden Jan 7, 2026
5f0bcf6
feat(get-subscription): Add Cart model
amorask-bitwarden Jan 7, 2026
8b5bad5
feat(get-subscription): Add Storage model
amorask-bitwarden Jan 7, 2026
d87b987
feat(get-subscription): Add BitwardenSubscription model
amorask-bitwarden Jan 7, 2026
4fe3ba4
feat(get-subscription): Add DiscountExtensions
amorask-bitwarden Jan 7, 2026
4af8d3a
feat(get-subscription): Add error code to StripeConstants
amorask-bitwarden Jan 7, 2026
5a5bd85
feat(get-subscription): Add GetBitwardenSubscriptionQuery
amorask-bitwarden Jan 7, 2026
d0e284a
feat(get-subscription): Expose GET /account/billing/vnext/subscription
amorask-bitwarden Jan 7, 2026
5e6ad80
feat(reinstate-subscription): Add ReinstateSubscriptionCommand
amorask-bitwarden Jan 7, 2026
d9fe77f
feat(reinstate-subscription): Expose POST /account/billing/vnext/subsโ€ฆ
amorask-bitwarden Jan 7, 2026
992ac34
feat(pay-with-paypal-immediately): Add SubscriberId union
amorask-bitwarden Jan 7, 2026
551f259
feat(pay-with-paypal-immediately): Add BraintreeService with PayInvoiโ€ฆ
amorask-bitwarden Jan 7, 2026
480ee5f
feat(pay-with-paypal-immediately): Pay PayPal invoice immediately wheโ€ฆ
amorask-bitwarden Jan 7, 2026
ffeb573
feat(pay-with-paypal-immediately): Pay invoice with Braintree on invoโ€ฆ
amorask-bitwarden Jan 7, 2026
0b21cf8
fix(update-storage): Always invoice for premium storage update
amorask-bitwarden Jan 8, 2026
e04e5a4
fix(update-storage): Move endpoint to subscription path
amorask-bitwarden Jan 8, 2026
c2cc9c4
docs: Note FF removal POIs
amorask-bitwarden Jan 8, 2026
f7eac25
(format): Run dotnet format
amorask-bitwarden Jan 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions src/Api/Billing/Controllers/AccountsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -61,7 +61,7 @@ public async Task<SubscriptionResponseModel> GetSubscriptionAsync(
}
}

// 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)
Expand Down Expand Up @@ -118,7 +118,7 @@ await subscriberService.CancelSubscription(user,
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()
Expand All @@ -131,10 +131,4 @@ public async Task PostReinstateAsync()

await userService.ReinstatePremiumAsync(user);
}

private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
{
var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);
return organizationsClaimingUser.Select(o => o.Id);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
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;
Expand All @@ -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
Expand Down Expand Up @@ -91,10 +95,30 @@ public async Task<IResult> GetLicenseAsync(
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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -22,14 +21,14 @@ public IEnumerable<ValidationResult> Validate(ValidationContext validationContex
{
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)]);
}
}
}
1 change: 1 addition & 0 deletions src/Api/Models/Response/SubscriptionResponseModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

namespace Bit.Api.Models.Response;

// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
public class SubscriptionResponseModel : ResponseModel
{

Expand Down
12 changes: 6 additions & 6 deletions src/Billing/Services/Implementations/InvoiceCreatedHandler.cs
Original file line number Diff line number Diff line change
@@ -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
{
Expand All @@ -29,23 +30,22 @@ public async Task HandleAsync(Event parsedEvent)
{
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
{
AmountDue: > 0,
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)
Expand Down
3 changes: 3 additions & 0 deletions src/Core/Billing/Constants/StripeConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public static class MSPDiscounts
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";
Expand All @@ -65,8 +66,10 @@ public static class InvoiceStatus
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";
Expand Down
6 changes: 5 additions & 1 deletion src/Core/Billing/Enums/PlanCadenceType.cs
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions src/Core/Billing/Extensions/DiscountExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions src/Core/Billing/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@
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;

Expand All @@ -39,6 +42,9 @@ public static void AddBillingOperations(this IServiceCollection services)
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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;
Expand Down Expand Up @@ -49,6 +50,7 @@ Task<BillingCommandResult<None>> Run(

public class CreatePremiumCloudHostedSubscriptionCommand(
IBraintreeGateway braintreeGateway,
IBraintreeService braintreeService,
IGlobalSettings globalSettings,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
Expand Down Expand Up @@ -300,6 +302,7 @@ private async Task<Customer> ReconcileBillingLocationAsync(
ValidateLocation = ValidateTaxLocationTiming.Immediately
}
};

return await stripeAdapter.UpdateCustomerAsync(customer.Id, options);
}

Expand Down Expand Up @@ -351,14 +354,18 @@ private async Task<Subscription> CreateSubscriptionAsync(

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;
}
}
24 changes: 12 additions & 12 deletions src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,6 +11,8 @@

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.
Expand All @@ -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
Expand All @@ -54,7 +57,7 @@ public Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb

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);
Expand All @@ -66,20 +69,20 @@ public Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb
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(
Expand Down Expand Up @@ -124,21 +127,18 @@ public Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb
});
}

// 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();
});
}
Loading
Loading