mirror of
https://github.com/bitwarden/server
synced 2025-12-24 12:13:17 +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;
|
||||
|
||||
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)}";
|
||||
}
|
||||
@@ -7,7 +7,7 @@ namespace Bit.Api.Billing.Models.Requests.Payment;
|
||||
public class MinimalTokenizedPaymentMethodRequest
|
||||
{
|
||||
[Required]
|
||||
[PaymentMethodTypeValidation]
|
||||
[TokenizedPaymentMethodTypeValidation]
|
||||
public required string Type { get; set; }
|
||||
|
||||
[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;
|
||||
|
||||
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<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 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
|
||||
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
|
||||
Task<BillingCommandResult<None>> Run(
|
||||
User user,
|
||||
TokenizedPaymentMethod paymentMethod,
|
||||
PaymentMethod paymentMethod,
|
||||
BillingAddress billingAddress,
|
||||
short additionalStorageGb);
|
||||
}
|
||||
@@ -60,7 +61,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
|
||||
|
||||
public Task<BillingCommandResult<None>> Run(
|
||||
User user,
|
||||
TokenizedPaymentMethod paymentMethod,
|
||||
PaymentMethod paymentMethod,
|
||||
BillingAddress billingAddress,
|
||||
short additionalStorageGb) => HandleAsync<None>(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<Customer> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user