Skip to content

Commit 410e754

Browse files
[PM-27553] Resolve premium purchase for user with account credit that used payment method (#6514)
* Update payment method for customer purchasing premium who has account credit but used a payment method * Claude feedback + dotnet run format
1 parent e102a74 commit 410e754

3 files changed

Lines changed: 145 additions & 42 deletions

File tree

src/Core/Billing/Payment/Models/PaymentMethod.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ public class PaymentMethod(OneOf<TokenizedPaymentMethod, NonTokenizedPaymentMeth
1111
public static implicit operator PaymentMethod(TokenizedPaymentMethod tokenized) => new(tokenized);
1212
public static implicit operator PaymentMethod(NonTokenizedPaymentMethod nonTokenized) => new(nonTokenized);
1313
public bool IsTokenized => IsT0;
14+
public TokenizedPaymentMethod AsTokenized => AsT0;
1415
public bool IsNonTokenized => IsT1;
16+
public NonTokenizedPaymentMethod AsNonTokenized => AsT1;
1517
}
1618

1719
internal class PaymentMethodJsonConverter : JsonConverter<PaymentMethod>

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

Lines changed: 64 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
using Bit.Core.Billing.Commands;
33
using Bit.Core.Billing.Constants;
44
using Bit.Core.Billing.Extensions;
5+
using Bit.Core.Billing.Payment.Commands;
56
using Bit.Core.Billing.Payment.Models;
7+
using Bit.Core.Billing.Payment.Queries;
68
using Bit.Core.Billing.Pricing;
79
using Bit.Core.Billing.Services;
810
using Bit.Core.Entities;
@@ -21,6 +23,7 @@
2123

2224
namespace Bit.Core.Billing.Premium.Commands;
2325

26+
using static StripeConstants;
2427
using static Utilities;
2528

2629
/// <summary>
@@ -32,7 +35,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand
3235
/// <summary>
3336
/// Creates a premium cloud-hosted subscription for the specified user.
3437
/// </summary>
35-
/// <param name="user">The user to create the premium subscription for. Must not already be a premium user.</param>
38+
/// <param name="user">The user to create the premium subscription for. Must not yet be a premium user.</param>
3639
/// <param name="paymentMethod">The tokenized payment method containing the payment type and token for billing.</param>
3740
/// <param name="billingAddress">The billing address information required for tax calculation and customer creation.</param>
3841
/// <param name="additionalStorageGb">Additional storage in GB beyond the base 1GB included with premium (must be >= 0).</param>
@@ -53,7 +56,9 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
5356
IUserService userService,
5457
IPushNotificationService pushNotificationService,
5558
ILogger<CreatePremiumCloudHostedSubscriptionCommand> logger,
56-
IPricingClient pricingClient)
59+
IPricingClient pricingClient,
60+
IHasPaymentMethodQuery hasPaymentMethodQuery,
61+
IUpdatePaymentMethodCommand updatePaymentMethodCommand)
5762
: BaseBillingCommand<CreatePremiumCloudHostedSubscriptionCommand>(logger), ICreatePremiumCloudHostedSubscriptionCommand
5863
{
5964
private static readonly List<string> _expand = ["tax"];
@@ -75,10 +80,30 @@ public Task<BillingCommandResult<None>> Run(
7580
return new BadRequest("Additional storage must be greater than 0.");
7681
}
7782

78-
// Note: A customer will already exist if the customer has purchased account credits.
79-
var customer = string.IsNullOrEmpty(user.GatewayCustomerId)
80-
? await CreateCustomerAsync(user, paymentMethod, billingAddress)
81-
: await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
83+
Customer? customer;
84+
85+
/*
86+
* For a new customer purchasing a new subscription, we attach the payment method while creating the customer.
87+
*/
88+
if (string.IsNullOrEmpty(user.GatewayCustomerId))
89+
{
90+
customer = await CreateCustomerAsync(user, paymentMethod, billingAddress);
91+
}
92+
/*
93+
* An existing customer without a payment method starting a new subscription indicates a user who previously
94+
* purchased account credit but chose to use a tokenizable payment method to pay for the subscription. In this case,
95+
* we need to add the payment method to their customer first. If the incoming payment method is account credit,
96+
* we can just go straight to fetching the customer since there's no payment method to apply.
97+
*/
98+
else if (paymentMethod.IsTokenized && !await hasPaymentMethodQuery.Run(user))
99+
{
100+
await updatePaymentMethodCommand.Run(user, paymentMethod.AsTokenized, billingAddress);
101+
customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
102+
}
103+
else
104+
{
105+
customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
106+
}
82107

83108
customer = await ReconcileBillingLocationAsync(customer, billingAddress);
84109

@@ -91,23 +116,25 @@ public Task<BillingCommandResult<None>> Run(
91116
switch (tokenized)
92117
{
93118
case { Type: TokenizablePaymentMethodType.PayPal }
94-
when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete:
119+
when subscription.Status == SubscriptionStatus.Incomplete:
95120
case { Type: not TokenizablePaymentMethodType.PayPal }
96-
when subscription.Status == StripeConstants.SubscriptionStatus.Active:
121+
when subscription.Status == SubscriptionStatus.Active:
97122
{
98123
user.Premium = true;
99124
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
100125
break;
101126
}
102127
}
103128
},
104-
nonTokenized =>
129+
_ =>
105130
{
106-
if (subscription.Status == StripeConstants.SubscriptionStatus.Active)
131+
if (subscription.Status != SubscriptionStatus.Active)
107132
{
108-
user.Premium = true;
109-
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
133+
return;
110134
}
135+
136+
user.Premium = true;
137+
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
111138
});
112139

