mirror of
https://github.com/bitwarden/server
synced 2025-12-19 01:33:20 +00:00
[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
This commit is contained in:
@@ -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)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
namespace Bit.Api.Billing.Attributes;
|
namespace Bit.Api.Billing.Attributes;
|
||||||
|
|
||||||
public class PaymentMethodTypeValidationAttribute : StringMatchesAttribute
|
public class TokenizedPaymentMethodTypeValidationAttribute : StringMatchesAttribute
|
||||||
{
|
{
|
||||||
private static readonly string[] _acceptedValues = ["bankAccount", "card", "payPal"];
|
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)}";
|
ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}";
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ namespace Bit.Api.Billing.Models.Requests.Payment;
|
|||||||
public class MinimalTokenizedPaymentMethodRequest
|
public class MinimalTokenizedPaymentMethodRequest
|
||||||
{
|
{
|
||||||
[Required]
|
[Required]
|
||||||
[PaymentMethodTypeValidation]
|
[TokenizedPaymentMethodTypeValidation]
|
||||||
public required string Type { get; set; }
|
public required string Type { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
|
|||||||
@@ -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)}")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,10 @@ using Bit.Core.Billing.Payment.Models;
|
|||||||
|
|
||||||
namespace Bit.Api.Billing.Models.Requests.Premium;
|
namespace Bit.Api.Billing.Models.Requests.Premium;
|
||||||
|
|
||||||
public class PremiumCloudHostedSubscriptionRequest
|
public class PremiumCloudHostedSubscriptionRequest : IValidatableObject
|
||||||
{
|
{
|
||||||
[Required]
|
public MinimalTokenizedPaymentMethodRequest? TokenizedPaymentMethod { get; set; }
|
||||||
public required MinimalTokenizedPaymentMethodRequest TokenizedPaymentMethod { get; set; }
|
public NonTokenizedPaymentMethodRequest? NonTokenizedPaymentMethod { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public required MinimalBillingAddressRequest BillingAddress { get; set; }
|
public required MinimalBillingAddressRequest BillingAddress { get; set; }
|
||||||
@@ -15,11 +15,38 @@ public class PremiumCloudHostedSubscriptionRequest
|
|||||||
[Range(0, 99)]
|
[Range(0, 99)]
|
||||||
public short AdditionalStorageGb { get; set; } = 0;
|
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();
|
var billingAddress = BillingAddress.ToDomain();
|
||||||
|
|
||||||
return (paymentMethod, billingAddress, AdditionalStorageGb);
|
return (paymentMethod, billingAddress, AdditionalStorageGb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IEnumerable<ValidationResult> 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) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/Core/Billing/Payment/Models/NonTokenizedPaymentMethod.cs
Normal file
11
src/Core/Billing/Payment/Models/NonTokenizedPaymentMethod.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Bit.Core.Billing.Payment.Models;
|
||||||
|
|
||||||
|
public record NonTokenizedPaymentMethod
|
||||||
|
{
|
||||||
|
public NonTokenizablePaymentMethodType Type { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NonTokenizablePaymentMethodType
|
||||||
|
{
|
||||||
|
AccountCredit,
|
||||||
|
}
|
||||||
69
src/Core/Billing/Payment/Models/PaymentMethod.cs
Normal file
69
src/Core/Billing/Payment/Models/PaymentMethod.cs
Normal file
@@ -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<TokenizedPaymentMethod, NonTokenizedPaymentMethod> input)
|
||||||
|
: OneOfBase<TokenizedPaymentMethod, NonTokenizedPaymentMethod>(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<PaymentMethod>
|
||||||
|
{
|
||||||
|
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<TokenizablePaymentMethodType>(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<NonTokenizablePaymentMethodType>(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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using OneOf.Types;
|
using OneOf.Types;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
using Customer = Stripe.Customer;
|
using Customer = Stripe.Customer;
|
||||||
|
using PaymentMethod = Bit.Core.Billing.Payment.Models.PaymentMethod;
|
||||||
using Subscription = Stripe.Subscription;
|
using Subscription = Stripe.Subscription;
|
||||||
|
|
||||||
namespace Bit.Core.Billing.Premium.Commands;
|
namespace Bit.Core.Billing.Premium.Commands;
|
||||||
@@ -38,7 +39,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand
|
|||||||
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
|
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
|
||||||
Task<BillingCommandResult<None>> Run(
|
Task<BillingCommandResult<None>> Run(
|
||||||
User user,
|
User user,
|
||||||
TokenizedPaymentMethod paymentMethod,
|
PaymentMethod paymentMethod,
|
||||||
BillingAddress billingAddress,
|
BillingAddress billingAddress,
|
||||||
short additionalStorageGb);
|
short additionalStorageGb);
|
||||||
}
|
}
|
||||||
@@ -60,7 +61,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
|
|||||||
|
|
||||||
public Task<BillingCommandResult<None>> Run(
|
public Task<BillingCommandResult<None>> Run(
|
||||||
User user,
|
User user,
|
||||||
TokenizedPaymentMethod paymentMethod,
|
PaymentMethod paymentMethod,
|
||||||
BillingAddress billingAddress,
|
BillingAddress billingAddress,
|
||||||
short additionalStorageGb) => HandleAsync<None>(async () =>
|
short additionalStorageGb) => HandleAsync<None>(async () =>
|
||||||
{
|
{
|
||||||
@@ -74,6 +75,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
|
|||||||
return new BadRequest("Additional storage must be greater than 0.");
|
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)
|
var customer = string.IsNullOrEmpty(user.GatewayCustomerId)
|
||||||
? await CreateCustomerAsync(user, paymentMethod, billingAddress)
|
? await CreateCustomerAsync(user, paymentMethod, billingAddress)
|
||||||
: await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
|
: 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);
|
var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null);
|
||||||
|
|
||||||
switch (paymentMethod)
|
paymentMethod.Switch(
|
||||||
{
|
tokenized =>
|
||||||
case { Type: TokenizablePaymentMethodType.PayPal }
|
{
|
||||||
when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete:
|
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
|
||||||
case { Type: not TokenizablePaymentMethodType.PayPal }
|
switch (tokenized)
|
||||||
when subscription.Status == StripeConstants.SubscriptionStatus.Active:
|
{
|
||||||
|
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.Premium = true;
|
||||||
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
|
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
user.Gateway = GatewayType.Stripe;
|
user.Gateway = GatewayType.Stripe;
|
||||||
user.GatewayCustomerId = customer.Id;
|
user.GatewayCustomerId = customer.Id;
|
||||||
@@ -109,9 +124,15 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
|
|||||||
});
|
});
|
||||||
|
|
||||||
private async Task<Customer> CreateCustomerAsync(User user,
|
private async Task<Customer> CreateCustomerAsync(User user,
|
||||||
TokenizedPaymentMethod paymentMethod,
|
PaymentMethod paymentMethod,
|
||||||
BillingAddress billingAddress)
|
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 subscriberName = user.SubscriberName();
|
||||||
var customerCreateOptions = new CustomerCreateOptions
|
var customerCreateOptions = new CustomerCreateOptions
|
||||||
{
|
{
|
||||||
@@ -153,13 +174,14 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
|
|||||||
|
|
||||||
var braintreeCustomerId = "";
|
var braintreeCustomerId = "";
|
||||||
|
|
||||||
|
// We have checked that the payment method is tokenized, so we can safely cast it.
|
||||||
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
|
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
|
||||||
switch (paymentMethod.Type)
|
switch (paymentMethod.AsT0.Type)
|
||||||
{
|
{
|
||||||
case TokenizablePaymentMethodType.BankAccount:
|
case TokenizablePaymentMethodType.BankAccount:
|
||||||
{
|
{
|
||||||
var setupIntent =
|
var setupIntent =
|
||||||
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.Token }))
|
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.AsT0.Token }))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
if (setupIntent == null)
|
if (setupIntent == null)
|
||||||
@@ -173,19 +195,19 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
|
|||||||
}
|
}
|
||||||
case TokenizablePaymentMethodType.Card:
|
case TokenizablePaymentMethodType.Card:
|
||||||
{
|
{
|
||||||
customerCreateOptions.PaymentMethod = paymentMethod.Token;
|
customerCreateOptions.PaymentMethod = paymentMethod.AsT0.Token;
|
||||||
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token;
|
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.AsT0.Token;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TokenizablePaymentMethodType.PayPal:
|
case TokenizablePaymentMethodType.PayPal:
|
||||||
{
|
{
|
||||||
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.Token);
|
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.AsT0.Token);
|
||||||
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
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();
|
throw new BillingException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,18 +225,21 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
|
|||||||
async Task Revert()
|
async Task Revert()
|
||||||
{
|
{
|
||||||
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
|
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
|
||||||
switch (paymentMethod.Type)
|
if (paymentMethod.IsTokenized)
|
||||||
{
|
{
|
||||||
case TokenizablePaymentMethodType.BankAccount:
|
switch (paymentMethod.AsT0.Type)
|
||||||
{
|
{
|
||||||
await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id);
|
case TokenizablePaymentMethodType.BankAccount:
|
||||||
break;
|
{
|
||||||
}
|
await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id);
|
||||||
case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
|
break;
|
||||||
{
|
}
|
||||||
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
|
case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
|
||||||
break;
|
{
|
||||||
}
|
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
112
test/Core.Test/Billing/Payment/Models/PaymentMethodTests.cs
Normal file
112
test/Core.Test/Billing/Payment/Models/PaymentMethodTests.cs
Normal file
@@ -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<JsonException>(() => JsonSerializer.Deserialize<PaymentMethod>(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<JsonException>(() => JsonSerializer.Deserialize<PaymentMethod>(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<PaymentMethod>(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<PaymentMethod>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Constants;
|
||||||
using Bit.Core.Billing.Extensions;
|
using Bit.Core.Billing.Extensions;
|
||||||
using Bit.Core.Billing.Payment.Models;
|
using Bit.Core.Billing.Payment.Models;
|
||||||
@@ -567,4 +568,79 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
|
|||||||
var unhandled = result.AsT3;
|
var unhandled = result.AsT3;
|
||||||
Assert.Equal("Something went wrong with your request. Please contact support for assistance.", unhandled.Response);
|
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<StripeCustomer>();
|
||||||
|
mockCustomer.Id = "existing_customer_123";
|
||||||
|
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
|
||||||
|
mockCustomer.Metadata = new Dictionary<string, string>();
|
||||||
|
|
||||||
|
var mockSubscription = Substitute.For<StripeSubscription>();
|
||||||
|
mockSubscription.Id = "sub_123";
|
||||||
|
mockSubscription.Status = "active";
|
||||||
|
mockSubscription.Items = new StripeList<SubscriptionItem>
|
||||||
|
{
|
||||||
|
Data =
|
||||||
|
[
|
||||||
|
new SubscriptionItem
|
||||||
|
{
|
||||||
|
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var mockInvoice = Substitute.For<Invoice>();
|
||||||
|
|
||||||
|
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
|
||||||
|
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
|
||||||
|
_stripeAdapter.InvoiceUpdateAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.True(result.IsT0);
|
||||||
|
await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>());
|
||||||
|
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
|
||||||
|
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<BillingException>(result.AsT3.Exception);
|
||||||
|
// Verify no customer was created or subscription attempted
|
||||||
|
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
|
||||||
|
await _stripeAdapter.DidNotReceive().SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
|
||||||
|
await _userService.DidNotReceive().SaveUserAsync(Arg.Any<User>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user