1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-06 21:48:12 -05:00

[PM-8445] Allow for organization sales with no payment method for trials (#4800)

* Allow for OrganizationSales with no payment method

* Run dotnet format
This commit is contained in:
Alex Morask 2024-09-25 08:55:45 -04:00 committed by GitHub
parent 6514b342fc
commit 2e072aebe3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 112 additions and 101 deletions

View File

@ -4,7 +4,9 @@
public class CustomerSetup public class CustomerSetup
{ {
public required TokenizedPaymentSource TokenizedPaymentSource { get; set; } public TokenizedPaymentSource? TokenizedPaymentSource { get; set; }
public required TaxInformation TaxInformation { get; set; } public TaxInformation? TaxInformation { get; set; }
public string? Coupon { get; set; } public string? Coupon { get; set; }
public bool IsBillable => TokenizedPaymentSource != null && TaxInformation != null;
} }

View File

@ -41,18 +41,27 @@ public class OrganizationSale
SubscriptionSetup = GetSubscriptionSetup(upgrade) 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) if (!signup.PaymentMethodType.HasValue)
{ {
return null; return customerSetup;
} }
var tokenizedPaymentSource = new TokenizedPaymentSource( customerSetup.TokenizedPaymentSource = new TokenizedPaymentSource(
signup.PaymentMethodType!.Value, signup.PaymentMethodType!.Value,
signup.PaymentToken); signup.PaymentToken);
var taxInformation = new TaxInformation( customerSetup.TaxInformation = new TaxInformation(
signup.TaxInfo.BillingAddressCountry, signup.TaxInfo.BillingAddressCountry,
signup.TaxInfo.BillingAddressPostalCode, signup.TaxInfo.BillingAddressPostalCode,
signup.TaxInfo.TaxIdNumber, signup.TaxInfo.TaxIdNumber,
@ -61,18 +70,7 @@ public class OrganizationSale
signup.TaxInfo.BillingAddressCity, signup.TaxInfo.BillingAddressCity,
signup.TaxInfo.BillingAddressState); signup.TaxInfo.BillingAddressState);
var coupon = signup.IsFromProvider return customerSetup;
? StripeConstants.CouponIDs.MSPDiscount35
: signup.IsFromSecretsManagerTrial
? StripeConstants.CouponIDs.SecretsManagerStandalone
: null;
return new CustomerSetup
{
TokenizedPaymentSource = tokenizedPaymentSource,
TaxInformation = taxInformation,
Coupon = coupon
};
} }
private static SubscriptionSetup GetSubscriptionSetup(OrganizationUpgrade upgrade) private static SubscriptionSetup GetSubscriptionSetup(OrganizationUpgrade upgrade)

View File

@ -4,6 +4,8 @@ using Bit.Core.Billing.Models.Sales;
namespace Bit.Core.Billing.Services; namespace Bit.Core.Billing.Services;
#nullable enable
public interface IOrganizationBillingService public interface IOrganizationBillingService
{ {
/// <summary> /// <summary>
@ -29,7 +31,7 @@ public interface IOrganizationBillingService
/// </summary> /// </summary>
/// <param name="organizationId">The ID of the organization to retrieve metadata for.</param> /// <param name="organizationId">The ID of the organization to retrieve metadata for.</param>
/// <returns>An <see cref="OrganizationMetadata"/> record.</returns> /// <returns>An <see cref="OrganizationMetadata"/> record.</returns>
Task<OrganizationMetadata> GetMetadata(Guid organizationId); Task<OrganizationMetadata?> GetMetadata(Guid organizationId);
/// <summary> /// <summary>
/// Updates the provided <paramref name="organization"/>'s payment source and tax information. /// Updates the provided <paramref name="organization"/>'s payment source and tax information.

View File

@ -19,6 +19,8 @@ using Subscription = Stripe.Subscription;
namespace Bit.Core.Billing.Services.Implementations; namespace Bit.Core.Billing.Services.Implementations;
#nullable enable
public class OrganizationBillingService( public class OrganizationBillingService(
IBraintreeGateway braintreeGateway, IBraintreeGateway braintreeGateway,
IGlobalSettings globalSettings, IGlobalSettings globalSettings,
@ -53,7 +55,7 @@ public class OrganizationBillingService(
await organizationRepository.ReplaceAsync(organization); await organizationRepository.ReplaceAsync(organization);
} }
public async Task<OrganizationMetadata> GetMetadata(Guid organizationId) public async Task<OrganizationMetadata?> GetMetadata(Guid organizationId)
{ {
var organization = await organizationRepository.GetByIdAsync(organizationId); var organization = await organizationRepository.GetByIdAsync(organizationId);
@ -90,7 +92,7 @@ public class OrganizationBillingService(
new CustomerSetup new CustomerSetup
{ {
TokenizedPaymentSource = tokenizedPaymentSource, TokenizedPaymentSource = tokenizedPaymentSource,
TaxInformation = taxInformation, TaxInformation = taxInformation
}); });
organization.Gateway = GatewayType.Stripe; organization.Gateway = GatewayType.Stripe;
@ -110,37 +112,12 @@ public class OrganizationBillingService(
private async Task<Customer> CreateCustomerAsync( private async Task<Customer> CreateCustomerAsync(
Organization organization, Organization organization,
CustomerSetup customerSetup, CustomerSetup customerSetup,
List<string> expand = null) List<string>? 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 organizationDisplayName = organization.DisplayName();
var customerCreateOptions = new CustomerCreateOptions var customerCreateOptions = new CustomerCreateOptions
{ {
Address = address,
Coupon = customerSetup.Coupon, Coupon = customerSetup.Coupon,
Description = organization.DisplayBusinessName(), Description = organization.DisplayBusinessName(),
Email = organization.BillingEmail, Email = organization.BillingEmail,
@ -159,58 +136,87 @@ public class OrganizationBillingService(
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string>
{ {
{ "region", globalSettings.BaseServiceUri.CloudRegion } { "region", globalSettings.BaseServiceUri.CloudRegion }
}, }
Tax = new CustomerTaxOptions
{
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
},
TaxIdData = taxIdData
}; };
var (type, token) = customerSetup.TokenizedPaymentSource;
var braintreeCustomerId = ""; var braintreeCustomerId = "";
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault if (customerSetup.IsBillable)
switch (type)
{ {
case PaymentMethodType.BankAccount: if (customerSetup.TokenizedPaymentSource is not
{ {
var setupIntent = Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal,
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token })) Token: not null and not ""
.FirstOrDefault(); })
{
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(); 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 try
@ -241,19 +247,22 @@ public class OrganizationBillingService(
async Task Revert() async Task Revert()
{ {
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault if (customerSetup.IsBillable)
switch (type)
{ {
case PaymentMethodType.BankAccount: // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
{ switch (customerSetup.TokenizedPaymentSource!.Type)
await setupIntentCache.Remove(organization.Id); {
break; case PaymentMethodType.BankAccount:
} {
case PaymentMethodType.PayPal: await setupIntentCache.Remove(organization.Id);
{ break;
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); }
break; case PaymentMethodType.PayPal:
} {
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
break;
}
}
} }
} }
} }
@ -334,7 +343,7 @@ public class OrganizationBillingService(
["organizationId"] = organizationId.ToString() ["organizationId"] = organizationId.ToString()
}, },
OffSession = true, OffSession = true,
TrialPeriodDays = plan.TrialPeriodDays, TrialPeriodDays = plan.TrialPeriodDays
}; };
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);