Skip to content

Conversation

@amorask-bitwarden
Copy link
Contributor

@amorask-bitwarden amorask-bitwarden commented Dec 3, 2025

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-27117

📔 Objective

Bug Fix: Organization and Provider name/email changes were not being synchronized to Stripe invoice custom fields when updated via Admin Portal or Provider Portal APIs, causing outdated information to appear on customer invoices.

Root Cause: Controllers were bypassing the billing service layer that handles Stripe synchronization.

Solution: Added Stripe synchronization to all update paths:

  • Admin Portal - Organizations: Added IOrganizationBillingService dependency and Stripe sync for name/email changes
  • Admin Portal - Providers: Added ILogger dependency and Stripe sync for billing email changes
  • Provider Portal API: Added IProviderBillingService and ILogger dependencies and Stripe sync for name/email changes

Key Changes:

  • Created ProviderBillingService.UpdateProviderNameAndEmail() method to sync provider details to Stripe
  • Updated OrganizationBillingService.UpdateOrganizationNameAndEmail() to use warning logs instead of exceptions for consistency
  • Removed 30-character truncation limits on invoice custom fields for both organizations and providers
  • Added comprehensive unit tests (14 total: 7 for organizations, 7 for providers)
  • Graceful error handling: database updates always succeed; Stripe sync failures are logged but don't block operations

Impact: Ensures Stripe invoices always reflect current organization/provider names and contact emails.

📸 Screenshots

Screen.Recording.2025-12-03.at.10.34.20.AM.mov

⏰ Reminders before review

  • Contributor guidelines followed
  • All formatters and local linters executed and passed
  • Written new unit and / or integration tests where applicable
  • Protected functional changes with optionality (feature flags)
  • Used internationalization (i18n) for all UI strings
  • CI builds passed
  • Communicated to DevOps any deployment requirements
  • Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team

🦮 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

@amorask-bitwarden amorask-bitwarden marked this pull request as ready for review December 3, 2025 15:26
@amorask-bitwarden amorask-bitwarden requested review from a team as code owners December 3, 2025 15:26
@claude
Copy link
Contributor

claude bot commented Dec 3, 2025

Claude finished @amorask-bitwarden's task —— View job


Code Review Complete ✅

Tasks

  • Read project documentation and guidelines
  • Review PR diff and changed files
  • Run comprehensive code analysis
  • Compile and post review feedback

Summary

This PR addresses a bug where Admin Portal and Provider Portal APIs were bypassing the billing service layer, causing outdated information on Stripe invoices. The implementation adds proper Stripe synchronization for organization and provider name/email changes.

Overall Quality: Good ⭐⭐⭐⭐

The solution is well-architected with excellent test coverage. However, there is one blocking issue that needs attention before merge.


❌ Blocking Issue

30-Character Truncation Removed From Custom Fields

Files affected:

  • src/Core/Billing/Organizations/Services/OrganizationBillingService.cs:212
  • bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs:831

Previous code (truncated to 30 chars):

Value = newDisplayName.Length <= 30
    ? newDisplayName
    : newDisplayName[..30]

New code (no truncation):

Value = newDisplayName

Issue: According to Stripe's API documentation, custom field values have a maximum of 30 characters. Removing this truncation may cause Stripe API errors for organizations/providers with names longer than 30 characters.

Action Required:

  1. Verify Stripe's current API limits for invoice_settings.custom_fields[].value
  2. If the 30-character limit still exists, restore the truncation logic
  3. Add a test with a 50+ character name to validate the behavior

🎨 High Priority Recommendations

1. Document Error Handling Strategy

Locations:

  • src/Admin/AdminConsole/Controllers/OrganizationsController.cs:296-309
  • src/Admin/AdminConsole/Controllers/ProvidersController.cs:330-344
  • src/Api/AdminConsole/Controllers/ProvidersController.cs:81-93

All three controllers use a pattern where database updates succeed even if Stripe sync fails:

try
{
    await _billingService.UpdateProviderNameAndEmail(provider);
}
catch (Exception ex)
{
    _logger.LogError(ex, "...");
    TempData["Warning"] = "..."; // Only in Admin Portal
}

Concern: This creates eventual consistency issues where the database and Stripe may have different values.

Recommendations:

  • Add code comments explaining why this is acceptable (database is source of truth)
  • Consider adding metrics/alerts to track Stripe sync failure frequency
  • Document the support process for handling sync failures manually

2. Add Stripe API Failure Tests

The test suite covers graceful handling of missing data (null customer IDs, empty names) but doesn't test what happens when Stripe API calls fail.

Suggested tests:

[Theory, BitAutoData]
public async Task UpdateOrganizationNameAndEmail_WhenStripeThrowsException_PropagatesException(
    Organization organization,
    SutProvider<OrganizationBillingService> sutProvider)
{
    // Arrange
    organization.GatewayCustomerId = "cus_test123";
    organization.Name = "Test Org";
    
    sutProvider.GetDependency<IStripeAdapter>()
        .CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>())
        .Throws(new StripeException("API Error"));
    
    // Act & Assert
    await Assert.ThrowsAsync<StripeException>(
        () => sutProvider.Sut.UpdateOrganizationNameAndEmail(organization));
}

👍 Positive Observations

Excellent Test Coverage

  • 14 comprehensive unit tests covering edge cases (7 for organizations, 7 for providers)
  • Tests verify graceful handling of null/empty values
  • Tests confirm that long names are no longer truncated (though this needs verification per blocking issue above)

