From ff4b3eb9e5896f74aa4499da7378d66a90b314f6 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 23 Oct 2025 14:47:23 -0400 Subject: [PATCH] [PM-27123] Account Credit not Showing for Premium Upgrade Payment (#6484) * feat(billing): add PaymentMethod union * feat(billing): add nontokenized payment method * feat(billing): add validation for tokinized and nontokenized payments * feat(billing): update and add payment method requests * feat(billing): update command with new union object * test(billing): add tests for account credit for user. * feat(billing): update premium cloud hosted subscription request * fix(billing): dotnet format * tests(billing): include payment method tests * fix(billing): clean up tests and converter method --- ...zedPaymentMethodTypeValidationAttribute.cs | 13 ++ ...edPaymentMethodTypeValidationAttribute.cs} | 4 +- .../MinimalTokenizedPaymentMethodRequest.cs | 2 +- .../NonTokenizedPaymentMethodRequest.cs | 21 ++++ .../PremiumCloudHostedSubscriptionRequest.cs | 37 +++++- .../Models/NonTokenizedPaymentMethod.cs | 11 ++ .../Billing/Payment/Models/PaymentMethod.cs | 69 +++++++++++ ...tePremiumCloudHostedSubscriptionCommand.cs | 81 ++++++++----- .../Payment/Models/PaymentMethodTests.cs | 112 ++++++++++++++++++ ...miumCloudHostedSubscriptionCommandTests.cs | 78 +++++++++++- 10 files changed, 391 insertions(+), 37 deletions(-) create mode 100644 src/Api/Billing/Attributes/NonTokenizedPaymentMethodTypeValidationAttribute.cs rename src/Api/Billing/Attributes/{PaymentMethodTypeValidationAttribute.cs => TokenizedPaymentMethodTypeValidationAttribute.cs} (62%) create mode 100644 src/Api/Billing/Models/Requests/Payment/NonTokenizedPaymentMethodRequest.cs create mode 100644 src/Core/Billing/Payment/Models/NonTokenizedPaymentMethod.cs create mode 100644 src/Core/Billing/Payment/Models/PaymentMethod.cs create mode 100644 test/Core.Test/Billing/Payment/Models/PaymentMethodTests.cs diff --git a/src/Api/Billing/Attributes/NonTokenizedPaymentMethodTypeValidationAttribute.cs b/src/Api/Billing/Attributes/NonTokenizedPaymentMethodTypeValidationAttribute.cs new file mode 100644 index 0000000000..7a906d4838 --- /dev/null +++ b/src/Api/Billing/Attributes/NonTokenizedPaymentMethodTypeValidationAttribute.cs @@ -0,0 +1,13 @@ +using Bit.Api.Utilities; + +namespace Bit.Api.Billing.Attributes; + +public class NonTokenizedPaymentMethodTypeValidationAttribute : StringMatchesAttribute +{ + private static readonly string[] _acceptedValues = ["accountCredit"]; + + public NonTokenizedPaymentMethodTypeValidationAttribute() : base(_acceptedValues) + { + ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}"; + } +} diff --git a/src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs b/src/Api/Billing/Attributes/TokenizedPaymentMethodTypeValidationAttribute.cs similarity index 62% rename from src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs rename to src/Api/Billing/Attributes/TokenizedPaymentMethodTypeValidationAttribute.cs index 227b454f9f..51e40e9999 100644 --- a/src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs +++ b/src/Api/Billing/Attributes/TokenizedPaymentMethodTypeValidationAttribute.cs @@ -2,11 +2,11 @@ namespace Bit.Api.Billing.Attributes; -public class PaymentMethodTypeValidationAttribute : StringMatchesAttribute +public class TokenizedPaymentMethodTypeValidationAttribute : StringMatchesAttribute { private static readonly string[] _acceptedValues = ["bankAccount", "card", "payPal"]; - public PaymentMethodTypeValidationAttribute() : base(_acceptedValues) + public TokenizedPaymentMethodTypeValidationAttribute() : base(_acceptedValues) { ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}"; } diff --git a/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs index b0e415c262..1311805ad4 100644 --- a/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs @@ -7,7 +7,7 @@ namespace Bit.Api.Billing.Models.Requests.Payment; public class MinimalTokenizedPaymentMethodRequest { [Required] - [PaymentMethodTypeValidation] + [TokenizedPaymentMethodTypeValidation] public required string Type { get; set; } [Required] diff --git a/src/Api/Billing/Models/Requests/Payment/NonTokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/NonTokenizedPaymentMethodRequest.cs new file mode 100644 index 0000000000..d15bc73778 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/NonTokenizedPaymentMethodRequest.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Attributes; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public class NonTokenizedPaymentMethodRequest +{ + [Required] + [NonTokenizedPaymentMethodTypeValidation] + public required string Type { get; set; } + + public NonTokenizedPaymentMethod ToDomain() + { + return Type switch + { + "accountCredit" => new NonTokenizedPaymentMethod { Type = NonTokenizablePaymentMethodType.AccountCredit }, + _ => throw new InvalidOperationException($"Invalid value for {nameof(NonTokenizedPaymentMethod)}.{nameof(NonTokenizedPaymentMethod.Type)}") + }; + } +} diff --git a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs index 03f20ec9c1..0f9198fdad 100644 --- a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs +++ b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs @@ -4,10 +4,10 @@ using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Premium; -public class PremiumCloudHostedSubscriptionRequest +public class PremiumCloudHostedSubscriptionRequest : IValidatableObject { - [Required] - public required MinimalTokenizedPaymentMethodRequest TokenizedPaymentMethod { get; set; } + public MinimalTokenizedPaymentMethodRequest? TokenizedPaymentMethod { get; set; } + public NonTokenizedPaymentMethodRequest? NonTokenizedPaymentMethod { get; set; } [Required] public required MinimalBillingAddressRequest BillingAddress { get; set; } @@ -15,11 +15,38 @@ public class PremiumCloudHostedSubscriptionRequest [Range(0, 99)] public short AdditionalStorageGb { get; set; } = 0; - public (TokenizedPaymentMethod, BillingAddress, short) ToDomain() + + public (PaymentMethod, BillingAddress, short) ToDomain() { - var paymentMethod = TokenizedPaymentMethod.ToDomain(); + // Check if TokenizedPaymentMethod or NonTokenizedPaymentMethod is provided. + var tokenizedPaymentMethod = TokenizedPaymentMethod?.ToDomain(); + var nonTokenizedPaymentMethod = NonTokenizedPaymentMethod?.ToDomain(); + + PaymentMethod paymentMethod = tokenizedPaymentMethod != null + ? tokenizedPaymentMethod + : nonTokenizedPaymentMethod!; + var billingAddress = BillingAddress.ToDomain(); return (paymentMethod, billingAddress, AdditionalStorageGb); } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (TokenizedPaymentMethod == null && NonTokenizedPaymentMethod == null) + { + yield return new ValidationResult( + "Either TokenizedPaymentMethod or NonTokenizedPaymentMethod must be provided.", + new[] { nameof(TokenizedPaymentMethod), nameof(NonTokenizedPaymentMethod) } + ); + } + + if (TokenizedPaymentMethod != null && NonTokenizedPaymentMethod != null) + { + yield return new ValidationResult( + "Only one of TokenizedPaymentMethod or NonTokenizedPaymentMethod can be provided.", + new[] { nameof(TokenizedPaymentMethod), nameof(NonTokenizedPaymentMethod) } + ); + } + } } diff --git a/src/Core/Billing/Payment/Models/NonTokenizedPaymentMethod.cs b/src/Core/Billing/Payment/Models/NonTokenizedPaymentMethod.cs new file mode 100644 index 0000000000..5e8ec0484c --- /dev/null +++ b/src/Core/Billing/Payment/Models/NonTokenizedPaymentMethod.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Billing.Payment.Models; + +public record NonTokenizedPaymentMethod +{ + public NonTokenizablePaymentMethodType Type { get; set; } +} + +public enum NonTokenizablePaymentMethodType +{ + AccountCredit, +} diff --git a/src/Core/Billing/Payment/Models/PaymentMethod.cs b/src/Core/Billing/Payment/Models/PaymentMethod.cs new file mode 100644 index 0000000000..a6835f9a32 --- /dev/null +++ b/src/Core/Billing/Payment/Models/PaymentMethod.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using OneOf; + +namespace Bit.Core.Billing.Payment.Models; + +[JsonConverter(typeof(PaymentMethodJsonConverter))] +public class PaymentMethod(OneOf input) + : OneOfBase(input) +{ + public static implicit operator PaymentMethod(TokenizedPaymentMethod tokenized) => new(tokenized); + public static implicit operator PaymentMethod(NonTokenizedPaymentMethod nonTokenized) => new(nonTokenized); + public bool IsTokenized => IsT0; + public bool IsNonTokenized => IsT1; +} + +internal class PaymentMethodJsonConverter : JsonConverter +{ + public override PaymentMethod Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var element = JsonElement.ParseValue(ref reader); + + if (!element.TryGetProperty("type", out var typeProperty)) + { + throw new JsonException("PaymentMethod requires a 'type' property"); + } + + var type = typeProperty.GetString(); + + + if (Enum.TryParse(type, true, out var tokenizedType) && + Enum.IsDefined(typeof(TokenizablePaymentMethodType), tokenizedType)) + { + var token = element.TryGetProperty("token", out var tokenProperty) ? tokenProperty.GetString() : null; + if (string.IsNullOrEmpty(token)) + { + throw new JsonException("TokenizedPaymentMethod requires a 'token' property"); + } + + return new TokenizedPaymentMethod { Type = tokenizedType, Token = token }; + } + + if (Enum.TryParse(type, true, out var nonTokenizedType) && + Enum.IsDefined(typeof(NonTokenizablePaymentMethodType), nonTokenizedType)) + { + return new NonTokenizedPaymentMethod { Type = nonTokenizedType }; + } + + throw new JsonException($"Unknown payment method type: {type}"); + } + + public override void Write(Utf8JsonWriter writer, PaymentMethod value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + value.Switch( + tokenized => + { + writer.WriteString("type", + tokenized.Type.ToString().ToLowerInvariant() + ); + writer.WriteString("token", tokenized.Token); + }, + nonTokenized => { writer.WriteString("type", nonTokenized.Type.ToString().ToLowerInvariant()); } + ); + + writer.WriteEndObject(); + } +} diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index fa01acabda..3b2ac5343f 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -16,6 +16,7 @@ using Microsoft.Extensions.Logging; using OneOf.Types; using Stripe; using Customer = Stripe.Customer; +using PaymentMethod = Bit.Core.Billing.Payment.Models.PaymentMethod; using Subscription = Stripe.Subscription; namespace Bit.Core.Billing.Premium.Commands; @@ -38,7 +39,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand /// A billing command result indicating success or failure with appropriate error details. Task> Run( User user, - TokenizedPaymentMethod paymentMethod, + PaymentMethod paymentMethod, BillingAddress billingAddress, short additionalStorageGb); } @@ -60,7 +61,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( public Task> Run( User user, - TokenizedPaymentMethod paymentMethod, + PaymentMethod paymentMethod, BillingAddress billingAddress, short additionalStorageGb) => HandleAsync(async () => { @@ -74,6 +75,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( return new BadRequest("Additional storage must be greater than 0."); } + // Note: A customer will already exist if the customer has purchased account credits. var customer = string.IsNullOrEmpty(user.GatewayCustomerId) ? await CreateCustomerAsync(user, paymentMethod, billingAddress) : await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); @@ -82,18 +84,31 @@ public class CreatePremiumCloudHostedSubscriptionCommand( var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null); - switch (paymentMethod) - { - case { Type: TokenizablePaymentMethodType.PayPal } - when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete: - case { Type: not TokenizablePaymentMethodType.PayPal } - when subscription.Status == StripeConstants.SubscriptionStatus.Active: + paymentMethod.Switch( + tokenized => + { + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault + switch (tokenized) + { + case { Type: TokenizablePaymentMethodType.PayPal } + when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete: + case { Type: not TokenizablePaymentMethodType.PayPal } + when subscription.Status == StripeConstants.SubscriptionStatus.Active: + { + user.Premium = true; + user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); + break; + } + } + }, + nonTokenized => + { + if (subscription.Status == StripeConstants.SubscriptionStatus.Active) { user.Premium = true; user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); - break; } - } + }); user.Gateway = GatewayType.Stripe; user.GatewayCustomerId = customer.Id; @@ -109,9 +124,15 @@ public class CreatePremiumCloudHostedSubscriptionCommand( }); private async Task CreateCustomerAsync(User user, - TokenizedPaymentMethod paymentMethod, + PaymentMethod paymentMethod, BillingAddress billingAddress) { + if (paymentMethod.IsNonTokenized) + { + _logger.LogError("Cannot create customer for user ({UserID}) using non-tokenized payment method. The customer should already exist", user.Id); + throw new BillingException(); + } + var subscriberName = user.SubscriberName(); var customerCreateOptions = new CustomerCreateOptions { @@ -153,13 +174,14 @@ public class CreatePremiumCloudHostedSubscriptionCommand( var braintreeCustomerId = ""; + // We have checked that the payment method is tokenized, so we can safely cast it. // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault - switch (paymentMethod.Type) + switch (paymentMethod.AsT0.Type) { case TokenizablePaymentMethodType.BankAccount: { var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.Token })) + (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.AsT0.Token })) .FirstOrDefault(); if (setupIntent == null) @@ -173,19 +195,19 @@ public class CreatePremiumCloudHostedSubscriptionCommand( } case TokenizablePaymentMethodType.Card: { - customerCreateOptions.PaymentMethod = paymentMethod.Token; - customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token; + customerCreateOptions.PaymentMethod = paymentMethod.AsT0.Token; + customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.AsT0.Token; break; } case TokenizablePaymentMethodType.PayPal: { - braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.Token); + braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.AsT0.Token); customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; break; } default: { - _logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.Type.ToString()); + _logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.AsT0.Type.ToString()); throw new BillingException(); } } @@ -203,18 +225,21 @@ public class CreatePremiumCloudHostedSubscriptionCommand( async Task Revert() { // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault - switch (paymentMethod.Type) + if (paymentMethod.IsTokenized) { - case TokenizablePaymentMethodType.BankAccount: - { - await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id); - break; - } - case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): - { - await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); - break; - } + switch (paymentMethod.AsT0.Type) + { + case TokenizablePaymentMethodType.BankAccount: + { + await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id); + break; + } + case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): + { + await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); + break; + } + } } } } diff --git a/test/Core.Test/Billing/Payment/Models/PaymentMethodTests.cs b/test/Core.Test/Billing/Payment/Models/PaymentMethodTests.cs new file mode 100644 index 0000000000..e3953cd152 --- /dev/null +++ b/test/Core.Test/Billing/Payment/Models/PaymentMethodTests.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using Bit.Core.Billing.Payment.Models; +using Xunit; + +namespace Bit.Core.Test.Billing.Payment.Models; + +public class PaymentMethodTests +{ + [Theory] + [InlineData("{\"cardNumber\":\"1234\"}")] + [InlineData("{\"type\":\"unknown_type\",\"data\":\"value\"}")] + [InlineData("{\"type\":\"invalid\",\"token\":\"test-token\"}")] + [InlineData("{\"type\":\"invalid\"}")] + public void Read_ShouldThrowJsonException_OnInvalidOrMissingType(string json) + { + // Arrange + var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } }; + + // Act & Assert + Assert.Throws(() => JsonSerializer.Deserialize(json, options)); + } + + [Theory] + [InlineData("{\"type\":\"card\"}")] + [InlineData("{\"type\":\"card\",\"token\":\"\"}")] + [InlineData("{\"type\":\"card\",\"token\":null}")] + public void Read_ShouldThrowJsonException_OnInvalidTokenizedPaymentMethodToken(string json) + { + // Arrange + var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } }; + + // Act & Assert + Assert.Throws(() => JsonSerializer.Deserialize(json, options)); + } + + // Tokenized payment method deserialization + [Theory] + [InlineData("bankAccount", TokenizablePaymentMethodType.BankAccount)] + [InlineData("card", TokenizablePaymentMethodType.Card)] + [InlineData("payPal", TokenizablePaymentMethodType.PayPal)] + public void Read_ShouldDeserializeTokenizedPaymentMethods(string typeString, TokenizablePaymentMethodType expectedType) + { + // Arrange + var json = $"{{\"type\":\"{typeString}\",\"token\":\"test-token\"}}"; + var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } }; + + // Act + var result = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.True(result.IsTokenized); + Assert.Equal(expectedType, result.AsT0.Type); + Assert.Equal("test-token", result.AsT0.Token); + } + + // Non-tokenized payment method deserialization + [Theory] + [InlineData("accountcredit", NonTokenizablePaymentMethodType.AccountCredit)] + public void Read_ShouldDeserializeNonTokenizedPaymentMethods(string typeString, NonTokenizablePaymentMethodType expectedType) + { + // Arrange + var json = $"{{\"type\":\"{typeString}\"}}"; + var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } }; + + // Act + var result = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.True(result.IsNonTokenized); + Assert.Equal(expectedType, result.AsT1.Type); + } + + // Tokenized payment method serialization + [Theory] + [InlineData(TokenizablePaymentMethodType.BankAccount, "bankaccount")] + [InlineData(TokenizablePaymentMethodType.Card, "card")] + [InlineData(TokenizablePaymentMethodType.PayPal, "paypal")] + public void Write_ShouldSerializeTokenizedPaymentMethods(TokenizablePaymentMethodType type, string expectedTypeString) + { + // Arrange + var paymentMethod = new PaymentMethod(new TokenizedPaymentMethod + { + Type = type, + Token = "test-token" + }); + var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } }; + + // Act + var json = JsonSerializer.Serialize(paymentMethod, options); + + // Assert + Assert.Contains($"\"type\":\"{expectedTypeString}\"", json); + Assert.Contains("\"token\":\"test-token\"", json); + } + + // Non-tokenized payment method serialization + [Theory] + [InlineData(NonTokenizablePaymentMethodType.AccountCredit, "accountcredit")] + public void Write_ShouldSerializeNonTokenizedPaymentMethods(NonTokenizablePaymentMethodType type, string expectedTypeString) + { + // Arrange + var paymentMethod = new PaymentMethod(new NonTokenizedPaymentMethod { Type = type }); + var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } }; + + // Act + var json = JsonSerializer.Serialize(paymentMethod, options); + + // Assert + Assert.Contains($"\"type\":\"{expectedTypeString}\"", json); + Assert.DoesNotContain("token", json); + } +} diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index b6d497b7de..c0618f78ed 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.Billing.Caches; +using Bit.Core.Billing; +using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Models; @@ -567,4 +568,79 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var unhandled = result.AsT3; Assert.Equal("Something went wrong with your request. Please contact support for assistance.", unhandled.Response); } + + [Theory, BitAutoData] + public async Task Run_AccountCredit_WithExistingCustomer_Success( + User user, + NonTokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = "existing_customer_123"; + paymentMethod.Type = NonTokenizablePaymentMethodType.AccountCredit; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "existing_customer_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + mockSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; + + var mockInvoice = Substitute.For(); + + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any()); + Assert.True(user.Premium); + Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate); + } + + [Theory, BitAutoData] + public async Task Run_NonTokenizedPaymentWithoutExistingCustomer_ThrowsBillingException( + User user, + NonTokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + // No existing gateway customer ID + user.GatewayCustomerId = null; + paymentMethod.Type = NonTokenizablePaymentMethodType.AccountCredit; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + //Assert + Assert.True(result.IsT3); // Assuming T3 is the Unhandled result + Assert.IsType(result.AsT3.Exception); + // Verify no customer was created or subscription attempted + await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().SubscriptionCreateAsync(Arg.Any()); + await _userService.DidNotReceive().SaveUserAsync(Arg.Any()); + } }