113140
user.Gateway = GatewayType.Stripe;
@@ -163,25 +190,25 @@ private async Task<Customer> CreateCustomerAsync(User user,
163190
},
164191
Metadata = new Dictionary<string, string>
165192
{
166-
[StripeConstants.MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion,
167-
[StripeConstants.MetadataKeys.UserId] = user.Id.ToString()
193+
[MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion,
194+
[MetadataKeys.UserId] = user.Id.ToString()
168195
},
169196
Tax = new CustomerTaxOptions
170197
{
171-
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
198+
ValidateLocation = ValidateTaxLocationTiming.Immediately
172199
}
173200
};
174201

175202
var braintreeCustomerId = "";
176203

177204
// We have checked that the payment method is tokenized, so we can safely cast it.
178-
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
179-
switch (paymentMethod.AsT0.Type)
205+
var tokenizedPaymentMethod = paymentMethod.AsTokenized;
206+
switch (tokenizedPaymentMethod.Type)
180207
{
181208
case TokenizablePaymentMethodType.BankAccount:
182209
{
183210
var setupIntent =
184-
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.AsT0.Token }))
211+
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = tokenizedPaymentMethod.Token }))
185212
.FirstOrDefault();
186213

187214
if (setupIntent == null)
@@ -195,19 +222,19 @@ private async Task<Customer> CreateCustomerAsync(User user,
195222
}
196223
case TokenizablePaymentMethodType.Card:
197224
{
198-
customerCreateOptions.PaymentMethod = paymentMethod.AsT0.Token;
199-
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.AsT0.Token;
225+
customerCreateOptions.PaymentMethod = tokenizedPaymentMethod.Token;
226+
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = tokenizedPaymentMethod.Token;
200227
break;
201228
}
202229
case TokenizablePaymentMethodType.PayPal:
203230
{
204-
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.AsT0.Token);
231+
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, tokenizedPaymentMethod.Token);
205232
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
206233
break;
207234
}
208235
default:
209236
{
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());
237+
_logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, tokenizedPaymentMethod.Type.ToString());
211238
throw new BillingException();
212239
}
213240
}
@@ -225,21 +252,18 @@ private async Task<Customer> CreateCustomerAsync(User user,
225252
async Task Revert()
226253
{
227254
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
228-
if (paymentMethod.IsTokenized)
255+
switch (tokenizedPaymentMethod.Type)
229256
{
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-
}
257+
case TokenizablePaymentMethodType.BankAccount:
258+
{
259+
await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id);
260+
break;
261+
}
262+
case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
263+
{
264+
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
265+
break;
266+
}
243267
}
244268
}
245269
}
@@ -271,7 +295,7 @@ private async Task<Customer> ReconcileBillingLocationAsync(
271295
Expand = _expand,
272296
Tax = new CustomerTaxOptions
273297
{
274-
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
298+
ValidateLocation = ValidateTaxLocationTiming.Immediately
275299
}
276300
};
277301
return await stripeAdapter.CustomerUpdateAsync(customer.Id, options);
@@ -310,15 +334,15 @@ private async Task<Subscription> CreateSubscriptionAsync(
310334
{
311335
Enabled = true
312336
},
313-
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
337+
CollectionMethod = CollectionMethod.ChargeAutomatically,
314338
Customer = customer.Id,
315339
Items = subscriptionItemOptionsList,
316340
Metadata = new Dictionary<string, string>
317341
{
318-
[StripeConstants.MetadataKeys.UserId] = userId.ToString()
342+
[MetadataKeys.UserId] = userId.ToString()
319343
},
320344
PaymentBehavior = usingPayPal
321-
? StripeConstants.PaymentBehavior.DefaultIncomplete
345+
? PaymentBehavior.DefaultIncomplete
322346
: null,
323347
OffSession = true
324348
};

