From c66879eb8947a89ae72acc2f6862c8885aaf7c69 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Fri, 27 Sep 2024 19:39:44 +0100 Subject: [PATCH] [PM-8445] Update trial initiation UI (#4712) * Add the feature flag Signed-off-by: Cy Okeke * Initial comment Signed-off-by: Cy Okeke * changes to subscribe with payment method Signed-off-by: Cy Okeke * Add new objects * Implementation for subscription without payment method Signed-off-by: Cy Okeke * Remove unused codes and classes Signed-off-by: Cy Okeke * Rename the flag properly Signed-off-by: Cy Okeke * remove implementation that is no longer needed Signed-off-by: Cy Okeke * revert the changes on some code removal Signed-off-by: Cy Okeke * Resolve the pr comment Signed-off-by: Cy Okeke * format the data annotations line breaks Signed-off-by: Cy Okeke --------- Signed-off-by: Cy Okeke --- .../Controllers/OrganizationsController.cs | 15 +++ .../OrganizationCreateRequestModel.cs | 25 ++++ .../OrganizationNoPaymentCreateRequest.cs | 116 ++++++++++++++++++ .../Implementations/OrganizationService.cs | 18 ++- src/Core/Constants.cs | 1 + src/Core/Services/IPaymentService.cs | 3 + .../Implementations/StripePaymentService.cs | 71 +++++++++++ 7 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 0715a3652..e5dbcd10b 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -171,6 +171,21 @@ public class OrganizationsController : Controller return new OrganizationResponseModel(result.Item1); } + [HttpPost("create-without-payment")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task CreateWithoutPaymentAsync([FromBody] OrganizationNoPaymentCreateRequest model) + { + var user = await _userService.GetUserByPrincipalAsync(User); + if (user == null) + { + throw new UnauthorizedAccessException(); + } + + var organizationSignup = model.ToOrganizationSignup(user); + var result = await _organizationService.SignUpAsync(organizationSignup); + return new OrganizationResponseModel(result.Item1); + } + [HttpPut("{id}")] [HttpPost("{id}")] public async Task Put(string id, [FromBody] OrganizationUpdateRequestModel model) diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs index 6f5e39b7d..539260a31 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs @@ -14,42 +14,63 @@ public class OrganizationCreateRequestModel : IValidatableObject [StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")] [JsonConverter(typeof(HtmlEncodingStringConverter))] public string Name { get; set; } + [StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")] [JsonConverter(typeof(HtmlEncodingStringConverter))] public string BusinessName { get; set; } + [Required] [StringLength(256)] [EmailAddress] public string BillingEmail { get; set; } + public PlanType PlanType { get; set; } + [Required] public string Key { get; set; } + public OrganizationKeysRequestModel Keys { get; set; } public PaymentMethodType? PaymentMethodType { get; set; } public string PaymentToken { get; set; } + [Range(0, int.MaxValue)] public int AdditionalSeats { get; set; } + [Range(0, 99)] public short? AdditionalStorageGb { get; set; } + public bool PremiumAccessAddon { get; set; } + [EncryptedString] [EncryptedStringLength(1000)] public string CollectionName { get; set; } + public string TaxIdNumber { get; set; } + public string BillingAddressLine1 { get; set; } + public string BillingAddressLine2 { get; set; } + public string BillingAddressCity { get; set; } + public string BillingAddressState { get; set; } + public string BillingAddressPostalCode { get; set; } + [StringLength(2)] public string BillingAddressCountry { get; set; } + public int? MaxAutoscaleSeats { get; set; } + [Range(0, int.MaxValue)] public int? AdditionalSmSeats { get; set; } + [Range(0, int.MaxValue)] public int? AdditionalServiceAccounts { get; set; } + [Required] public bool UseSecretsManager { get; set; } + public bool IsFromSecretsManagerTrial { get; set; } public string InitiationPath { get; set; } @@ -99,16 +120,19 @@ public class OrganizationCreateRequestModel : IValidatableObject { yield return new ValidationResult("Payment required.", new string[] { nameof(PaymentToken) }); } + if (PlanType != PlanType.Free && !PaymentMethodType.HasValue) { yield return new ValidationResult("Payment method type required.", new string[] { nameof(PaymentMethodType) }); } + if (PlanType != PlanType.Free && string.IsNullOrWhiteSpace(BillingAddressCountry)) { yield return new ValidationResult("Country required.", new string[] { nameof(BillingAddressCountry) }); } + if (PlanType != PlanType.Free && BillingAddressCountry == "US" && string.IsNullOrWhiteSpace(BillingAddressPostalCode)) { @@ -117,3 +141,4 @@ public class OrganizationCreateRequestModel : IValidatableObject } } } + diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs new file mode 100644 index 000000000..3255c8b41 --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationNoPaymentCreateRequest.cs @@ -0,0 +1,116 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Business; +using Bit.Core.Utilities; + +namespace Bit.Api.AdminConsole.Models.Request.Organizations; + +public class OrganizationNoPaymentCreateRequest +{ + [Required] + [StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")] + [JsonConverter(typeof(HtmlEncodingStringConverter))] + public string Name { get; set; } + + [StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")] + [JsonConverter(typeof(HtmlEncodingStringConverter))] + public string BusinessName { get; set; } + + [Required] + [StringLength(256)] + [EmailAddress] + public string BillingEmail { get; set; } + + public PlanType PlanType { get; set; } + + [Required] + public string Key { get; set; } + + public OrganizationKeysRequestModel Keys { get; set; } + public PaymentMethodType? PaymentMethodType { get; set; } + public string PaymentToken { get; set; } + + [Range(0, int.MaxValue)] + public int AdditionalSeats { get; set; } + + [Range(0, 99)] + public short? AdditionalStorageGb { get; set; } + + public bool PremiumAccessAddon { get; set; } + + [EncryptedString] + [EncryptedStringLength(1000)] + public string CollectionName { get; set; } + + public string TaxIdNumber { get; set; } + + public string BillingAddressLine1 { get; set; } + + public string BillingAddressLine2 { get; set; } + + public string BillingAddressCity { get; set; } + + public string BillingAddressState { get; set; } + + public string BillingAddressPostalCode { get; set; } + + [StringLength(2)] + public string BillingAddressCountry { get; set; } + + public int? MaxAutoscaleSeats { get; set; } + + [Range(0, int.MaxValue)] + public int? AdditionalSmSeats { get; set; } + + [Range(0, int.MaxValue)] + public int? AdditionalServiceAccounts { get; set; } + + [Required] + public bool UseSecretsManager { get; set; } + + public bool IsFromSecretsManagerTrial { get; set; } + + public string InitiationPath { get; set; } + + public virtual OrganizationSignup ToOrganizationSignup(User user) + { + var orgSignup = new OrganizationSignup + { + Owner = user, + OwnerKey = Key, + Name = Name, + Plan = PlanType, + PaymentMethodType = PaymentMethodType, + PaymentToken = PaymentToken, + AdditionalSeats = AdditionalSeats, + MaxAutoscaleSeats = MaxAutoscaleSeats, + AdditionalStorageGb = AdditionalStorageGb.GetValueOrDefault(0), + PremiumAccessAddon = PremiumAccessAddon, + BillingEmail = BillingEmail, + BusinessName = BusinessName, + CollectionName = CollectionName, + AdditionalSmSeats = AdditionalSmSeats.GetValueOrDefault(), + AdditionalServiceAccounts = AdditionalServiceAccounts.GetValueOrDefault(), + UseSecretsManager = UseSecretsManager, + IsFromSecretsManagerTrial = IsFromSecretsManagerTrial, + TaxInfo = new TaxInfo + { + TaxIdNumber = TaxIdNumber, + BillingAddressLine1 = BillingAddressLine1, + BillingAddressLine2 = BillingAddressLine2, + BillingAddressCity = BillingAddressCity, + BillingAddressState = BillingAddressState, + BillingAddressPostalCode = BillingAddressPostalCode, + BillingAddressCountry = BillingAddressCountry, + }, + InitiationPath = InitiationPath, + }; + + Keys?.ToOrganizationSignup(orgSignup); + + return orgSignup; + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 3bf69cc07..6a0855c4e 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -590,10 +590,20 @@ public class OrganizationService : IOrganizationService } else { - await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value, - signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, - signup.PremiumAccessAddon, signup.TaxInfo, signup.IsFromProvider, signup.AdditionalSmSeats.GetValueOrDefault(), - signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial); + if (signup.PaymentMethodType != null) + { + await _paymentService.PurchaseOrganizationAsync(organization, signup.PaymentMethodType.Value, + signup.PaymentToken, plan, signup.AdditionalStorageGb, signup.AdditionalSeats, + signup.PremiumAccessAddon, signup.TaxInfo, signup.IsFromProvider, signup.AdditionalSmSeats.GetValueOrDefault(), + signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial); + } + else + { + await _paymentService.PurchaseOrganizationNoPaymentMethod(organization, plan, signup.AdditionalSeats, + signup.PremiumAccessAddon, signup.AdditionalSmSeats.GetValueOrDefault(), + signup.AdditionalServiceAccounts.GetValueOrDefault(), signup.IsFromSecretsManagerTrial); + } + } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 50e66386d..65c83da50 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -142,6 +142,7 @@ public static class FeatureFlagKeys public const string CipherKeyEncryption = "cipher-key-encryption"; public const string EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill"; public const string StorageReseedRefactor = "storage-reseed-refactor"; + public const string TrialPayment = "PM-8163-trial-payment"; public static List GetAllKeys() { diff --git a/src/Core/Services/IPaymentService.cs b/src/Core/Services/IPaymentService.cs index bee69f9c6..bf9d04702 100644 --- a/src/Core/Services/IPaymentService.cs +++ b/src/Core/Services/IPaymentService.cs @@ -15,6 +15,9 @@ public interface IPaymentService string paymentToken, Plan plan, short additionalStorageGb, int additionalSeats, bool premiumAccessAddon, TaxInfo taxInfo, bool provider = false, int additionalSmSeats = 0, int additionalServiceAccount = 0, bool signupIsFromSecretsManagerTrial = false); + Task PurchaseOrganizationNoPaymentMethod(Organization org, Plan plan, int additionalSeats, + bool premiumAccessAddon, int additionalSmSeats = 0, int additionalServiceAccount = 0, + bool signupIsFromSecretsManagerTrial = false); Task SponsorOrganizationAsync(Organization org, OrganizationSponsorship sponsorship); Task RemoveOrganizationSponsorshipAsync(Organization org, OrganizationSponsorship sponsorship); Task UpgradeFreeOrganizationAsync(Organization org, Plan plan, OrganizationUpgrade upgrade); diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index dd5adbda2..b31719a96 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -207,6 +207,77 @@ public class StripePaymentService : IPaymentService } } + public async Task PurchaseOrganizationNoPaymentMethod(Organization org, StaticStore.Plan plan, int additionalSeats, bool premiumAccessAddon, + int additionalSmSeats = 0, int additionalServiceAccount = 0, bool signupIsFromSecretsManagerTrial = false) + { + + var stripeCustomerMetadata = new Dictionary + { + { "region", _globalSettings.BaseServiceUri.CloudRegion } + }; + var subCreateOptions = new OrganizationPurchaseSubscriptionOptions(org, plan, new TaxInfo(), additionalSeats, 0, premiumAccessAddon + , additionalSmSeats, additionalServiceAccount); + + Customer customer = null; + Subscription subscription; + try + { + var customerCreateOptions = new CustomerCreateOptions + { + Description = org.DisplayBusinessName(), + Email = org.BillingEmail, + Metadata = stripeCustomerMetadata, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = + [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = org.SubscriberType(), + Value = GetFirstThirtyCharacters(org.SubscriberName()), + } + ], + }, + Coupon = signupIsFromSecretsManagerTrial + ? SecretsManagerStandaloneDiscountId + : null, + TaxIdData = null, + }; + + customer = await _stripeAdapter.CustomerCreateAsync(customerCreateOptions); + subCreateOptions.AddExpand("latest_invoice.payment_intent"); + subCreateOptions.Customer = customer.Id; + + subscription = await _stripeAdapter.SubscriptionCreateAsync(subCreateOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating customer, walking back operation."); + if (customer != null) + { + await _stripeAdapter.CustomerDeleteAsync(customer.Id); + } + + throw; + } + + org.Gateway = GatewayType.Stripe; + org.GatewayCustomerId = customer.Id; + org.GatewaySubscriptionId = subscription.Id; + + if (subscription.Status == "incomplete" && + subscription.LatestInvoice?.PaymentIntent?.Status == "requires_action") + { + org.Enabled = false; + return subscription.LatestInvoice.PaymentIntent.ClientSecret; + } + + org.Enabled = true; + org.ExpirationDate = subscription.CurrentPeriodEnd; + return null; + + } + private async Task ChangeOrganizationSponsorship( Organization org, OrganizationSponsorship sponsorship,