Skip to content

Commit ff4b3eb

Browse files
[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
1 parent b15913c commit ff4b3eb

File tree

10 files changed

+391
-37
lines changed

10 files changed

+391
-37
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using Bit.Api.Utilities;
2+
3+
namespace Bit.Api.Billing.Attributes;
4+
5+
public class NonTokenizedPaymentMethodTypeValidationAttribute : StringMatchesAttribute
6+
{
7+
private static readonly string[] _acceptedValues = ["accountCredit"];
8+
9+
public NonTokenizedPaymentMethodTypeValidationAttribute() : base(_acceptedValues)
10+
{
11+
ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}";
12+
}
13+
}

src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs renamed to src/Api/Billing/Attributes/TokenizedPaymentMethodTypeValidationAttribute.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
namespace Bit.Api.Billing.Attributes;
44

5-
public class PaymentMethodTypeValidationAttribute : StringMatchesAttribute
5+
public class TokenizedPaymentMethodTypeValidationAttribute : StringMatchesAttribute
66
{
77
private static readonly string[] _acceptedValues = ["bankAccount", "card", "payPal"];
88

9-
public PaymentMethodTypeValidationAttribute() : base(_acceptedValues)
9+
public TokenizedPaymentMethodTypeValidationAttribute() : base(_acceptedValues)
1010
{
1111
ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}";
1212
}

