1
0
mirror of https://github.com/bitwarden/server synced 2026-01-09 03:53:42 +00: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:
Alex Morask
2024-09-11 09:04:15 -04:00
committed by GitHub
parent f2180aa7b7
commit 68b421fa2b
20 changed files with 340 additions and 203 deletions

View File

@@ -1,27 +0,0 @@
using Bit.Core.Billing.Enums;
namespace Bit.Core.Billing.Models;
public record OrganizationSubscriptionPurchase(
OrganizationSubscriptionPurchaseMetadata Metadata,
OrganizationPasswordManagerSubscriptionPurchase PasswordManagerSubscription,
TokenizedPaymentSource PaymentSource,
PlanType PlanType,
OrganizationSecretsManagerSubscriptionPurchase SecretsManagerSubscription,
TaxInformation TaxInformation);
public record OrganizationPasswordManagerSubscriptionPurchase(
int Storage,
bool PremiumAccess,
int Seats);
public record OrganizationSecretsManagerSubscriptionPurchase(
int Seats,
int ServiceAccounts);
public record OrganizationSubscriptionPurchaseMetadata(
bool FromProvider,
bool FromSecretsManagerStandalone)
{
public static OrganizationSubscriptionPurchaseMetadata Default => new(false, false);
}

View File

@@ -0,0 +1,10 @@
namespace Bit.Core.Billing.Models.Sales;
#nullable enable
public class CustomerSetup
{
public required TokenizedPaymentSource TokenizedPaymentSource { get; set; }
public required TaxInformation TaxInformation { get; set; }
public string? Coupon { get; set; }
}

View File

@@ -0,0 +1,104 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Constants;
using Bit.Core.Models.Business;
namespace Bit.Core.Billing.Models.Sales;
#nullable enable
public class OrganizationSale
{
private OrganizationSale() { }
public void Deconstruct(
out Organization organization,
out CustomerSetup? customerSetup,
out SubscriptionSetup subscriptionSetup)
{
organization = Organization;
customerSetup = CustomerSetup;
subscriptionSetup = SubscriptionSetup;
}
public required Organization Organization { get; init; }
public CustomerSetup? CustomerSetup { get; init; }
public required SubscriptionSetup SubscriptionSetup { get; init; }
public static OrganizationSale From(
Organization organization,
OrganizationSignup signup) => new()
{
Organization = organization,
CustomerSetup = string.IsNullOrEmpty(organization.GatewayCustomerId) ? GetCustomerSetup(signup) : null,
SubscriptionSetup = GetSubscriptionSetup(signup)
};
public static OrganizationSale From(
Organization organization,
OrganizationUpgrade upgrade) => new()
{
Organization = organization,
SubscriptionSetup = GetSubscriptionSetup(upgrade)
};
private static CustomerSetup? GetCustomerSetup(OrganizationSignup signup)
{
if (!signup.PaymentMethodType.HasValue)
{
return null;
}
var tokenizedPaymentSource = new TokenizedPaymentSource(
signup.PaymentMethodType!.Value,
signup.PaymentToken);
var taxInformation = new TaxInformation(
signup.TaxInfo.BillingAddressCountry,
signup.TaxInfo.BillingAddressPostalCode,
signup.TaxInfo.TaxIdNumber,
signup.TaxInfo.BillingAddressLine1,
signup.TaxInfo.BillingAddressLine2,
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
};
}
private static SubscriptionSetup GetSubscriptionSetup(OrganizationUpgrade upgrade)
{
var plan = Core.Utilities.StaticStore.GetPlan(upgrade.Plan);
var passwordManagerOptions = new SubscriptionSetup.PasswordManager
{
Seats = upgrade.AdditionalSeats,
Storage = upgrade.AdditionalStorageGb,
PremiumAccess = upgrade.PremiumAccessAddon
};
var secretsManagerOptions = upgrade.UseSecretsManager
? new SubscriptionSetup.SecretsManager
{
Seats = upgrade.AdditionalSmSeats ?? 0,
ServiceAccounts = upgrade.AdditionalServiceAccounts
}
: null;
return new SubscriptionSetup
{
Plan = plan,
PasswordManagerOptions = passwordManagerOptions,
SecretsManagerOptions = secretsManagerOptions
};
}
}

View File

@@ -0,0 +1,25 @@
using Bit.Core.Models.StaticStore;
namespace Bit.Core.Billing.Models.Sales;
#nullable enable
public class SubscriptionSetup
{
public required Plan Plan { get; set; }
public required PasswordManager PasswordManagerOptions { get; set; }
public SecretsManager? SecretsManagerOptions { get; set; }
public class PasswordManager
{
public required int Seats { get; set; }
public short? Storage { get; set; }
public bool? PremiumAccess { get; set; }
}
public class SecretsManager
{
public required int Seats { get; set; }
public int? ServiceAccounts { get; set; }
}
}

View File

@@ -34,6 +34,9 @@ public abstract record Plan
public SecretsManagerPlanFeatures SecretsManager { get; protected init; }
public bool SupportsSecretsManager => SecretsManager != null;
public bool HasNonSeatBasedPasswordManagerPlan() =>
PasswordManager is { StripePlanId: not null and not "", StripeSeatPlanId: null or "" };
public record SecretsManagerPlanFeatures
{
// Service accounts

View File

@@ -1,4 +1,6 @@
namespace Bit.Core.Billing.Models;
using Stripe;
namespace Bit.Core.Billing.Models;
public record TaxInformation(
string Country,
@@ -9,6 +11,25 @@ public record TaxInformation(
string City,
string State)
{
public (AddressOptions, List<CustomerTaxIdDataOptions>) GetStripeOptions()
{
var address = new AddressOptions
{
Country = Country,
PostalCode = PostalCode,
Line1 = Line1,
Line2 = Line2,
City = City,
State = State
};
var customerTaxIdDataOptionsList = !string.IsNullOrEmpty(TaxId)
? new List<CustomerTaxIdDataOptions> { new() { Type = GetTaxIdType(), Value = TaxId } }
: null;
return (address, customerTaxIdDataOptionsList);
}
public string GetTaxIdType()
{
if (string.IsNullOrEmpty(Country) || string.IsNullOrEmpty(TaxId))