Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions src/Api/Billing/Controllers/PreviewInvoiceController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ public async Task<IResult> PreviewOrganizationSubscriptionUpdateTaxAsync(
public async Task<IResult> PreviewPremiumSubscriptionPurchaseTaxAsync(
[FromBody] PreviewPremiumSubscriptionPurchaseTaxRequest request)
{
var (purchase, billingAddress) = request.ToDomain();
var result = await previewPremiumTaxCommand.Run(purchase, billingAddress);
var (preview, billingAddress) = request.ToDomain();
var result = await previewPremiumTaxCommand.Run(preview, billingAddress);
return Handle(result.Map(pair => new { pair.Tax, pair.Total }));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,8 @@ public async Task<IResult> CreateSubscriptionAsync(
[BindNever] User user,
[FromBody] PremiumCloudHostedSubscriptionRequest request)
{
var (paymentMethod, billingAddress, additionalStorageGb) = request.ToDomain();
var result = await createPremiumCloudHostedSubscriptionCommand.Run(
user, paymentMethod, billingAddress, additionalStorageGb);
var subscriptionPurchase = request.ToDomain();
var result = await createPremiumCloudHostedSubscriptionCommand.Run(user, subscriptionPurchase);
return Handle(result);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ public record OrganizationSubscriptionPurchaseRequest : IValidatableObject

public SecretsManagerPurchaseSelections? SecretsManager { get; set; }

[MaxLength(50)]
public string? Coupon { get; set; }

public OrganizationSubscriptionPurchase ToDomain() => new()
{
Tier = Tier,
Expand All @@ -35,7 +38,8 @@ public record OrganizationSubscriptionPurchaseRequest : IValidatableObject
Seats = SecretsManager.Seats,
AdditionalServiceAccounts = SecretsManager.AdditionalServiceAccounts,
Standalone = SecretsManager.Standalone
} : null
} : null,
Coupon = Coupon
};

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Premium.Models;

namespace Bit.Api.Billing.Models.Requests.Premium;

Expand All @@ -15,8 +16,10 @@ public class PremiumCloudHostedSubscriptionRequest : IValidatableObject
[Range(0, 99)]
public short AdditionalStorageGb { get; set; } = 0;

[MaxLength(50)]
public string? Coupon { get; set; }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏️ The DB only takes 50 characters so we should [MaxLength(50)] this.


public (PaymentMethod, BillingAddress, short) ToDomain()
public PremiumSubscriptionPurchase ToDomain()
{
// Check if TokenizedPaymentMethod or NonTokenizedPaymentMethod is provided.
var tokenizedPaymentMethod = TokenizedPaymentMethod?.ToDomain();
Expand All @@ -28,7 +31,13 @@ public class PremiumCloudHostedSubscriptionRequest : IValidatableObject

var billingAddress = BillingAddress.ToDomain();

return (paymentMethod, billingAddress, AdditionalStorageGb);
return new PremiumSubscriptionPurchase
{
PaymentMethod = paymentMethod,
BillingAddress = billingAddress,
AdditionalStorageGb = AdditionalStorageGb,
Coupon = Coupon
};
}

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Premium.Models;

namespace Bit.Api.Billing.Models.Requests.PreviewInvoice;

Expand All @@ -13,5 +14,14 @@ public record PreviewPremiumSubscriptionPurchaseTaxRequest
[Required]
public required MinimalBillingAddressRequest BillingAddress { get; set; }

public (short, BillingAddress) ToDomain() => (AdditionalStorage, BillingAddress.ToDomain());
[MaxLength(50)]
public string? Coupon { get; set; }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛏️ The DB only takes 50 characters so we should [MaxLength(50)] this.


public (PremiumPurchasePreview, BillingAddress) ToDomain() => (
new PremiumPurchasePreview
{
AdditionalStorageGb = AdditionalStorage,
Coupon = Coupon
},
BillingAddress.ToDomain());
}
2 changes: 2 additions & 0 deletions src/Core/Billing/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
namespace Bit.Core.Billing.Extensions;

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

public static class ServiceCollectionExtensions
{
Expand All @@ -31,6 +32,7 @@ public static void AddBillingOperations(this IServiceCollection services)
services.AddTransient<IPremiumUserBillingService, PremiumUserBillingService>();
services.AddTransient<ISetupIntentCache, SetupIntentDistributedCache>();
services.AddTransient<ISubscriberService, SubscriberService>();
services.TryAddTransient<ISubscriptionDiscountService, SubscriptionDiscountService>();
services.AddLicenseServices();
services.AddLicenseOperations();
services.AddPricingClient();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ public class PreviewOrganizationTaxCommand(
Quantity = purchase.SecretsManager.Seats
}
]);
// System coupon takes precedence for standalone Secrets Manager purchases.
// Any user-provided coupons are ignored in this scenario.
options.Discounts =
[
new InvoiceDiscountOptions
Expand Down Expand Up @@ -120,6 +122,11 @@ public class PreviewOrganizationTaxCommand(
}
}