src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ namespace Bit.Api.Billing.Models.Requests.Payment;
77
public class MinimalTokenizedPaymentMethodRequest
88
{
99
[Required]
10-
[PaymentMethodTypeValidation]
10+
[TokenizedPaymentMethodTypeValidation]
1111
public required string Type { get; set; }
1212

1313
[Required]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using Bit.Api.Billing.Attributes;
3+
using Bit.Core.Billing.Payment.Models;
4+
5+
namespace Bit.Api.Billing.Models.Requests.Payment;
6+
7+
public class NonTokenizedPaymentMethodRequest
8+
{
9+
[Required]
10+
[NonTokenizedPaymentMethodTypeValidation]
11+
public required string Type { get; set; }
12+
13+
public NonTokenizedPaymentMethod ToDomain()
14+
{
15+
return Type switch
16+
{
17+
"accountCredit" => new NonTokenizedPaymentMethod { Type = NonTokenizablePaymentMethodType.AccountCredit },
18+
_ => throw new InvalidOperationException($"Invalid value for {nameof(NonTokenizedPaymentMethod)}.{nameof(NonTokenizedPaymentMethod.Type)}")
19+
};
20+
}
21+
}

src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,49 @@
44

55
namespace Bit.Api.Billing.Models.Requests.Premium;
66

7-
public class PremiumCloudHostedSubscriptionRequest
7+
public class PremiumCloudHostedSubscriptionRequest : IValidatableObject
88
{
9-
[Required]
10-
public required MinimalTokenizedPaymentMethodRequest TokenizedPaymentMethod { get; set; }
9+
public MinimalTokenizedPaymentMethodRequest? TokenizedPaymentMethod { get; set; }
10+
public NonTokenizedPaymentMethodRequest? NonTokenizedPaymentMethod { get; set; }
1111

1212
[Required]
1313
public required MinimalBillingAddressRequest BillingAddress { get; set; }
1414

1515
[Range(0, 99)]
1616
public short AdditionalStorageGb { get; set; } = 0;
1717

18-
public (TokenizedPaymentMethod, BillingAddress, short) ToDomain()
18+
19+
public (PaymentMethod, BillingAddress, short) ToDomain()
1920
{
20-
var paymentMethod = TokenizedPaymentMethod.ToDomain();
21+
// Check if TokenizedPaymentMethod or NonTokenizedPaymentMethod is provided.
22+
var tokenizedPaymentMethod = TokenizedPaymentMethod?.ToDomain();
23+
var nonTokenizedPaymentMethod = NonTokenizedPaymentMethod?.ToDomain();
24+
25+
PaymentMethod paymentMethod = tokenizedPaymentMethod != null
26+
? tokenizedPaymentMethod
27+
: nonTokenizedPaymentMethod!;
28+
2129
var billingAddress = BillingAddress.ToDomain();
2230

2331
return (paymentMethod, billingAddress, AdditionalStorageGb);
2432
}
33+
34+
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
35+
{
36+
if (TokenizedPaymentMethod == null && NonTokenizedPaymentMethod == null)
37+
{
38+
yield return new ValidationResult(
39+
"Either TokenizedPaymentMethod or NonTokenizedPaymentMethod must be provided.",
40+
new[] { nameof(TokenizedPaymentMethod), nameof(NonTokenizedPaymentMethod) }
41+
);
42+
}
43+
44+
if (TokenizedPaymentMethod != null && NonTokenizedPaymentMethod != null)
45+
{
46+
yield return new ValidationResult(
47+
"Only one of TokenizedPaymentMethod or NonTokenizedPaymentMethod can be provided.",
48+
new[] { nameof(TokenizedPaymentMethod), nameof(NonTokenizedPaymentMethod) }
49+
);
50+
}
51+
}
2552
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace Bit.Core.Billing.Payment.Models;
2+
3+
public record NonTokenizedPaymentMethod
4+
{
5+
public NonTokenizablePaymentMethodType Type { get; set; }
6+
}
7+
8+
public enum NonTokenizablePaymentMethodType
9+
{
10+
AccountCredit,
11+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
using OneOf;
4+
5+
namespace Bit.Core.Billing.Payment.Models;
6+
7+
[JsonConverter(typeof(PaymentMethodJsonConverter))]
8+
public class PaymentMethod(OneOf<TokenizedPaymentMethod, NonTokenizedPaymentMethod> input)
9+
: OneOfBase<TokenizedPaymentMethod, NonTokenizedPaymentMethod>(input)
10+
{
11+
public static implicit operator PaymentMethod(TokenizedPaymentMethod tokenized) => new(tokenized);
12+
public static implicit operator PaymentMethod(NonTokenizedPaymentMethod nonTokenized) => new(nonTokenized);
13+
public bool IsTokenized => IsT0;
14+
public bool IsNonTokenized => IsT1;
15+
}
16+
17+
internal class PaymentMethodJsonConverter : JsonConverter<PaymentMethod>
18+
{
19+
public override PaymentMethod Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
20+
{
21+
var element = JsonElement.ParseValue(ref reader);
22+
23+
if (!element.TryGetProperty("type", out var typeProperty))
24+
{
25+
throw new JsonException("PaymentMethod requires a 'type' property");
26+
}
27+
28+
var type = typeProperty.GetString();
29+
30+
31+
if (Enum.TryParse<TokenizablePaymentMethodType>(type, true, out var tokenizedType) &&
32+
Enum.IsDefined(typeof(TokenizablePaymentMethodType), tokenizedType))
33+
{
34+
var token = element.TryGetProperty("token", out var tokenProperty) ? tokenProperty.GetString() : null;
35+
if (string.IsNullOrEmpty(token))
36+
{
37+
throw new JsonException("TokenizedPaymentMethod requires a 'token' property");
38+
}
39+
40+
return new TokenizedPaymentMethod { Type = tokenizedType, Token = token };
41+
}
42+
43+
if (Enum.TryParse<NonTokenizablePaymentMethodType>(type, true, out var nonTokenizedType) &&
44+
Enum.IsDefined(typeof(NonTokenizablePaymentMethodType), nonTokenizedType))
45+
{
46+
return new NonTokenizedPaymentMethod { Type = nonTokenizedType };
47+
}
48+
49+
throw new JsonException($"Unknown payment method type: {type}");
50+
}
51+
52+
public override void Write(Utf8JsonWriter writer, PaymentMethod value, JsonSerializerOptions options)
53+
{
54+
writer.WriteStartObject();
55+
56+
value.Switch(
57+
tokenized =>
58+
{
59+
writer.WriteString("type",
60+
tokenized.Type.ToString().ToLowerInvariant()
61+
);
62+
writer.WriteString("token", tokenized.Token);
63+
},
64+
nonTokenized => { writer.WriteString("type", nonTokenized.Type.ToString().ToLowerInvariant()); }
65+
);
66+
67+
writer.WriteEndObject();
68+
}
69+
}

src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
using OneOf.Types;
1717
using Stripe;
1818
using Customer = Stripe.Customer;
19+
using PaymentMethod = Bit.Core.Billing.Payment.Models.PaymentMethod;
1920
using Subscription = Stripe.Subscription;
2021

2122
namespace Bit.Core.Billing.Premium.Commands;
@@ -38,7 +39,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand
3839
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
3940
Task<BillingCommandResult<None>> Run(
4041
User user,
41-
TokenizedPaymentMethod paymentMethod,
42+
PaymentMethod paymentMethod,
4243
BillingAddress billingAddress,
4344
short additionalStorageGb);
4445
}
@@ -60,7 +61,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
6061

6162
public Task<BillingCommandResult<None>> Run(
6263
User user,
63-
TokenizedPaymentMethod paymentMethod,
64+
PaymentMethod paymentMethod,
6465
BillingAddress billingAddress,
6566
short additionalStorageGb) => HandleAsync<None>(async () =>
6667
{
@@ -74,6 +75,7 @@ public Task<BillingCommandResult<None>> Run(
7475
return new BadRequest("Additional storage must be greater than 0.");
7576
}
7677

78+
// Note: A customer will already exist if the customer has purchased account credits.
7779
var customer = string.IsNullOrEmpty(user.GatewayCustomerId)
7880
? await CreateCustomerAsync(user, paymentMethod, billingAddress)
7981
: await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
@@ -82,18 +84,31 @@ public Task<BillingCommandResult<None>> Run(
8284

8385
var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null);
8486

85-
switch (paymentMethod)
86-
{
87-
case { Type: TokenizablePaymentMethodType.PayPal }
88-
when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete:
89-
case { Type: not TokenizablePaymentMethodType.PayPal }
90-
when subscription.Status == StripeConstants.SubscriptionStatus.Active:
87+
paymentMethod.Switch(
88+
tokenized =>
89+
{
90+
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
91+
switch (tokenized)
92+
{
93+
case { Type: TokenizablePaymentMethodType.PayPal }
94+
when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete:
95+
case { Type: not TokenizablePaymentMethodType.PayPal }
96+
when subscription.Status == StripeConstants.SubscriptionStatus.Active:
97+
{
98+
user.Premium = true;
99+
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
100+
break;
101+
}
102+
}
103+
},
104+
nonTokenized =>
105+
{
106+
if (subscription.Status == StripeConstants.SubscriptionStatus.Active)
91107
{
92108
user.Premium = true;
93109
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
94-
break;
95110
}
96-
}
111+
});
97112

98113
user.Gateway = GatewayType.Stripe;
99114
user.GatewayCustomerId = customer.Id;
@@ -109,9 +124,15 @@ public Task<BillingCommandResult<None>> Run(
109124
});
110125

111126
private async Task<Customer> CreateCustomerAsync(User user,
112-
TokenizedPaymentMethod paymentMethod,
127+
PaymentMethod paymentMethod,
113128
BillingAddress billingAddress)
114129
{
130+
if (paymentMethod.IsNonTokenized)
131+
{
132+
_logger.LogError("Cannot create customer for user ({UserID}) using non-tokenized payment method. The customer should already exist", user.Id);
133+
throw new BillingException();
134+
}
135+
115136
var subscriberName = user.SubscriberName();
116137
var customerCreateOptions = new CustomerCreateOptions
117138
{
@@ -153,13 +174,14 @@ private async Task<Customer> CreateCustomerAsync(User user,
153174

154175
var braintreeCustomerId = "";
155176

177+
// We have checked that the payment method is tokenized, so we can safely cast it.
156178
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
157-
switch (paymentMethod.Type)
179+
switch (paymentMethod.AsT0.Type)
158180
{
159181
case TokenizablePaymentMethodType.BankAccount:
160182
{
161183
var setupIntent =
162-
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.Token }))
184+
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.AsT0.Token }))
163185
.FirstOrDefault();
164186

165187
if (setupIntent == null)
@@ -173,19 +195,19 @@ private async Task<Customer> CreateCustomerAsync(User user,
173195
}
174196
case TokenizablePaymentMethodType.Card:
175197
{
176-
customerCreateOptions.PaymentMethod = paymentMethod.Token;
177-
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token;
198+
customerCreateOptions.PaymentMethod = paymentMethod.AsT0.Token;
199+
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.AsT0.Token;
178200
break;
179201
}
180202
case TokenizablePaymentMethodType.PayPal:
181203
{
182-
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.Token);
204+
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.AsT0.Token);
183205
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
184206
break;
185207
}
186208
default:
187209
{
188-
_logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.Type.ToString());
210+
_logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.AsT0.Type.ToString());
189211
throw new BillingException();
190212
}
191213
}
@@ -203,18 +225,21 @@ private async Task<Customer> CreateCustomerAsync(User user,
203225
async Task Revert()
204226
{
205227
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
206-
switch (paymentMethod.Type)
228+
if (paymentMethod.IsTokenized)
207229
{
208-
case TokenizablePaymentMethodType.BankAccount:
209-
{
210-
await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id);
211-
break;
212-
}
213-
case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
214-
{
215-
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
216-
break;
217-
}
230+
switch (paymentMethod.AsT0.Type)
231+
{
232+
case TokenizablePaymentMethodType.BankAccount:
233+
{
234+
await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id);
235+
break;
236+
}
237+
case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
238+
{
239+
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
240+
break;
241+
}
242+
}
218243
}
219244
}
220245
}

0 commit comments

Comments
 (0)