test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
using Bit.Core.Billing.Caches;
33
using Bit.Core.Billing.Constants;
44
using Bit.Core.Billing.Extensions;
5+
using Bit.Core.Billing.Payment.Commands;
56
using Bit.Core.Billing.Payment.Models;
7+
using Bit.Core.Billing.Payment.Queries;
68
using Bit.Core.Billing.Premium.Commands;
79
using Bit.Core.Billing.Pricing;
810
using Bit.Core.Billing.Services;
@@ -34,6 +36,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
3436
private readonly IUserService _userService = Substitute.For<IUserService>();
3537
private readonly IPushNotificationService _pushNotificationService = Substitute.For<IPushNotificationService>();
3638
private readonly IPricingClient _pricingClient = Substitute.For<IPricingClient>();
39+
private readonly IHasPaymentMethodQuery _hasPaymentMethodQuery = Substitute.For<IHasPaymentMethodQuery>();
40+
private readonly IUpdatePaymentMethodCommand _updatePaymentMethodCommand = Substitute.For<IUpdatePaymentMethodCommand>();
3741
private readonly CreatePremiumCloudHostedSubscriptionCommand _command;
3842

3943
public CreatePremiumCloudHostedSubscriptionCommandTests()
@@ -62,7 +66,9 @@ public CreatePremiumCloudHostedSubscriptionCommandTests()
6266
_userService,
6367
_pushNotificationService,
6468
Substitute.For<ILogger<CreatePremiumCloudHostedSubscriptionCommand>>(),
65-
_pricingClient);
69+
_pricingClient,
70+
_hasPaymentMethodQuery,
71+
_updatePaymentMethodCommand);
6672
}
6773

6874
[Theory, BitAutoData]
@@ -314,7 +320,7 @@ public async Task Run_ValidRequestWithAdditionalStorage_Success(
314320
}
315321