Follows Bitwarden Standards

  • No security concerns: No sensitive data logged, proper authorization maintained
  • ADR-0026 Compliance: Uses TryAdd DI pattern with constructor injection
  • Structured Logging: Uses proper log levels with organization/provider IDs
  • Separation of Concerns: Billing logic stays in billing services

Clean Architecture

  • ✅ Interface-based design with IOrganizationBillingService and IProviderBillingService
  • ✅ Consistent error handling across all three controllers
  • ✅ Capture-before-modify pattern for detecting changes

Good Refactoring

The removal of the GatewayCustomerId check from OrganizationUpdateCommand.cs:70 is good - this check belongs in the billing service, not the caller. The updated tests reflect this correctly.


📝 Minor Suggestions (Non-blocking)

1. Reduce Code Duplication

The UpdateOrganizationNameAndEmail() and UpdateProviderNameAndEmail() methods have nearly identical structure. Consider extracting common logic into a helper method in a future refactor.

2. Enhanced Test Coverage

  • Test concurrent update scenarios
  • Test with very long names (100+ characters)

3. Test Naming

Minor nitpick: UpdateAsync_WhenGatewayCustomerIdIsNullOrEmpty_CallsBillingUpdateButHandledGracefully could be shortened to UpdateAsync_WithNullGatewayCustomerId_HandledGracefully


Security Review ✅

  • No sensitive data logging: Only organization/provider IDs are logged
  • No PII exposure: Name and email changes are legitimate business operations
  • Proper authorization: All endpoints maintain existing authorization checks
  • No SQL injection risks: Uses existing repository patterns
  • No cryptographic concerns: Only updates billing metadata, not vault data

Questions for Author

  1. 30-Character Limit: Have you tested with organization/provider names longer than 30 characters against Stripe's API? The documentation suggests this limit still exists.

  2. Error Handling: Is the "database succeeds, Stripe fails" approach documented? Should we consider retry logic or monitoring?

  3. Production Monitoring: How will we track Stripe sync failures in production to ensure they're not happening frequently?


Final Verdict

Ready to merge after:

  1. ✅ Confirming Stripe API limits for custom fields and restoring truncation if needed
  2. 🎨 Optional but recommended: Document error handling strategy and add Stripe failure tests

Security: ✅ No concerns
Testing: ✅ Excellent coverage
Architecture: ✅ Follows standards
Code Quality: ⚠️ Blocking issue with 30-char limit removal


@github-actions
Copy link
Contributor

github-actions bot commented Dec 3, 2025

Logo
Checkmarx One – Scan Summary & Details64b71206-f86f-4835-b0b3-ac0a0e67baaa

Fixed Issues (3)

Great job! The following issues were fixed in this Pull Request

Severity Issue Source File / Package
MEDIUM CSRF /src/Api/KeyManagement/Controllers/AccountsKeyManagementController.cs: 97
MEDIUM CSRF /bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs: 87
MEDIUM CSRF /bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs: 97

@codecov
Copy link

codecov bot commented Dec 3, 2025

Codecov Report

❌ Patch coverage is 53.12500% with 45 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.48%. Comparing base (d5f39ea) to head (c827b38).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
...pi/AdminConsole/Controllers/ProvidersController.cs 0.00% 18 Missing ⚠️
...in/AdminConsole/Controllers/ProvidersController.cs 17.64% 14 Missing ⚠️
...dminConsole/Controllers/OrganizationsController.cs 27.77% 12 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6679      +/-   ##
==========================================
- Coverage   53.48%   53.48%   -0.01%     
==========================================
  Files        1918     1918              
  Lines       85563    85650      +87     
  Branches     7678     7683       +5     
==========================================
+ Hits        45762    45808      +46     
- Misses      38030    38071      +41     
  Partials     1771     1771              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

eliykat
eliykat previously approved these changes Dec 3, 2025
Copy link
Member

@eliykat eliykat left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work - one comment below but it's non-blocking.

Comment on lines 81 to 82
if (!string.IsNullOrEmpty(provider.GatewayCustomerId) &&
(originalName != provider.Name || originalBillingEmail != provider.BillingEmail))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 (non-blocking) This bit of boilerplate is repeated a few times. Is there a way to simplify things for the callers?

  • the BillingServices no longer throw if the GatewayCustomerId is null. Can we remove that check from the callers? (That has another benefit, which is that callers don't have to know about the significance of this property, which is effectively billing metadata.)
  • (less sure about this) is there a better way to track / handle the "has the name or billing email changed" logic? It's a shame that cloning objects is so difficult in C#.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed the Customer ID check from the callers here: 8982bcb

With regards to your second bullet, I didn't see a way to do it that would constitute any meaningful difference. Could use a new DTO to capture previous name / email prior to the database update, but that's the same process with a different flavor.

cyprain-okeke
cyprain-okeke previously approved these changes Dec 4, 2025
Copy link
Contributor

@cyprain-okeke cyprain-okeke left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks Great

eliykat
eliykat previously approved these changes Dec 4, 2025
cyprain-okeke
cyprain-okeke previously approved these changes Dec 5, 2025
[BitAutoData("")]
[BitAutoData((string)null)]
public async Task UpdateAsync_WhenGatewayCustomerIdIsNullOrEmpty_SkipsBillingUpdate(
public async Task UpdateAsync_WhenGatewayCustomerIdIsNullOrEmpty_CallsBillingUpdateButHandledGracefully(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove this test case, it's no longer relevant. It's not blocking through, I can do it in a separate PR if desired.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants