mirror of
https://github.com/bitwarden/server.git
synced 2025-07-06 18:42:49 -05:00
[PM-11728] Upgrade free organizations without Stripe Sources API (#4757)
* Refactor: Update metadata in OrganizationSignup and OrganizationUpgrade This commit moves the IsFromSecretsManagerTrial flag from the OrganizationUpgrade to the OrganizationSignup because it will only be passed in on organization creation. Additionally, it removes the nullable boolean 'provider' flag passed to OrganizationService.SignUpAsync and instead adds that boolean flag to the OrganizationSignup which seems more appropriate. * Introduce OrganizationSale While I'm trying to ingrain a singular model that can be used to purchase or upgrade organizations, I disliked my previously implemented OrganizationSubscriptionPurchase for being a little too wordy and specific. This sale class aligns more closely with the work we need to complete against Stripe and also uses a private constructor so that it can only be created and utilized via an Organiztion and either OrganizationSignup or OrganizationUpgrade object. * Use OrganizationSale in OrganizationBillingService This commit renames the OrganizationBillingService.PurchaseSubscription to Finalize and passes it the OrganizationSale object. It also updates the method so that, if the organization already has a customer, it retrieves that customer instead of automatically trying to create one which we'll need for upgraded free organizations. * Add functionality for free organization upgrade This commit adds an UpdatePaymentMethod to the OrganizationBillingService that will check if a customer exists for the organization and if not, creates one with the updated payment source and tax information. Then, in the UpgradeOrganizationPlanCommand, we can use the OrganizationUpgrade to get an OrganizationSale and finalize it, which will create a subscription using the customer created as part of the payment method update that takes place right before it on the client-side. Additionally, it adds some tax ID backfill logic to SubscriberService.UpdateTaxInformation * (No Logic) Re-order OrganizationBillingService methods alphabetically * (No Logic) Run dotnet format
This commit is contained in:
@ -1,8 +1,8 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Billing.Caches;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Sales;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
@ -28,6 +28,31 @@ public class OrganizationBillingService(
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService) : IOrganizationBillingService
|
||||
{
|
||||
public async Task Finalize(OrganizationSale sale)
|
||||
{
|
||||
var (organization, customerSetup, subscriptionSetup) = sale;
|
||||
|
||||
List<string> expand = ["tax"];
|
||||
|
||||
var customer = customerSetup != null
|
||||
? await CreateCustomerAsync(organization, customerSetup, expand)
|
||||
: await subscriberService.GetCustomerOrThrow(organization, new CustomerGetOptions { Expand = expand });
|
||||
|
||||
var subscription = await CreateSubscriptionAsync(organization.Id, customer, subscriptionSetup);
|
||||
|
||||
if (subscription.Status is StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active)
|
||||
{
|
||||
organization.Enabled = true;
|
||||
organization.ExpirationDate = subscription.CurrentPeriodEnd;
|
||||
}
|
||||
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
organization.GatewayCustomerId = customer.Id;
|
||||
organization.GatewaySubscriptionId = subscription.Id;
|
||||
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
}
|
||||
|
||||
public async Task<OrganizationMetadata> GetMetadata(Guid organizationId)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
@ -54,97 +79,72 @@ public class OrganizationBillingService(
|
||||
return new OrganizationMetadata(isOnSecretsManagerStandalone);
|
||||
}
|
||||
|
||||
public async Task PurchaseSubscription(
|
||||
public async Task UpdatePaymentMethod(
|
||||
Organization organization,
|
||||
OrganizationSubscriptionPurchase organizationSubscriptionPurchase)
|
||||
TokenizedPaymentSource tokenizedPaymentSource,
|
||||
TaxInformation taxInformation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(organization);
|
||||
ArgumentNullException.ThrowIfNull(organizationSubscriptionPurchase);
|
||||
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
|
||||
{
|
||||
var customer = await CreateCustomerAsync(organization,
|
||||
new CustomerSetup
|
||||
{
|
||||
TokenizedPaymentSource = tokenizedPaymentSource,
|
||||
TaxInformation = taxInformation,
|
||||
});
|
||||
|
||||
var (
|
||||
metadata,
|
||||
passwordManager,
|
||||
paymentSource,
|
||||
planType,
|
||||
secretsManager,
|
||||
taxInformation) = organizationSubscriptionPurchase;
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
organization.GatewayCustomerId = customer.Id;
|
||||
|
||||
var customer = await CreateCustomerAsync(organization, metadata, paymentSource, taxInformation);
|
||||
|
||||
var subscription =
|
||||
await CreateSubscriptionAsync(customer, organization.Id, passwordManager, planType, secretsManager);
|
||||
|
||||
organization.Enabled = true;
|
||||
organization.ExpirationDate = subscription.CurrentPeriodEnd;
|
||||
organization.Gateway = GatewayType.Stripe;
|
||||
organization.GatewayCustomerId = customer.Id;
|
||||
organization.GatewaySubscriptionId = subscription.Id;
|
||||
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
}
|
||||
else
|
||||
{
|
||||
await subscriberService.UpdatePaymentSource(organization, tokenizedPaymentSource);
|
||||
await subscriberService.UpdateTaxInformation(organization, taxInformation);
|
||||
}
|
||||
}
|
||||
|
||||
#region Utilities
|
||||
|
||||
private async Task<Customer> CreateCustomerAsync(
|
||||
Organization organization,
|
||||
OrganizationSubscriptionPurchaseMetadata metadata,
|
||||
TokenizedPaymentSource paymentSource,
|
||||
TaxInformation taxInformation)
|
||||
CustomerSetup customerSetup,
|
||||
List<string> expand = null)
|
||||
{
|
||||
if (paymentSource == 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 payment source",
|
||||
"Cannot create customer for organization ({OrganizationID}) without a valid payment source",
|
||||
organization.Id);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
if (taxInformation is not { Country: not null, PostalCode: not null })
|
||||
if (customerSetup.TaxInformation is not { Country: not null and not "", PostalCode: not null and not "" })
|
||||
{
|
||||
logger.LogError(
|
||||
"Cannot create customer for organization ({OrganizationID}) without both a country and postal code",
|
||||
"Cannot create customer for organization ({OrganizationID}) without valid tax information",
|
||||
organization.Id);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var (
|
||||
country,
|
||||
postalCode,
|
||||
taxId,
|
||||
line1,
|
||||
line2,
|
||||
city,
|
||||
state) = taxInformation;
|
||||
|
||||
var address = new AddressOptions
|
||||
{
|
||||
Country = country,
|
||||
PostalCode = postalCode,
|
||||
City = city,
|
||||
Line1 = line1,
|
||||
Line2 = line2,
|
||||
State = state
|
||||
};
|
||||
|
||||
var (fromProvider, fromSecretsManagerStandalone) = metadata ?? OrganizationSubscriptionPurchaseMetadata.Default;
|
||||
|
||||
var coupon = fromProvider
|
||||
? StripeConstants.CouponIDs.MSPDiscount35
|
||||
: fromSecretsManagerStandalone
|
||||
? StripeConstants.CouponIDs.SecretsManagerStandalone
|
||||
: null;
|
||||
var (address, taxIdData) = customerSetup.TaxInformation.GetStripeOptions();
|
||||
|
||||
var organizationDisplayName = organization.DisplayName();
|
||||
|
||||
var customerCreateOptions = new CustomerCreateOptions
|
||||
{
|
||||
Address = address,
|
||||
Coupon = coupon,
|
||||
Coupon = customerSetup.Coupon,
|
||||
Description = organization.DisplayBusinessName(),
|
||||
Email = organization.BillingEmail,
|
||||
Expand = ["tax"],
|
||||
Expand = expand,
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
CustomFields = [
|
||||
@ -164,21 +164,10 @@ public class OrganizationBillingService(
|
||||
{
|
||||
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
|
||||
},
|
||||
TaxIdData = !string.IsNullOrEmpty(taxId)
|
||||
? [new CustomerTaxIdDataOptions { Type = taxInformation.GetTaxIdType(), Value = taxId }]
|
||||
: null
|
||||
TaxIdData = taxIdData
|
||||
};
|
||||
|
||||
var (type, token) = paymentSource;
|
||||
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
logger.LogError(
|
||||
"Cannot create customer for organization ({OrganizationID}) without a payment source token",
|
||||
organization.Id);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
var (type, token) = customerSetup.TokenizedPaymentSource;
|
||||
|
||||
var braintreeCustomerId = "";
|
||||
|
||||
@ -270,31 +259,30 @@ public class OrganizationBillingService(
|
||||
}
|
||||
|
||||
private async Task<Subscription> CreateSubscriptionAsync(
|
||||
Customer customer,
|
||||
Guid organizationId,
|
||||
OrganizationPasswordManagerSubscriptionPurchase passwordManager,
|
||||
PlanType planType,
|
||||
OrganizationSecretsManagerSubscriptionPurchase secretsManager)
|
||||
Customer customer,
|
||||
SubscriptionSetup subscriptionSetup)
|
||||
{
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
var plan = subscriptionSetup.Plan;
|
||||
|
||||
if (passwordManager == null)
|
||||
{
|
||||
logger.LogError("Cannot create subscription for organization ({OrganizationID}) without password manager purchase information", organizationId);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
var passwordManagerOptions = subscriptionSetup.PasswordManagerOptions;
|
||||
|
||||
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Price = plan.PasswordManager.StripeSeatPlanId,
|
||||
Quantity = passwordManager.Seats
|
||||
}
|
||||
plan.HasNonSeatBasedPasswordManagerPlan()
|
||||
? new SubscriptionItemOptions
|
||||
{
|
||||
Price = plan.PasswordManager.StripePlanId,
|
||||
Quantity = 1
|
||||
}
|
||||
: new SubscriptionItemOptions
|
||||
{
|
||||
Price = plan.PasswordManager.StripeSeatPlanId,
|
||||
Quantity = passwordManagerOptions.Seats
|
||||
}
|
||||
};
|
||||
|
||||
if (passwordManager.PremiumAccess)
|
||||
if (passwordManagerOptions.PremiumAccess is true)
|
||||
{
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
@ -303,29 +291,31 @@ public class OrganizationBillingService(
|
||||
});
|
||||
}
|
||||
|
||||
if (passwordManager.Storage > 0)
|
||||
if (passwordManagerOptions.Storage is > 0)
|
||||
{
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = plan.PasswordManager.StripeStoragePlanId,
|
||||
Quantity = passwordManager.Storage
|
||||
Quantity = passwordManagerOptions.Storage
|
||||
});
|
||||
}
|
||||
|
||||
if (secretsManager != null)
|
||||
var secretsManagerOptions = subscriptionSetup.SecretsManagerOptions;
|
||||
|
||||
if (secretsManagerOptions != null)
|
||||
{
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = plan.SecretsManager.StripeSeatPlanId,
|
||||
Quantity = secretsManager.Seats
|
||||
Quantity = secretsManagerOptions.Seats
|
||||
});
|
||||
|
||||
if (secretsManager.ServiceAccounts > 0)
|
||||
if (secretsManagerOptions.ServiceAccounts is > 0)
|
||||
{
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = plan.SecretsManager.StripeServiceAccountPlanId,
|
||||
Quantity = secretsManager.ServiceAccounts
|
||||
Quantity = secretsManagerOptions.ServiceAccounts
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -347,15 +337,7 @@ public class OrganizationBillingService(
|
||||
TrialPeriodDays = plan.TrialPeriodDays,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await stripeAdapter.CustomerDeleteAsync(customer.Id);
|
||||
throw;
|
||||
}
|
||||
return await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
}
|
||||
|
||||
private static bool IsOnSecretsManagerStandalone(
|
||||
|
Reference in New Issue
Block a user