Skip to content

Add coupon support to invoice preview and subscription creation#6994

Open
cyprain-okeke wants to merge 8 commits intomainfrom
billing/pm-30272/Subscription-Purchase-Flow-with-Coupon-Support
Open

Add coupon support to invoice preview and subscription creation#6994
cyprain-okeke wants to merge 8 commits intomainfrom
billing/pm-30272/Subscription-Purchase-Flow-with-Coupon-Support

Conversation

@cyprain-okeke
Copy link
Contributor

🎟️ Tracking

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

📔 Objective

This PR adds coupon/discount code support to the subscription purchase flow, enabling users to apply promotional discounts during both
tax preview and subscription creation for Premium and Organization subscriptions.


What's Changed

1. Tax Preview with Coupon Support

Added optional coupon parameter to tax preview endpoints, allowing users to see discounted pricing before purchase:

  • Organization Tax Preview: POST /api/billing/preview-invoice/organization-subscription-purchase-tax
  • Premium Tax Preview: POST /api/billing/preview-invoice/premium-subscription-purchase-tax

Both endpoints now accept an optional coupon field in the request body and return tax calculations with the discount applied.

2. Subscription Creation with Coupon Support

Updated subscription creation endpoints to accept and validate coupon codes:

  • Premium Subscription: POST /api/billing/vnext/accounts/premium-cloud-hosted-subscription
  • Organization Subscription: Handled via OrganizationBillingService.Finalize()

Coupons are validated server-side before being applied to ensure user eligibility.

3. Server-Side Validation

Implemented ISubscriptionDiscountService to validate coupon eligibility:

  • Verifies coupon exists in database
  • Checks if coupon is within valid date range
  • Validates user meets audience targeting criteria (e.g., no previous subscriptions)
  • Returns appropriate error messages for invalid/ineligible coupons

4. Special Case Handling

  • Sponsored Plans: User-provided coupons are ignored (organization gets free plan)
  • Standalone Secrets Manager: System coupon takes precedence over user coupons
  • Invalid Coupons: Returns 400 Bad Request with message: "The coupon code is invalid or you are not eligible for this discount."

Technical Implementation

API Layer:

  • Added Coupon property to request models with [MaxLength(50)] validation
  • Updated controllers to extract and pass coupon parameter to commands
  • Input sanitization: whitespace is trimmed before processing

Business Logic:

  • PreviewOrganizationTaxCommand: Applies coupon to Stripe invoice preview
  • PreviewPremiumTaxCommand: Applies coupon to Stripe invoice preview
  • CreatePremiumCloudHostedSubscriptionCommand: Validates and applies coupon to subscription
  • OrganizationBillingService: Validates and applies coupon to organization subscription

📸 Screenshots

@cyprain-okeke cyprain-okeke requested a review from a team as a code owner February 12, 2026 11:35
@github-actions
Copy link
Contributor

github-actions bot commented Feb 12, 2026

Logo
Checkmarx One – Scan Summary & Detailsc55da753-6bd6-49e6-a049-cc59b802e54f

New Issues (2)

Checkmarx found the following issues in this Pull Request

# Severity Issue Source File / Package Checkmarx Insight
1 MEDIUM CSRF /src/Api/Vault/Controllers/CiphersController.cs: 1527
detailsMethod at line 1527 of /src/Api/Vault/Controllers/CiphersController.cs gets a parameter from a user request from id. This parameter value flows ...
Attack Vector
2 MEDIUM CSRF /src/Api/Vault/Controllers/CiphersController.cs: 1403
detailsMethod at line 1403 of /src/Api/Vault/Controllers/CiphersController.cs gets a parameter from a user request from id. This parameter value flows ...
Attack Vector
Fixed Issues (1)

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

Severity Issue Source File / Package
MEDIUM CSRF /src/Api/Vault/Controllers/CiphersController.cs: 293

@codecov
Copy link

codecov bot commented Feb 12, 2026

Codecov Report

❌ Patch coverage is 30.55556% with 50 lines in your changes missing coverage. Please review.
✅ Project coverage is 56.26%. Comparing base (c15c418) to head (56109c1).

Files with missing lines Patch % Lines
...ces/Implementations/SubscriptionDiscountService.cs 0.00% 18 Missing ⚠️
...nds/CreatePremiumCloudHostedSubscriptionCommand.cs 33.33% 9 Missing and 1 partial ⚠️
...ganizations/Services/OrganizationBillingService.cs 30.00% 6 Missing and 1 partial ⚠️
...pi/Billing/Controllers/PreviewInvoiceController.cs 0.00% 4 Missing ⚠️
...lling/Premium/Commands/PreviewPremiumTaxCommand.cs 40.00% 3 Missing ⚠️
...Controllers/VNext/AccountBillingVNextController.cs 0.00% 2 Missing ⚠️
...s/Premium/PremiumCloudHostedSubscriptionRequest.cs 0.00% 2 Missing ⚠️
...eviewOrganizationSubscriptionPurchaseTaxRequest.cs 0.00% 2 Missing ⚠️
...ce/PreviewPremiumSubscriptionPurchaseTaxRequest.cs 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6994      +/-   ##
==========================================
- Coverage   56.28%   56.26%   -0.02%     
==========================================
  Files        1986     1987       +1     
  Lines       87660    87718      +58     
  Branches     7814     7821       +7     