if (!string.IsNullOrWhiteSpace(purchase.Coupon))
{
options.Discounts = [new InvoiceDiscountOptions { Coupon = purchase.Coupon.Trim() }];
}

break;
}

Expand Down
9 changes: 7 additions & 2 deletions src/Core/Billing/Organizations/Models/OrganizationSale.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Sales;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Entities;
using Bit.Core.Models.Business;

namespace Bit.Core.Billing.Organizations.Models;
Expand All @@ -14,16 +15,19 @@ internal OrganizationSale() { }
public void Deconstruct(
out Organization organization,
out CustomerSetup? customerSetup,
out SubscriptionSetup subscriptionSetup)
out SubscriptionSetup subscriptionSetup,
out User? owner)
{
organization = Organization;
customerSetup = CustomerSetup;
subscriptionSetup = SubscriptionSetup;
owner = Owner;
}

public required Organization Organization { get; init; }
public CustomerSetup? CustomerSetup { get; init; }
public required SubscriptionSetup SubscriptionSetup { get; init; }
public User? Owner { get; init; }

public static OrganizationSale From(
Organization organization,
Expand All @@ -40,7 +44,8 @@ public static OrganizationSale From(
{
Organization = organization,
CustomerSetup = customerSetup,
SubscriptionSetup = subscriptionSetup
SubscriptionSetup = subscriptionSetup,
Owner = signup.Owner
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public record OrganizationSubscriptionPurchase
public PlanCadenceType Cadence { get; init; }
public required PasswordManagerSelections PasswordManager { get; init; }
public SecretsManagerSelections? SecretsManager { get; init; }
public string? Coupon { get; init; }

public PlanType PlanType =>
// ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public class OrganizationBillingService(
{
public async Task Finalize(OrganizationSale sale)
{
var (organization, customerSetup, subscriptionSetup) = sale;
var (organization, customerSetup, subscriptionSetup, owner) = sale;

var customer = string.IsNullOrEmpty(organization.GatewayCustomerId) && customerSetup != null
? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType)
Expand Down Expand Up @@ -484,7 +484,7 @@ private async Task<Subscription> CreateSubscriptionAsync(
{
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
Customer = customer.Id,
Discounts = !string.IsNullOrEmpty(coupon) ? [new SubscriptionDiscountOptions { Coupon = coupon }] : null,
Discounts = !string.IsNullOrWhiteSpace(coupon) ? [new SubscriptionDiscountOptions { Coupon = coupon.Trim() }] : null,
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using Bit.Core.Billing.Caches;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Premium.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Subscriptions.Models;
Expand Down Expand Up @@ -37,15 +39,11 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand
/// Creates a premium cloud-hosted subscription for the specified user.
/// </summary>
/// <param name="user">The user to create the premium subscription for. Must not yet be a premium user.</param>
/// <param name="paymentMethod">The tokenized payment method containing the payment type and token for billing.</param>
/// <param name="billingAddress">The billing address information required for tax calculation and customer creation.</param>
/// <param name="additionalStorageGb">Additional storage in GB beyond the base 1GB included with premium (must be >= 0).</param>
/// <param name="subscriptionPurchase">The subscription purchase details including payment method, billing address, storage, and optional coupon.</param>
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
Task<BillingCommandResult<None>> Run(
User user,
PaymentMethod paymentMethod,
BillingAddress billingAddress,
short additionalStorageGb);
PremiumSubscriptionPurchase subscriptionPurchase);
}

public class CreatePremiumCloudHostedSubscriptionCommand(
Expand All @@ -60,17 +58,16 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
ILogger<CreatePremiumCloudHostedSubscriptionCommand> logger,
IPricingClient pricingClient,
IHasPaymentMethodQuery hasPaymentMethodQuery,
IUpdatePaymentMethodCommand updatePaymentMethodCommand)
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
ISubscriptionDiscountService subscriptionDiscountService)
: BaseBillingCommand<CreatePremiumCloudHostedSubscriptionCommand>(logger), ICreatePremiumCloudHostedSubscriptionCommand
{
private static readonly List<string> _expand = ["tax"];
private readonly ILogger<CreatePremiumCloudHostedSubscriptionCommand> _logger = logger;

public Task<BillingCommandResult<None>> Run(
User user,
PaymentMethod paymentMethod,
BillingAddress billingAddress,
short additionalStorageGb) => HandleAsync<None>(async () =>
PremiumSubscriptionPurchase subscriptionPurchase) => HandleAsync<None>(async () =>
{
// A "terminal" subscription is one that has ended and cannot be renewed/reactivated.
// These are: 'canceled' (user canceled) and 'incomplete_expired' (payment failed and time expired).
Expand All @@ -83,11 +80,20 @@ public Task<BillingCommandResult<None>> Run(
return new BadRequest("Already a premium user.");
}

if (additionalStorageGb < 0)
if (subscriptionPurchase.AdditionalStorageGb < 0)
{
return new BadRequest("Additional storage must be greater than 0.");
}

if (!string.IsNullOrWhiteSpace(subscriptionPurchase.Coupon))
{
var isValid = await subscriptionDiscountService.ValidateDiscountForUserAsync(user, subscriptionPurchase.Coupon.Trim(), DiscountAudienceType.UserHasNoPreviousSubscriptions);
if (!isValid)
{
return new BadRequest("The coupon code is invalid or you are not eligible for this discount.");
}
}

var premiumPlan = await pricingClient.GetAvailablePremiumPlan();

Customer? customer;
Expand All @@ -97,7 +103,7 @@ public Task<BillingCommandResult<None>> Run(
*/
if (string.IsNullOrEmpty(user.GatewayCustomerId))
{
customer = await CreateCustomerAsync(user, paymentMethod, billingAddress);
customer = await CreateCustomerAsync(user, subscriptionPurchase.PaymentMethod, subscriptionPurchase.BillingAddress);
}
/*
* An existing customer without a payment method starting a new subscription indicates a user who previously
Expand All @@ -108,21 +114,21 @@ public Task<BillingCommandResult<None>> Run(
* Additionally, if this is a resubscribe scenario with a tokenized payment method, we should update the payment method
* to ensure the new payment method is used instead of the old one.
*/
else if (paymentMethod.IsTokenized && (!await hasPaymentMethodQuery.Run(user) || hasTerminalSubscription))
else if (subscriptionPurchase.PaymentMethod.IsTokenized && (!await hasPaymentMethodQuery.Run(user) || hasTerminalSubscription))
{
await updatePaymentMethodCommand.Run(user, paymentMethod.AsTokenized, billingAddress);
await updatePaymentMethodCommand.Run(user, subscriptionPurchase.PaymentMethod.AsTokenized, subscriptionPurchase.BillingAddress);
customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
}
else
{
customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
}

customer = await ReconcileBillingLocationAsync(customer, billingAddress);
customer = await ReconcileBillingLocationAsync(customer, subscriptionPurchase.BillingAddress);

var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, additionalStorageGb > 0 ? additionalStorageGb : null);
var subscription = await CreateSubscriptionAsync(user.Id, customer, premiumPlan, subscriptionPurchase.AdditionalStorageGb > 0 ? subscriptionPurchase.AdditionalStorageGb : null, subscriptionPurchase.Coupon);

paymentMethod.Switch(
subscriptionPurchase.PaymentMethod.Switch(
tokenized =>
{
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
Expand Down Expand Up @@ -153,7 +159,7 @@ public Task<BillingCommandResult<None>> Run(
user.Gateway = GatewayType.Stripe;
user.GatewayCustomerId = customer.Id;
user.GatewaySubscriptionId = subscription.Id;
user.MaxStorageGb = (short)(premiumPlan.Storage.Provided + additionalStorageGb);
user.MaxStorageGb = (short)(premiumPlan.Storage.Provided + subscriptionPurchase.AdditionalStorageGb);
user.LicenseKey = CoreHelpers.SecureRandomString(20);
user.RevisionDate = DateTime.UtcNow;

Expand Down Expand Up @@ -319,7 +325,8 @@ private async Task<Subscription> CreateSubscriptionAsync(
Guid userId,
Customer customer,
Pricing.Premium.Plan premiumPlan,
int? storage)
int? storage,
string? coupon)
{

var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
Expand Down Expand Up @@ -361,6 +368,11 @@ private async Task<Subscription> CreateSubscriptionAsync(
OffSession = true
};

if (!string.IsNullOrWhiteSpace(coupon))
{
subscriptionCreateOptions.Discounts = [new SubscriptionDiscountOptions { Coupon = coupon.Trim() }];
}

var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions);

if (!usingPayPal)
Expand Down
14 changes: 10 additions & 4 deletions src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Premium.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Microsoft.Extensions.Logging;
Expand All @@ -10,7 +11,7 @@ namespace Bit.Core.Billing.Premium.Commands;
public interface IPreviewPremiumTaxCommand
{
Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run(
int additionalStorage,
PremiumPurchasePreview preview,
BillingAddress billingAddress);
}

Expand All @@ -20,7 +21,7 @@ public class PreviewPremiumTaxCommand(
IStripeAdapter stripeAdapter) : BaseBillingCommand<PreviewPremiumTaxCommand>(logger), IPreviewPremiumTaxCommand
{
public Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run(
int additionalStorage,
PremiumPurchasePreview preview,
BillingAddress billingAddress)
=> HandleAsync<(decimal, decimal)>(async () =>
{
Expand All @@ -47,15 +48,20 @@ public class PreviewPremiumTaxCommand(
}
};

if (additionalStorage > 0)
if (preview.AdditionalStorageGb > 0)
{
options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions
{
Price = premiumPlan.Storage.StripePriceId,
Quantity = additionalStorage
Quantity = preview.AdditionalStorageGb
});
}

if (!string.IsNullOrWhiteSpace(preview.Coupon))
{
options.Discounts = [new InvoiceDiscountOptions { Coupon = preview.Coupon.Trim() }];
}

var invoice = await stripeAdapter.CreateInvoicePreviewAsync(options);
return GetAmounts(invoice);
});
Expand Down
Loading
Loading