From 2e072aebe3a1354965b0018de620703133fec501 Mon Sep 17 00:00:00 2001
From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com>
Date: Wed, 25 Sep 2024 08:55:45 -0400
Subject: [PATCH] [PM-8445] Allow for organization sales with no payment method
for trials (#4800)
* Allow for OrganizationSales with no payment method
* Run dotnet format
---
.../Billing/Models/Sales/CustomerSetup.cs | 6 +-
.../Billing/Models/Sales/OrganizationSale.cs | 30 ++-
.../Services/IOrganizationBillingService.cs | 4 +-
.../OrganizationBillingService.cs | 173 +++++++++---------
4 files changed, 112 insertions(+), 101 deletions(-)
diff --git a/src/Core/Billing/Models/Sales/CustomerSetup.cs b/src/Core/Billing/Models/Sales/CustomerSetup.cs
index 47fd5621d..bb4f2352e 100644
--- a/src/Core/Billing/Models/Sales/CustomerSetup.cs
+++ b/src/Core/Billing/Models/Sales/CustomerSetup.cs
@@ -4,7 +4,9 @@
public class CustomerSetup
{
- public required TokenizedPaymentSource TokenizedPaymentSource { get; set; }
- public required TaxInformation TaxInformation { get; set; }
+ public TokenizedPaymentSource? TokenizedPaymentSource { get; set; }
+ public TaxInformation? TaxInformation { get; set; }
public string? Coupon { get; set; }
+
+ public bool IsBillable => TokenizedPaymentSource != null && TaxInformation != null;
}
diff --git a/src/Core/Billing/Models/Sales/OrganizationSale.cs b/src/Core/Billing/Models/Sales/OrganizationSale.cs
index 4d471a84d..a19c278c6 100644
--- a/src/Core/Billing/Models/Sales/OrganizationSale.cs
+++ b/src/Core/Billing/Models/Sales/OrganizationSale.cs
@@ -41,18 +41,27 @@ public class OrganizationSale
SubscriptionSetup = GetSubscriptionSetup(upgrade)
};
- private static CustomerSetup? GetCustomerSetup(OrganizationSignup signup)
+ private static CustomerSetup GetCustomerSetup(OrganizationSignup signup)
{
+ var customerSetup = new CustomerSetup
+ {
+ Coupon = signup.IsFromProvider
+ ? StripeConstants.CouponIDs.MSPDiscount35
+ : signup.IsFromSecretsManagerTrial
+ ? StripeConstants.CouponIDs.SecretsManagerStandalone
+ : null
+ };
+
if (!signup.PaymentMethodType.HasValue)
{
- return null;
+ return customerSetup;
}
- var tokenizedPaymentSource = new TokenizedPaymentSource(
+ customerSetup.TokenizedPaymentSource = new TokenizedPaymentSource(
signup.PaymentMethodType!.Value,
signup.PaymentToken);
- var taxInformation = new TaxInformation(
+ customerSetup.TaxInformation = new TaxInformation(
signup.TaxInfo.BillingAddressCountry,
signup.TaxInfo.BillingAddressPostalCode,
signup.TaxInfo.TaxIdNumber,
@@ -61,18 +70,7 @@ public class OrganizationSale
signup.TaxInfo.BillingAddressCity,
signup.TaxInfo.BillingAddressState);
- var coupon = signup.IsFromProvider
- ? StripeConstants.CouponIDs.MSPDiscount35
- : signup.IsFromSecretsManagerTrial
- ? StripeConstants.CouponIDs.SecretsManagerStandalone
- : null;
-
- return new CustomerSetup
- {
- TokenizedPaymentSource = tokenizedPaymentSource,
- TaxInformation = taxInformation,
- Coupon = coupon
- };
+ return customerSetup;
}
private static SubscriptionSetup GetSubscriptionSetup(OrganizationUpgrade upgrade)
diff --git a/src/Core/Billing/Services/IOrganizationBillingService.cs b/src/Core/Billing/Services/IOrganizationBillingService.cs
index c4d02db7f..907860d96 100644
--- a/src/Core/Billing/Services/IOrganizationBillingService.cs
+++ b/src/Core/Billing/Services/IOrganizationBillingService.cs
@@ -4,6 +4,8 @@ using Bit.Core.Billing.Models.Sales;
namespace Bit.Core.Billing.Services;
+#nullable enable
+
public interface IOrganizationBillingService
{
///
@@ -29,7 +31,7 @@ public interface IOrganizationBillingService
///
/// The ID of the organization to retrieve metadata for.
/// An record.
- Task GetMetadata(Guid organizationId);
+ Task GetMetadata(Guid organizationId);
///
/// Updates the provided 's payment source and tax information.
diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs
index 9b76a04f1..0880c3678 100644
--- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs
+++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs
@@ -19,6 +19,8 @@ using Subscription = Stripe.Subscription;
namespace Bit.Core.Billing.Services.Implementations;
+#nullable enable
+
public class OrganizationBillingService(
IBraintreeGateway braintreeGateway,
IGlobalSettings globalSettings,
@@ -53,7 +55,7 @@ public class OrganizationBillingService(
await organizationRepository.ReplaceAsync(organization);
}
- public async Task GetMetadata(Guid organizationId)
+ public async Task GetMetadata(Guid organizationId)
{
var organization = await organizationRepository.GetByIdAsync(organizationId);
@@ -90,7 +92,7 @@ public class OrganizationBillingService(
new CustomerSetup
{
TokenizedPaymentSource = tokenizedPaymentSource,
- TaxInformation = taxInformation,
+ TaxInformation = taxInformation
});
organization.Gateway = GatewayType.Stripe;
@@ -110,37 +112,12 @@ public class OrganizationBillingService(
private async Task CreateCustomerAsync(
Organization organization,
CustomerSetup customerSetup,
- List expand = null)
+ List? expand = null)
{
- if (customerSetup.TokenizedPaymentSource is not
- {
- Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
- Token: not null and not ""
- })
- {
- logger.LogError(
- "Cannot create customer for organization ({OrganizationID}) without a valid payment source",
- organization.Id);
-
- throw new BillingException();
- }
-
- if (customerSetup.TaxInformation is not { Country: not null and not "", PostalCode: not null and not "" })
- {
- logger.LogError(
- "Cannot create customer for organization ({OrganizationID}) without valid tax information",
- organization.Id);
-
- throw new BillingException();
- }
-
- var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions();
-
var organizationDisplayName = organization.DisplayName();
var customerCreateOptions = new CustomerCreateOptions
{
- Address = address,
Coupon = customerSetup.Coupon,
Description = organization.DisplayBusinessName(),
Email = organization.BillingEmail,
@@ -159,58 +136,87 @@ public class OrganizationBillingService(
Metadata = new Dictionary
{
{ "region", globalSettings.BaseServiceUri.CloudRegion }
- },
- Tax = new CustomerTaxOptions
- {
- ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
- },
- TaxIdData = taxIdData
+ }
};
- var (type, token) = customerSetup.TokenizedPaymentSource;
-
var braintreeCustomerId = "";
- // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
- switch (type)
+ if (customerSetup.IsBillable)
{
- case PaymentMethodType.BankAccount:
+ if (customerSetup.TokenizedPaymentSource is not
{
- var setupIntent =
- (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
- .FirstOrDefault();
+ Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
+ Token: not null and not ""
+ })
+ {
+ logger.LogError(
+ "Cannot create customer for organization ({OrganizationID}) without a valid payment source",
+ organization.Id);
- if (setupIntent == null)
+ throw new BillingException();
+ }
+
+ if (customerSetup.TaxInformation is not { Country: not null and not "", PostalCode: not null and not "" })
+ {
+ logger.LogError(
+ "Cannot create customer for organization ({OrganizationID}) without valid tax information",
+ organization.Id);
+
+ throw new BillingException();
+ }
+
+ var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions();
+
+ customerCreateOptions.Address = address;
+ customerCreateOptions.Tax = new CustomerTaxOptions
+ {
+ ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
+ };
+ customerCreateOptions.TaxIdData = taxIdData;
+
+ var (type, token) = customerSetup.TokenizedPaymentSource;
+
+ // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
+ switch (type)
+ {
+ case PaymentMethodType.BankAccount:
{
- logger.LogError("Cannot create customer for organization ({OrganizationID}) without a setup intent for their bank account", organization.Id);
+ var setupIntent =
+ (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token }))
+ .FirstOrDefault();
+
+ if (setupIntent == null)
+ {
+ logger.LogError("Cannot create customer for organization ({OrganizationID}) without a setup intent for their bank account", organization.Id);
+
+ throw new BillingException();
+ }
+
+ await setupIntentCache.Set(organization.Id, setupIntent.Id);
+
+ break;
+ }
+ case PaymentMethodType.Card:
+ {
+ customerCreateOptions.PaymentMethod = token;
+ customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = token;
+ break;
+ }
+ case PaymentMethodType.PayPal:
+ {
+ braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, token);
+
+ customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
+
+ break;
+ }
+ default:
+ {
+ logger.LogError("Cannot create customer for organization ({OrganizationID}) using payment method type ({PaymentMethodType}) as it is not supported", organization.Id, type.ToString());
throw new BillingException();
}
-
- await setupIntentCache.Set(organization.Id, setupIntent.Id);
-
- break;
- }
- case PaymentMethodType.Card:
- {
- customerCreateOptions.PaymentMethod = token;
- customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = token;
- break;
- }
- case PaymentMethodType.PayPal:
- {
- braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(organization, token);
-
- customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
-
- break;
- }
- default:
- {
- logger.LogError("Cannot create customer for organization ({OrganizationID}) using payment method type ({PaymentMethodType}) as it is not supported", organization.Id, type.ToString());
-
- throw new BillingException();
- }
+ }
}
try
@@ -241,19 +247,22 @@ public class OrganizationBillingService(
async Task Revert()
{
- // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
- switch (type)
+ if (customerSetup.IsBillable)
{
- case PaymentMethodType.BankAccount:
- {
- await setupIntentCache.Remove(organization.Id);
- break;
- }
- case PaymentMethodType.PayPal:
- {
- await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
- break;
- }
+ // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
+ switch (customerSetup.TokenizedPaymentSource!.Type)
+ {
+ case PaymentMethodType.BankAccount:
+ {
+ await setupIntentCache.Remove(organization.Id);
+ break;
+ }
+ case PaymentMethodType.PayPal:
+ {
+ await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
+ break;
+ }
+ }
}
}
}
@@ -334,7 +343,7 @@ public class OrganizationBillingService(
["organizationId"] = organizationId.ToString()
},
OffSession = true,
- TrialPeriodDays = plan.TrialPeriodDays,
+ TrialPeriodDays = plan.TrialPeriodDays
};
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);