316322
[Theory, BitAutoData]
317-
public async Task Run_UserHasExistingGatewayCustomerId_UsesExistingCustomer(
323+
public async Task Run_UserHasExistingGatewayCustomerIdAndPaymentMethod_UsesExistingCustomer(
318324
User user,
319325
TokenizedPaymentMethod paymentMethod,
320326
BillingAddress billingAddress)
@@ -347,6 +353,8 @@ public async Task Run_UserHasExistingGatewayCustomerId_UsesExistingCustomer(
347353

348354
var mockInvoice = Substitute.For<Invoice>();
349355

356+
// Mock that the user has a payment method (this is the key difference from the credit purchase case)
357+
_hasPaymentMethodQuery.Run(Arg.Any<User>()).Returns(true);
350358
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
351359
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
352360
_stripeAdapter.InvoiceUpdateAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
@@ -358,6 +366,75 @@ public async Task Run_UserHasExistingGatewayCustomerId_UsesExistingCustomer(
358366
Assert.True(result.IsT0);
359367
await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>());
360368
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
369+
await _updatePaymentMethodCommand.DidNotReceive().Run(Arg.Any<User>(), Arg.Any<TokenizedPaymentMethod>(), Arg.Any<BillingAddress>());
370+
}
371+
372+
[Theory, BitAutoData]
373+
public async Task Run_UserPreviouslyPurchasedCreditWithoutPaymentMethod_UpdatesPaymentMethodAndCreatesSubscription(
374+
User user,
375+
TokenizedPaymentMethod paymentMethod,
376+
BillingAddress billingAddress)
377+
{
378+
// Arrange
379+
user.Premium = false;
380+
user.GatewayCustomerId = "existing_customer_123"; // Customer exists from previous credit purchase
381+
paymentMethod.Type = TokenizablePaymentMethodType.Card;
382+
paymentMethod.Token = "card_token_123";
383+
billingAddress.Country = "US";
384+
billingAddress.PostalCode = "12345";
385+
386+
var mockCustomer = Substitute.For<StripeCustomer>();
387+
mockCustomer.Id = "existing_customer_123";
388+
mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" };
389+
mockCustomer.Metadata = new Dictionary<string, string>();
390+
391+
var mockSubscription = Substitute.For<StripeSubscription>();
392+
mockSubscription.Id = "sub_123";
393+
mockSubscription.Status = "active";
394+
mockSubscription.Items = new StripeList<SubscriptionItem>
395+
{
396+
Data =
397+
[
398+
new SubscriptionItem
399+
{
400+
CurrentPeriodEnd = DateTime.UtcNow.AddDays(30)
401+
}
402+
]
403+
};
404+
405+
var mockInvoice = Substitute.For<Invoice>();
406+
MaskedPaymentMethod mockMaskedPaymentMethod = new MaskedCard
407+
{
408+
Brand = "visa",
409+
Last4 = "1234",
410+
Expiration = "12/2025"
411+
};
412+
413+
// Mock that the user does NOT have a payment method (simulating credit purchase scenario)
414+
_hasPaymentMethodQuery.Run(Arg.Any<User>()).Returns(false);
415+
_updatePaymentMethodCommand.Run(Arg.Any<User>(), Arg.Any<TokenizedPaymentMethod>(), Arg.Any<BillingAddress>())
416+
.Returns(mockMaskedPaymentMethod);
417+
_subscriberService.GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>()).Returns(mockCustomer);
418+
_stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(mockSubscription);
419+
_stripeAdapter.InvoiceUpdateAsync(Arg.Any<string>(), Arg.Any<InvoiceUpdateOptions>()).Returns(mockInvoice);
420+
421+
// Act
422+
var result = await _command.Run(user, paymentMethod, billingAddress, 0);
423+
424+
// Assert
425+
Assert.True(result.IsT0);
426+
// Verify that update payment method was called (new behavior for credit purchase case)
427+
await _updatePaymentMethodCommand.Received(1).Run(user, paymentMethod, billingAddress);
428+
// Verify GetCustomerOrThrow was called after updating payment method
429+
await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any<User>(), Arg.Any<CustomerGetOptions>());
430+
// Verify no new customer was created
431+
await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any<CustomerCreateOptions>());
432+
// Verify subscription was created
433+
await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
434+
// Verify user was updated correctly
435+
Assert.True(user.Premium);
436+
await _userService.Received(1).SaveUserAsync(user);
437+
await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id);
361438
}
362439

363440
[Theory, BitAutoData]

0 commit comments

Comments
 (0)