[PM-27553] Resolve premium purchase for user with account credit that used payment method#6514
Conversation
… credit but used a payment method
|
Claude finished @amorask-bitwarden's task —— View job Code Review CompleteSummary of ChangesThis PR fixes a billing issue where users who previously purchased account credit (creating a Stripe customer without a payment method) encounter an error when trying to purchase premium using an actual payment method. The fix adds logic to detect this scenario and update the customer's payment method before creating the subscription. Key Changes:
Critical Issues1. Race Condition in Payment Method CheckLocation: else if (paymentMethod.IsTokenized && !await hasPaymentMethodQuery.Run(user))
{
await updatePaymentMethodCommand.Run(user, paymentMethod.AsTokenized, billingAddress);
customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
}There's a TOCTOU (Time-of-Check-Time-of-Use) race condition here. Between checking Suggestion: Consider checking the result of 2. Missing Null Reference CheckLocation: The code calls Suggestion: Review error handling in 3. Inconsistent Error Handling for NonTokenized Payment MethodsLocation: The code path for NonTokenized payment methods (like AccountCredit) with an existing customer (line 104-106) doesn't validate that the customer has sufficient credit or that the billing address is valid before attempting subscription creation. This could lead to cryptic Stripe errors downstream. Verification needed: Does the existing customer validation in Suggested Improvements4. Code Clarity - Switch Statement RefactoringLocation: The nested Suggested refactoringpaymentMethod.Switch(
tokenized => HandleTokenizedPaymentMethod(tokenized, subscription, user),
nonTokenized => HandleNonTokenizedPaymentMethod(nonTokenized, subscription, user)
);
// Extract to private methods for clarity
private void HandleTokenizedPaymentMethod(TokenizedPaymentMethod tokenized, Subscription subscription, User user)
{
var shouldSetPremium = tokenized.Type switch
{
TokenizablePaymentMethodType.PayPal => subscription.Status == SubscriptionStatus.Incomplete,
_ => subscription.Status == SubscriptionStatus.Active
};
if (shouldSetPremium)
{
user.Premium = true;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
}
}
private void HandleNonTokenizedPaymentMethod(NonTokenizedPaymentMethod nonTokenized, Subscription subscription, User user)
{
if (subscription.Status == SubscriptionStatus.Active)
{
user.Premium = true;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
}
}5. Test Coverage Gap - NonTokenized Payment Method Edge CaseLocation: Test file line 699-722 The test
This is a legitimate scenario that should be covered. 6. Potential Billing Address Validation IssueLocation: The Suggestion: Add validation of the incoming if (string.IsNullOrEmpty(billingAddress.Country) || string.IsNullOrEmpty(billingAddress.PostalCode))
{
_logger.LogError("Cannot reconcile billing location for customer without valid billing address");
throw new BillingException("Valid billing address required for subscription");
}7. Checkmarx CSRF Finding - False PositiveLocation: The Checkmarx report flagged a CSRF vulnerability at line 72. This appears to be a false positive because:
However, verify that:
8. Documentation - Missing XML CommentsLocation: The newly added properties lack XML documentation comments: public TokenizedPaymentMethod AsTokenized => AsT0;
public NonTokenizedPaymentMethod AsNonTokenized => AsT1;Suggestion: Add XML comments explaining when to use these vs the type checks: /// <summary>
/// Gets the tokenized payment method. Only call after verifying IsTokenized is true.
/// </summary>
public TokenizedPaymentMethod AsTokenized => AsT0;9. Logging EnhancementLocation: The new code path (updating payment method for existing customer) doesn't log this scenario. For debugging and monitoring purposes, it would be helpful to log when this specific edge case occurs. Suggestion: else if (paymentMethod.IsTokenized && !await hasPaymentMethodQuery.Run(user))
{
_logger.LogInformation("User ({UserId}) has existing customer but no payment method. Updating payment method before subscription creation", user.Id);
await updatePaymentMethodCommand.Run(user, paymentMethod.AsTokenized, billingAddress);
customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
}Note: Ensure the log message doesn't expose PII - the user ID should be sufficient. Good Practices Observed
Action ItemsFor the Author:
Security Review:
Before Merging:
|
|
Great job! No new security vulnerabilities introduced in this pull request |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6514 +/- ##
==========================================
+ Coverage 51.86% 51.87% +0.01%
==========================================
Files 1901 1901
Lines 84051 84073 +22
Branches 7501 7500 -1
==========================================
+ Hits 43594 43615 +21
Misses 38763 38763
- Partials 1694 1695 +1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
sbrown-livefront
left a comment
There was a problem hiding this comment.
✅ Solid update on the PaymentMethod model. Makes it so much cleaner.
|
@sbrown-livefront pointed out here that we should make the same change in the |
cyprain-okeke
left a comment
There was a problem hiding this comment.
I noticed we're using Customer? customer but there's no null validation after assignment. While GetCustomerOrThrow guarantees non-null returns, CreateCustomerAsync calls the Stripe SDK which could theoretically return null in edge
cases. can we null check for customer
// Add this defensive check
if (customer == null)
{
_logger.LogError(
"Customer is unexpectedly null after creation/retrieval. UserId: {UserId}, GatewayCustomerId: {GatewayCustomerId}",
user.Id,
user.GatewayCustomerId ?? "none"
);
throw new BillingException("Customer is unexpectedly null after creation/retrieval");
}
@cyprain-okeke There's no case where Stripe's |

🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-27553
📔 Objective
If a user previously purchased account credit, they'll have a Stripe customer. If they then try to purchase a premium subscription using an actual payment method, our code will see they have a customer and assume they already have a payment method as well.
This resolves that by checking for this scenario and updating the customer's payment method in that case.
📸 Screenshots
Screen.Recording.2025-10-29.at.7.35.40.AM.mov
⏰ Reminders before review
🦮 Reviewer guidelines
:+1:) or similar for great changes:memo:) or ℹ️ (:information_source:) for notes or general info:question:) for questions:thinking:) or 💭 (:thought_balloon:) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion:art:) for suggestions / improvements:x:) or:warning:) for more significant problems or concerns needing attention:seedling:) or ♻️ (:recycle:) for future improvements or indications of technical debt:pick:) for minor or nitpick changes