==========================================
+ Hits        49339    49355      +16     
- Misses      36490    36530      +40     
- Partials     1831     1833       +2     

☔ 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.

Copy link
Contributor

@amorask-bitwarden amorask-bitwarden left a comment

Choose a reason for hiding this comment

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

Good start - few changes we need to clean this up.

For testing, can you:

  1. Add tests for SubscriptionDiscountService
  2. Update the tests for CreatePremiumCloudHostedSubscriptionCommand to validate coupon functionality instead of passing null to all
  3. Update the tests for PreviewPremiumTaxCommand to validate coupon functionality instead of passing null to all

If you want, optionally add more tests for OrganizationBillingService.Finalize to cover your cases.

@@ -0,0 +1,34 @@
#nullable enable
Copy link
Contributor

Choose a reason for hiding this comment

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

❌ Not needed

return discount.AudienceType switch
{
DiscountAudienceType.UserHasNoPreviousSubscriptions =>
!user.Premium && string.IsNullOrEmpty(user.GatewaySubscriptionId),
Copy link
Contributor

Choose a reason for hiding this comment

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

❌ This checks if the user has a premium subscription; not if they've ever had a premium subscription. I think we need to check this and also pull their customer to check for any inactive premium subscriptions from the past.

OrganizationSubscriptionPurchase purchase,
BillingAddress billingAddress);
BillingAddress billingAddress,
string? coupon);
Copy link
Contributor

Choose a reason for hiding this comment

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

❌ This should be part of the OrganizationSubscriptionPurchase.

[FromBody] PremiumCloudHostedSubscriptionRequest request)
{
var (paymentMethod, billingAddress, additionalStorageGb) = request.ToDomain();
var (paymentMethod, billingAddress, additionalStorageGb, coupon) = request.ToDomain();
Copy link
Contributor

Choose a reason for hiding this comment

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

⛏️ At 4 values, I think this is becoming a bit too unwieldy. Can we create a new PremiumSubscriptionPurchase in src/Core/Billing/Premium/Models, similar to the OrganizationSubscriptionPurchase and pass that into the command instead?

{
var (purchase, billingAddress) = request.ToDomain();
var result = await previewOrganizationTaxCommand.Run(purchase, billingAddress);
var (purchase, billingAddress, coupon) = request.ToDomain();
Copy link
Contributor

Choose a reason for hiding this comment

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

coupon should be part of the purchase.

var (organization, customerSetup, subscriptionSetup) = sale;
var (organization, customerSetup, subscriptionSetup, owner) = sale;

if (!string.IsNullOrWhiteSpace(customerSetup?.Coupon) && owner != null)
Copy link
Contributor

Choose a reason for hiding this comment

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

❌ If the owner is set to null in the OrganizationSignup model that constructed the sale, the validation you've added will be bypassed and the coupon will be applied regardless.

Additionally:

  1. This change would break SM Standalone coupon application on release, since that coupon isn't represented as a SubscriptionDiscount nor does it have an audience.
  2. This change applies to all organization sales regardless of tier even though the ticket specifies these coupons are only for users purchasing Premium or Families.

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

public string? Coupon { get; set; }
Copy link
Contributor

Choose a reason for hiding this comment

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

⛏️ The DB only takes 50 characters so we should [MaxLength(50)] this.


public (OrganizationSubscriptionPurchase, BillingAddress) ToDomain() =>
(Purchase.ToDomain(), BillingAddress.ToDomain());
public string? Coupon { get; set; }
Copy link
Contributor

Choose a reason for hiding this comment

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

⛏️ The DB only takes 50 characters so we should [MaxLength(50)] this.

public required MinimalBillingAddressRequest BillingAddress { get; set; }

public (short, BillingAddress) ToDomain() => (AdditionalStorage, BillingAddress.ToDomain());
public string? Coupon { get; set; }
Copy link
Contributor

Choose a reason for hiding this comment

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

⛏️ The DB only takes 50 characters so we should [MaxLength(50)] this.

/// <item>The user meets the audience targeting criteria for the discount</item>
/// </list>
/// </remarks>
Task<bool> ValidateDiscountForUserAsync(User user, string stripeCouponId);
Copy link
Contributor

Choose a reason for hiding this comment

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

❌ The signature and description of this method are too vague in asserting what it actually does. It doesn't just validate a coupon ID for a user; it specifically checks to see if that coupon ID matches a specific discount audience. That needs to be explicit here. Your options are:

  1. Rename the method to emphasize the specific audience the discount is being validated against
  2. Take in an audience as a parameter to validate against

@sonarqubecloud
Copy link

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.

2 participants