mirror of
https://github.com/bitwarden/server
synced 2025-12-16 00:03:54 +00:00
Merge branch 'main' into billing/pm-27605/transition-migration
This commit is contained in:
20
.github/workflows/build.yml
vendored
20
.github/workflows/build.yml
vendored
@@ -46,8 +46,10 @@ jobs:
|
||||
permissions:
|
||||
security-events: write
|
||||
id-token: write
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 5
|
||||
matrix:
|
||||
include:
|
||||
- project_name: Admin
|
||||
@@ -122,7 +124,7 @@ jobs:
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: "**/package-lock.json"
|
||||
@@ -159,7 +161,7 @@ jobs:
|
||||
ls -atlh ../../../
|
||||
|
||||
- name: Upload project artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: ${{ matrix.dotnet }}
|
||||
with:
|
||||
name: ${{ matrix.project_name }}.zip
|
||||
@@ -364,7 +366,7 @@ jobs:
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: docker-stub-US.zip
|
||||
path: docker-stub-US.zip
|
||||
@@ -374,7 +376,7 @@ jobs:
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: docker-stub-EU.zip
|
||||
path: docker-stub-EU.zip
|
||||
@@ -386,21 +388,21 @@ jobs:
|
||||
pwsh ./generate_openapi_files.ps1
|
||||
|
||||
- name: Upload Public API Swagger artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: swagger.json
|
||||
path: api.public.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Internal API Swagger artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: internal.json
|
||||
path: api.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Identity Swagger artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: identity.json
|
||||
path: identity.json
|
||||
@@ -446,7 +448,7 @@ jobs:
|
||||
|
||||
- name: Upload project artifact for Windows
|
||||
if: ${{ contains(matrix.target, 'win') == true }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: MsSqlMigratorUtility-${{ matrix.target }}
|
||||
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
|
||||
@@ -454,7 +456,7 @@ jobs:
|
||||
|
||||
- name: Upload project artifact
|
||||
if: ${{ contains(matrix.target, 'win') == false }}
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: MsSqlMigratorUtility-${{ matrix.target }}
|
||||
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
|
||||
|
||||
1
.github/workflows/review-code.yml
vendored
1
.github/workflows/review-code.yml
vendored
@@ -15,6 +15,7 @@ jobs:
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
|
||||
4
.github/workflows/test-database.yml
vendored
4
.github/workflows/test-database.yml
vendored
@@ -197,7 +197,7 @@ jobs:
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload DACPAC
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: sql.dacpac
|
||||
path: Sql.dacpac
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
shell: pwsh
|
||||
|
||||
- name: Report validation results
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: report.xml
|
||||
path: |
|
||||
|
||||
@@ -4,6 +4,7 @@ using Bit.Api.Models.Request;
|
||||
using Bit.Api.Models.Request.Accounts;
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Api.Utilities;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
@@ -24,7 +25,8 @@ namespace Bit.Api.Billing.Controllers;
|
||||
public class AccountsController(
|
||||
IUserService userService,
|
||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||
IUserAccountKeysQuery userAccountKeysQuery) : Controller
|
||||
IUserAccountKeysQuery userAccountKeysQuery,
|
||||
IFeatureService featureService) : Controller
|
||||
{
|
||||
[HttpPost("premium")]
|
||||
public async Task<PaymentResponseModel> PostPremiumAsync(
|
||||
@@ -84,16 +86,24 @@ public class AccountsController(
|
||||
throw new UnauthorizedAccessException();
|
||||
}
|
||||
|
||||
if (!globalSettings.SelfHosted && user.Gateway != null)
|
||||
// Only cloud-hosted users with payment gateways have subscription and discount information
|
||||
if (!globalSettings.SelfHosted)
|
||||
{
|
||||
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
|
||||
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
|
||||
return new SubscriptionResponseModel(user, subscriptionInfo, license);
|
||||
}
|
||||
else if (!globalSettings.SelfHosted)
|
||||
{
|
||||
var license = await userService.GenerateLicenseAsync(user);
|
||||
return new SubscriptionResponseModel(user, license);
|
||||
if (user.Gateway != null)
|
||||
{
|
||||
// Note: PM23341_Milestone_2 is the feature flag for the overall Milestone 2 initiative (PM-23341).
|
||||
// This specific implementation (PM-26682) adds discount display functionality as part of that initiative.
|
||||
// The feature flag controls the broader Milestone 2 feature set, not just this specific task.
|
||||
var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
|
||||
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
|
||||
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
|
||||
return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount);
|
||||
}
|
||||
else
|
||||
{
|
||||
var license = await userService.GenerateLicenseAsync(user);
|
||||
return new SubscriptionResponseModel(user, license);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Api;
|
||||
@@ -11,7 +9,17 @@ namespace Bit.Api.Models.Response;
|
||||
|
||||
public class SubscriptionResponseModel : ResponseModel
|
||||
{
|
||||
public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license)
|
||||
|
||||
/// <param name="user">The user entity containing storage and premium subscription information</param>
|
||||
/// <param name="subscription">Subscription information retrieved from the payment provider (Stripe/Braintree)</param>
|
||||
/// <param name="license">The user's license containing expiration and feature entitlements</param>
|
||||
/// <param name="includeMilestone2Discount">
|
||||
/// Whether to include discount information in the response.
|
||||
/// Set to true when the PM23341_Milestone_2 feature flag is enabled AND
|
||||
/// you want to expose Milestone 2 discount information to the client.
|
||||
/// The discount will only be included if it matches the specific Milestone 2 coupon ID.
|
||||
/// </param>
|
||||
public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license, bool includeMilestone2Discount = false)
|
||||
: base("subscription")
|
||||
{
|
||||
Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null;
|
||||
@@ -22,9 +30,14 @@ public class SubscriptionResponseModel : ResponseModel
|
||||
MaxStorageGb = user.MaxStorageGb;
|
||||
License = license;
|
||||
Expiration = License.Expires;
|
||||
|
||||
// Only display the Milestone 2 subscription discount on the subscription page.
|
||||
CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription.CustomerDiscount)
|
||||
? new BillingCustomerDiscount(subscription.CustomerDiscount!)
|
||||
: null;
|
||||
}
|
||||
|
||||
public SubscriptionResponseModel(User user, UserLicense license = null)
|
||||
public SubscriptionResponseModel(User user, UserLicense? license = null)
|
||||
: base("subscription")
|
||||
{
|
||||
StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null;
|
||||
@@ -38,21 +51,109 @@ public class SubscriptionResponseModel : ResponseModel
|
||||
}
|
||||
}
|
||||
|
||||
public string StorageName { get; set; }
|
||||
public string? StorageName { get; set; }
|
||||
public double? StorageGb { get; set; }
|
||||
public short? MaxStorageGb { get; set; }
|
||||
public BillingSubscriptionUpcomingInvoice UpcomingInvoice { get; set; }
|
||||
public BillingSubscription Subscription { get; set; }
|
||||
public UserLicense License { get; set; }
|
||||
public BillingSubscriptionUpcomingInvoice? UpcomingInvoice { get; set; }
|
||||
public BillingSubscription? Subscription { get; set; }
|
||||
/// <summary>
|
||||
/// Customer discount information from Stripe for the Milestone 2 subscription discount.
|
||||
/// Only includes the specific Milestone 2 coupon (cm3nHfO1) when it's a perpetual discount (no expiration).
|
||||
/// This is for display purposes only and does not affect Stripe's automatic discount application.
|
||||
/// Other discounts may still apply in Stripe billing but are not included in this response.
|
||||
/// <para>
|
||||
/// Null when:
|
||||
/// - The PM23341_Milestone_2 feature flag is disabled
|
||||
/// - There is no active discount
|
||||
/// - The discount coupon ID doesn't match the Milestone 2 coupon (cm3nHfO1)
|
||||
/// - The instance is self-hosted
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public BillingCustomerDiscount? CustomerDiscount { get; set; }
|
||||
public UserLicense? License { get; set; }
|
||||
public DateTime? Expiration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the Milestone 2 discount should be included in the response.
|
||||
/// </summary>
|
||||
/// <param name="includeMilestone2Discount">Whether the feature flag is enabled and discount should be considered.</param>
|
||||
/// <param name="customerDiscount">The customer discount from subscription info, if any.</param>
|
||||
/// <returns>True if the discount should be included; false otherwise.</returns>
|
||||
private static bool ShouldIncludeMilestone2Discount(
|
||||
bool includeMilestone2Discount,
|
||||
SubscriptionInfo.BillingCustomerDiscount? customerDiscount)
|
||||
{
|
||||
return includeMilestone2Discount &&
|
||||
customerDiscount != null &&
|
||||
customerDiscount.Id == StripeConstants.CouponIDs.Milestone2SubscriptionDiscount &&
|
||||
customerDiscount.Active;
|
||||
}
|
||||
}
|
||||
|
||||
public class BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount)
|
||||
/// <summary>
|
||||
/// Customer discount information from Stripe billing.
|
||||
/// </summary>
|
||||
public class BillingCustomerDiscount
|
||||
{
|
||||
public string Id { get; } = discount.Id;
|
||||
public bool Active { get; } = discount.Active;
|
||||
public decimal? PercentOff { get; } = discount.PercentOff;
|
||||
public List<string> AppliesTo { get; } = discount.AppliesTo;
|
||||
/// <summary>
|
||||
/// The Stripe coupon ID (e.g., "cm3nHfO1").
|
||||
/// </summary>
|
||||
public string? Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the discount is a recurring/perpetual discount with no expiration date.
|
||||
/// <para>
|
||||
/// This property is true only when the discount has no end date, meaning it applies
|
||||
/// indefinitely to all future renewals. This is a product decision for Milestone 2
|
||||
/// to only display perpetual discounts in the UI.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Note: This does NOT indicate whether the discount is "currently active" in the billing sense.
|
||||
/// A discount with a future end date is functionally active and will be applied by Stripe,
|
||||
/// but this property will be false because it has an expiration date.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool Active { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Percentage discount applied to the subscription (e.g., 20.0 for 20% off).
|
||||
/// Null if this is an amount-based discount.
|
||||
/// </summary>
|
||||
public decimal? PercentOff { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Fixed amount discount in USD (e.g., 14.00 for $14 off).
|
||||
/// Converted from Stripe's cent-based values (1400 cents → $14.00).
|
||||
/// Null if this is a percentage-based discount.
|
||||
/// Note: Stripe stores amounts in the smallest currency unit. This value is always in USD.
|
||||
/// </summary>
|
||||
public decimal? AmountOff { get; }
|
||||
|
||||
/// <summary>
|
||||
/// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]).
|
||||
/// <para>
|
||||
/// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe).
|
||||
/// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe).
|
||||
/// Non-empty list: discount applies only to the specified product IDs.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? AppliesTo { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a BillingCustomerDiscount from a SubscriptionInfo.BillingCustomerDiscount.
|
||||
/// </summary>
|
||||
/// <param name="discount">The discount to convert. Must not be null.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when discount is null.</exception>
|
||||
public BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(discount);
|
||||
|
||||
Id = discount.Id;
|
||||
Active = discount.Active;
|
||||
PercentOff = discount.PercentOff;
|
||||
AmountOff = discount.AmountOff;
|
||||
AppliesTo = discount.AppliesTo;
|
||||
}
|
||||
}
|
||||
|
||||
public class BillingSubscription
|
||||
@@ -83,10 +184,10 @@ public class BillingSubscription
|
||||
public DateTime? PeriodEndDate { get; set; }
|
||||
public DateTime? CancelledDate { get; set; }
|
||||
public bool CancelAtEndDate { get; set; }
|
||||
public string Status { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public bool Cancelled { get; set; }
|
||||
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
|
||||
public string CollectionMethod { get; set; }
|
||||
public string? CollectionMethod { get; set; }
|
||||
public DateTime? SuspensionDate { get; set; }
|
||||
public DateTime? UnpaidPeriodEndDate { get; set; }
|
||||
public int? GracePeriod { get; set; }
|
||||
@@ -104,11 +205,11 @@ public class BillingSubscription
|
||||
AddonSubscriptionItem = item.AddonSubscriptionItem;
|
||||
}
|
||||
|
||||
public string ProductId { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string? ProductId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
public string Interval { get; set; }
|
||||
public string? Interval { get; set; }
|
||||
public bool SponsoredSubscriptionItem { get; set; }
|
||||
public bool AddonSubscriptionItem { get; set; }
|
||||
}
|
||||
|
||||
@@ -402,8 +402,9 @@ public class CiphersController : Controller
|
||||
{
|
||||
var org = _currentContext.GetOrganization(organizationId);
|
||||
|
||||
// If we're not an "admin" or if we're not a provider user we don't need to check the ciphers
|
||||
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId))
|
||||
// If we're not an "admin" we don't need to check the ciphers
|
||||
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
|
||||
{ Permissions.EditAnyCollection: true }))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -416,8 +417,9 @@ public class CiphersController : Controller
|
||||
{
|
||||
var org = _currentContext.GetOrganization(organizationId);
|
||||
|
||||
// If we're not an "admin" or if we're a provider user we don't need to check the ciphers
|
||||
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId))
|
||||
// If we're not an "admin" we don't need to check the ciphers
|
||||
if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or
|
||||
{ Permissions.EditAnyCollection: true }))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Payment.Queries;
|
||||
@@ -17,11 +14,13 @@ using Bit.Core.Platform.Mail.Mailer;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Stripe;
|
||||
using static Bit.Core.Billing.Constants.StripeConstants;
|
||||
using Event = Stripe.Event;
|
||||
using Plan = Bit.Core.Models.StaticStore.Plan;
|
||||
|
||||
namespace Bit.Billing.Services.Implementations;
|
||||
|
||||
using static StripeConstants;
|
||||
|
||||
public class UpcomingInvoiceHandler(
|
||||
IGetPaymentMethodQuery getPaymentMethodQuery,
|
||||
ILogger<StripeEventProcessor> logger,
|
||||
@@ -57,204 +56,88 @@ public class UpcomingInvoiceHandler(
|
||||
|
||||
if (organizationId.HasValue)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId.Value);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id);
|
||||
|
||||
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||
|
||||
if (!plan.IsAnnual)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
|
||||
{
|
||||
var sponsorshipIsValid =
|
||||
await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value);
|
||||
|
||||
if (!sponsorshipIsValid)
|
||||
{
|
||||
/*
|
||||
* If the sponsorship is invalid, then the subscription was updated to use the regular families plan
|
||||
* price. Given that this is the case, we need the new invoice amount
|
||||
*/
|
||||
invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
|
||||
}
|
||||
}
|
||||
|
||||
await SendUpcomingInvoiceEmailsAsync(new List<string> { organization.BillingEmail }, invoice);
|
||||
|
||||
/*
|
||||
* TODO: https://bitwarden.atlassian.net/browse/PM-4862
|
||||
* Disabling this as part of a hot fix. It needs to check whether the organization
|
||||
* belongs to a Reseller provider and only send an email to the organization owners if it does.
|
||||
* It also requires a new email template as the current one contains too much billing information.
|
||||
*/
|
||||
|
||||
// var ownerEmails = await _organizationRepository.GetOwnerEmailAddressesById(organization.Id);
|
||||
|
||||
// await SendEmails(ownerEmails);
|
||||
await HandleOrganizationUpcomingInvoiceAsync(
|
||||
organizationId.Value,
|
||||
parsedEvent,
|
||||
invoice,
|
||||
customer,
|
||||
subscription);
|
||||
}
|
||||
else if (userId.HasValue)
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(userId.Value);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subscription.AutomaticTax.Enabled && subscription.Customer.HasRecognizedTaxLocation())
|
||||
{
|
||||
try
|
||||
{
|
||||
await stripeFacade.UpdateSubscription(subscription.Id,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
|
||||
user.Id,
|
||||
parsedEvent.Id);
|
||||
}
|
||||
}
|
||||
|
||||
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
|
||||
if (milestone2Feature)
|
||||
{
|
||||
await UpdateSubscriptionItemPriceIdAsync(parsedEvent, subscription, user);
|
||||
}
|
||||
|
||||
if (user.Premium)
|
||||
{
|
||||
await (milestone2Feature
|
||||
? SendUpdatedUpcomingInvoiceEmailsAsync(new List<string> { user.Email })
|
||||
: SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice));
|
||||
}
|
||||
await HandlePremiumUsersUpcomingInvoiceAsync(
|
||||
userId.Value,
|
||||
parsedEvent,
|
||||
invoice,
|
||||
customer,
|
||||
subscription);
|
||||
}
|
||||
else if (providerId.HasValue)
|
||||
{
|
||||
var provider = await providerRepository.GetByIdAsync(providerId.Value);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await AlignProviderTaxConcernsAsync(provider, subscription, customer, parsedEvent.Id);
|
||||
|
||||
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId.Value);
|
||||
await HandleProviderUpcomingInvoiceAsync(
|
||||
providerId.Value,
|
||||
parsedEvent,
|
||||
invoice,
|
||||
customer,
|
||||
subscription);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateSubscriptionItemPriceIdAsync(Event parsedEvent, Subscription subscription, User user)
|
||||
#region Organizations
|
||||
|
||||
private async Task HandleOrganizationUpcomingInvoiceAsync(
|
||||
Guid organizationId,
|
||||
Event @event,
|
||||
Invoice invoice,
|
||||
Customer customer,
|
||||
Subscription subscription)
|
||||
{
|
||||
var pricingItem =
|
||||
subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually);
|
||||
if (pricingItem != null)
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
try
|
||||
logger.LogWarning("Could not find Organization ({OrganizationID}) for '{EventType}' event ({EventID})",
|
||||
organizationId, @event.Type, @event.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, @event.Id);
|
||||
|
||||
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||
|
||||
var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3);
|
||||
|
||||
await AlignOrganizationSubscriptionConcernsAsync(
|
||||
organization,
|
||||
@event,
|
||||
subscription,
|
||||
plan,
|
||||
milestone3);
|
||||
|
||||
// Don't send the upcoming invoice email unless the organization's on an annual plan.
|
||||
if (!plan.IsAnnual)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (stripeEventUtilityService.IsSponsoredSubscription(subscription))
|
||||
{
|
||||
var sponsorshipIsValid =
|
||||
await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId);
|
||||
|
||||
if (!sponsorshipIsValid)
|
||||
{
|
||||
var plan = await pricingClient.GetAvailablePremiumPlan();
|
||||
await stripeFacade.UpdateSubscription(subscription.Id,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new SubscriptionItemOptions { Id = pricingItem.Id, Price = plan.Seat.StripePriceId }
|
||||
],
|
||||
Discounts =
|
||||
[
|
||||
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount }
|
||||
],
|
||||
ProrationBehavior = "none"
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}",
|
||||
user.Id,
|
||||
parsedEvent.Id);
|
||||
/*
|
||||
* If the sponsorship is invalid, then the subscription was updated to use the regular families plan
|
||||
* price. Given that this is the case, we need the new invoice amount
|
||||
*/
|
||||
invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice)
|
||||
{
|
||||
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
||||
|
||||
var items = invoice.Lines.Select(i => i.Description).ToList();
|
||||
|
||||
if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
|
||||
{
|
||||
await mailService.SendInvoiceUpcoming(
|
||||
validEmails,
|
||||
invoice.AmountDue / 100M,
|
||||
invoice.NextPaymentAttempt.Value,
|
||||
items,
|
||||
true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable<string> emails)
|
||||
{
|
||||
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
||||
var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail
|
||||
{
|
||||
ToEmails = validEmails,
|
||||
View = new UpdatedInvoiceUpcomingView()
|
||||
};
|
||||
await mailer.SendEmail(updatedUpcomingEmail);
|
||||
}
|
||||
|
||||
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice,
|
||||
Subscription subscription, Guid providerId)
|
||||
{
|
||||
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
||||
|
||||
var items = invoice.FormatForProvider(subscription);
|
||||
|
||||
if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
|
||||
{
|
||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||
if (provider == null)
|
||||
{
|
||||
logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId);
|
||||
return;
|
||||
}
|
||||
|
||||
var collectionMethod = subscription.CollectionMethod;
|
||||
var paymentMethod = await getPaymentMethodQuery.Run(provider);
|
||||
|
||||
var hasPaymentMethod = paymentMethod != null;
|
||||
var paymentMethodDescription = paymentMethod?.Match(
|
||||
bankAccount => $"Bank account ending in {bankAccount.Last4}",
|
||||
card => $"{card.Brand} ending in {card.Last4}",
|
||||
payPal => $"PayPal account {payPal.Email}"
|
||||
);
|
||||
|
||||
await mailService.SendProviderInvoiceUpcoming(
|
||||
validEmails,
|
||||
invoice.AmountDue / 100M,
|
||||
invoice.NextPaymentAttempt.Value,
|
||||
items,
|
||||
collectionMethod,
|
||||
hasPaymentMethod,
|
||||
paymentMethodDescription);
|
||||
}
|
||||
await (milestone3
|
||||
? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail])
|
||||
: SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice));
|
||||
}
|
||||
|
||||
private async Task AlignOrganizationTaxConcernsAsync(
|
||||
@@ -305,6 +188,209 @@ public class UpcomingInvoiceHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AlignOrganizationSubscriptionConcernsAsync(
|
||||
Organization organization,
|
||||
Event @event,
|
||||
Subscription subscription,
|
||||
Plan plan,
|
||||
bool milestone3)
|
||||
{
|
||||
if (milestone3 && plan.Type == PlanType.FamiliesAnnually2019)
|
||||
{
|
||||
var passwordManagerItem =
|
||||
subscription.Items.FirstOrDefault(item => item.Price.Id == plan.PasswordManager.StripePlanId);
|
||||
|
||||
if (passwordManagerItem == null)
|
||||
{
|
||||
logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})",
|
||||
organization.Id, @event.Type, @event.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
var families = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually);
|
||||
|
||||
organization.PlanType = families.Type;
|
||||
organization.Plan = families.Name;
|
||||
organization.UsersGetPremium = families.UsersGetPremium;
|
||||
organization.Seats = families.PasswordManager.BaseSeats;
|
||||
|
||||
var options = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Id = passwordManagerItem.Id, Price = families.PasswordManager.StripePlanId
|
||||
}
|
||||
],
|
||||
Discounts =
|
||||
[
|
||||
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount }
|
||||
],
|
||||
ProrationBehavior = ProrationBehavior.None
|
||||
};
|
||||
|
||||
var premiumAccessAddOnItem = subscription.Items.FirstOrDefault(item =>
|
||||
item.Price.Id == plan.PasswordManager.StripePremiumAccessPlanId);
|
||||
|
||||
if (premiumAccessAddOnItem != null)
|
||||
{
|
||||
options.Items.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = premiumAccessAddOnItem.Id,
|
||||
Deleted = true
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await organizationRepository.ReplaceAsync(organization);
|
||||
await stripeFacade.UpdateSubscription(subscription.Id, options);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Failed to align subscription concerns for Organization ({OrganizationID}) while processing '{EventType}' event ({EventID})",
|
||||
organization.Id,
|
||||
@event.Type,
|
||||
@event.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Premium Users
|
||||
|
||||
private async Task HandlePremiumUsersUpcomingInvoiceAsync(
|
||||
Guid userId,
|
||||
Event @event,
|
||||
Invoice invoice,
|
||||
Customer customer,
|
||||
Subscription subscription)
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(userId);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
logger.LogWarning("Could not find User ({UserID}) for '{EventType}' event ({EventID})",
|
||||
userId, @event.Type, @event.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
await AlignPremiumUsersTaxConcernsAsync(user, @event, customer, subscription);
|
||||
|
||||
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
|
||||
if (milestone2Feature)
|
||||
{
|
||||
await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription);
|
||||
}
|
||||
|
||||
if (user.Premium)
|
||||
{
|
||||
await (milestone2Feature
|
||||
? SendUpdatedUpcomingInvoiceEmailsAsync(new List<string> { user.Email })
|
||||
: SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AlignPremiumUsersTaxConcernsAsync(
|
||||
User user,
|
||||
Event @event,
|
||||
Customer customer,
|
||||
Subscription subscription)
|
||||
{
|
||||
if (!subscription.AutomaticTax.Enabled && customer.HasRecognizedTaxLocation())
|
||||
{
|
||||
try
|
||||
{
|
||||
await stripeFacade.UpdateSubscription(subscription.Id,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}",
|
||||
user.Id,
|
||||
@event.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AlignPremiumUsersSubscriptionConcernsAsync(
|
||||
User user,
|
||||
Event @event,
|
||||
Subscription subscription)
|
||||
{
|
||||
var premiumItem = subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually);
|
||||
|
||||
if (premiumItem == null)
|
||||
{
|
||||
logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})",
|
||||
user.Id, @event.Type, @event.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var plan = await pricingClient.GetAvailablePremiumPlan();
|
||||
await stripeFacade.UpdateSubscription(subscription.Id,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new SubscriptionItemOptions { Id = premiumItem.Id, Price = plan.Seat.StripePriceId }
|
||||
],
|
||||
Discounts =
|
||||
[
|
||||
new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount }
|
||||
],
|
||||
ProrationBehavior = ProrationBehavior.None
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogError(
|
||||
exception,
|
||||
"Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}",
|
||||
user.Id,
|
||||
@event.Id);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Providers
|
||||
|
||||
private async Task HandleProviderUpcomingInvoiceAsync(
|
||||
Guid providerId,
|
||||
Event @event,
|
||||
Invoice invoice,
|
||||
Customer customer,
|
||||
Subscription subscription)
|
||||
{
|
||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
logger.LogWarning("Could not find Provider ({ProviderID}) for '{EventType}' event ({EventID})",
|
||||
providerId, @event.Type, @event.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
await AlignProviderTaxConcernsAsync(provider, subscription, customer, @event.Id);
|
||||
|
||||
if (!string.IsNullOrEmpty(provider.BillingEmail))
|
||||
{
|
||||
await SendProviderUpcomingInvoiceEmailsAsync(new List<string> { provider.BillingEmail }, invoice, subscription, providerId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AlignProviderTaxConcernsAsync(
|
||||
Provider provider,
|
||||
Subscription subscription,
|
||||
@@ -349,4 +435,75 @@ public class UpcomingInvoiceHandler(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice,
|
||||
Subscription subscription, Guid providerId)
|
||||
{
|
||||
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
||||
|
||||
var items = invoice.FormatForProvider(subscription);
|
||||
|
||||
if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0)
|
||||
{
|
||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||
if (provider == null)
|
||||
{
|
||||
logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId);
|
||||
return;
|
||||
}
|
||||
|
||||
var collectionMethod = subscription.CollectionMethod;
|
||||
var paymentMethod = await getPaymentMethodQuery.Run(provider);
|
||||
|
||||
var hasPaymentMethod = paymentMethod != null;
|
||||
var paymentMethodDescription = paymentMethod?.Match(
|
||||
bankAccount => $"Bank account ending in {bankAccount.Last4}",
|
||||
card => $"{card.Brand} ending in {card.Last4}",
|
||||
payPal => $"PayPal account {payPal.Email}"
|
||||
);
|
||||
|
||||
await mailService.SendProviderInvoiceUpcoming(
|
||||
validEmails,
|
||||
invoice.AmountDue / 100M,
|
||||
invoice.NextPaymentAttempt.Value,
|
||||
items,
|
||||
collectionMethod,
|
||||
hasPaymentMethod,
|
||||
paymentMethodDescription);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shared
|
||||
|
||||
private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable<string> emails, Invoice invoice)
|
||||
{
|
||||
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
||||
|
||||
var items = invoice.Lines.Select(i => i.Description).ToList();
|
||||
|
||||
if (invoice is { NextPaymentAttempt: not null, AmountDue: > 0 })
|
||||
{
|
||||
await mailService.SendInvoiceUpcoming(
|
||||
validEmails,
|
||||
invoice.AmountDue / 100M,
|
||||
invoice.NextPaymentAttempt.Value,
|
||||
items,
|
||||
true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable<string> emails)
|
||||
{
|
||||
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
||||
var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail
|
||||
{
|
||||
ToEmails = validEmails,
|
||||
View = new UpdatedInvoiceUpcomingView()
|
||||
};
|
||||
await mailer.SendEmail(updatedUpcomingEmail);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
||||
/// <summary>
|
||||
/// Defines behavior and functionality for a given PolicyType.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All methods defined in this interface are for the PolicyService#SavePolicy method. This needs to be supported until
|
||||
/// we successfully refactor policy validators over to policy validation handlers
|
||||
/// </remarks>
|
||||
public interface IPolicyValidator
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -53,6 +53,7 @@ public static class PolicyServiceCollectionExtensions
|
||||
services.AddScoped<IPolicyUpdateEvent, FreeFamiliesForEnterprisePolicyValidator>();
|
||||
services.AddScoped<IPolicyUpdateEvent, OrganizationDataOwnershipPolicyValidator>();
|
||||
services.AddScoped<IPolicyUpdateEvent, UriMatchDefaultPolicyValidator>();
|
||||
services.AddScoped<IPolicyUpdateEvent, AutomaticUserConfirmationPolicyEventHandler>();
|
||||
}
|
||||
|
||||
private static void AddPolicyRequirements(this IServiceCollection services)
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Represents all policies required to be enabled before the given policy can be enabled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This interface is intended for policy event handlers that mandate the activation of other policies
|
||||
/// as prerequisites for enabling the associated policy.
|
||||
/// </remarks>
|
||||
public interface IEnforceDependentPoliciesEvent : IPolicyUpdateEvent
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -3,6 +3,12 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Represents all side effects that should be executed before a policy is upserted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This should be added to policy handlers that need to perform side effects before policy upserts.
|
||||
/// </remarks>
|
||||
public interface IOnPolicyPreUpdateEvent : IPolicyUpdateEvent
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the policy to be upserted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is used for the VNextSavePolicyCommand. All policy handlers should implement this interface.
|
||||
/// </remarks>
|
||||
public interface IPolicyUpdateEvent
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -3,12 +3,17 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Represents all validations that need to be run to enable or disable the given policy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is used for the VNextSavePolicyCommand. This optional but should be implemented for all policies that have
|
||||
/// certain requirements for the given organization.
|
||||
/// </remarks>
|
||||
public interface IPolicyValidationEvent : IPolicyUpdateEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Performs side effects after a policy is validated but before it is saved.
|
||||
/// For example, this can be used to remove non-compliant users from the organization.
|
||||
/// Implementation is optional; by default, it will not perform any side effects.
|
||||
/// Performs any validations required to enable or disable the policy.
|
||||
/// </summary>
|
||||
/// <param name="policyRequest">The policy save request containing the policy update and metadata</param>
|
||||
/// <param name="currentPolicy">The current policy, if any</param>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
|
||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an event handler for the Automatic User Confirmation policy.
|
||||
///
|
||||
/// This class validates that the following conditions are met:
|
||||
/// <ul>
|
||||
/// <li>The Single organization policy is enabled</li>
|
||||
/// <li>All organization users are compliant with the Single organization policy</li>
|
||||
/// <li>No provider users exist</li>
|
||||
/// </ul>
|
||||
///
|
||||
/// This class also performs side effects when the policy is being enabled or disabled. They are:
|
||||
/// <ul>
|
||||
/// <li>Sets the UseAutomaticUserConfirmation organization feature to match the policy update</li>
|
||||
/// </ul>
|
||||
/// </summary>
|
||||
public class AutomaticUserConfirmationPolicyEventHandler(
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IPolicyRepository policyRepository,
|
||||
IOrganizationRepository organizationRepository,
|
||||
TimeProvider timeProvider)
|
||||
: IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent
|
||||
{
|
||||
public PolicyType Type => PolicyType.AutomaticUserConfirmation;
|
||||
public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy) =>
|
||||
await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy);
|
||||
|
||||
private const string _singleOrgPolicyNotEnabledErrorMessage =
|
||||
"The Single organization policy must be enabled before enabling the Automatically confirm invited users policy.";
|
||||
|
||||
private const string _usersNotCompliantWithSingleOrgErrorMessage =
|
||||
"All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations.";
|
||||
|
||||
private const string _providerUsersExistErrorMessage =
|
||||
"The organization has users with the Provider user type. Please remove provider users before enabling the Automatically confirm invited users policy.";
|
||||
|
||||
public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
|
||||
|
||||
public async Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
|
||||
{
|
||||
var isNotEnablingPolicy = policyUpdate is not { Enabled: true };
|
||||
var policyAlreadyEnabled = currentPolicy is { Enabled: true };
|
||||
if (isNotEnablingPolicy || policyAlreadyEnabled)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return await ValidateEnablingPolicyAsync(policyUpdate.OrganizationId);
|
||||
}
|
||||
|
||||
public async Task<string> ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) =>
|
||||
await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy);
|
||||
|
||||
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(policyUpdate.OrganizationId);
|
||||
|
||||
if (organization is not null)
|
||||
{
|
||||
organization.UseAutomaticUserConfirmation = policyUpdate.Enabled;
|
||||
organization.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
|
||||
await organizationRepository.UpsertAsync(organization);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ValidateEnablingPolicyAsync(Guid organizationId)
|
||||
{
|
||||
var singleOrgValidationError = await ValidateSingleOrgPolicyComplianceAsync(organizationId);
|
||||
if (!string.IsNullOrWhiteSpace(singleOrgValidationError))
|
||||
{
|
||||
return singleOrgValidationError;
|
||||
}
|
||||
|
||||
var providerValidationError = await ValidateNoProviderUsersAsync(organizationId);
|
||||
if (!string.IsNullOrWhiteSpace(providerValidationError))
|
||||
{
|
||||
return providerValidationError;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private async Task<string> ValidateSingleOrgPolicyComplianceAsync(Guid organizationId)
|
||||
{
|
||||
var singleOrgPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.SingleOrg);
|
||||
if (singleOrgPolicy is not { Enabled: true })
|
||||
{
|
||||
return _singleOrgPolicyNotEnabledErrorMessage;
|
||||
}
|
||||
|
||||
return await ValidateUserComplianceWithSingleOrgAsync(organizationId);
|
||||
}
|
||||
|
||||
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId)
|
||||
{
|
||||
var organizationUsers = (await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId))
|
||||
.Where(ou => ou.Status != OrganizationUserStatusType.Invited &&
|
||||
ou.Status != OrganizationUserStatusType.Revoked &&
|
||||
ou.UserId.HasValue)
|
||||
.ToList();
|
||||
|
||||
if (organizationUsers.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(
|
||||
organizationUsers.Select(ou => ou.UserId!.Value)))
|
||||
.Any(uo => uo.OrganizationId != organizationId &&
|
||||
uo.Status != OrganizationUserStatusType.Invited);
|
||||
|
||||
return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty;
|
||||
}
|
||||
|
||||
private async Task<string> ValidateNoProviderUsersAsync(Guid organizationId)
|
||||
{
|
||||
var providerUsers = await providerUserRepository.GetManyByOrganizationAsync(organizationId);
|
||||
|
||||
return providerUsers.Count > 0 ? _providerUsersExistErrorMessage : string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,8 @@ public static class StripeConstants
|
||||
{
|
||||
public const string LegacyMSPDiscount = "msp-discount-35";
|
||||
public const string SecretsManagerStandalone = "sm-standalone";
|
||||
public const string Milestone2SubscriptionDiscount = "cm3nHfO1";
|
||||
public const string Milestone2SubscriptionDiscount = "milestone-2c";
|
||||
public const string Milestone3SubscriptionDiscount = "milestone-3";
|
||||
|
||||
public static class MSPDiscounts
|
||||
{
|
||||
|
||||
@@ -104,6 +104,14 @@ public record PlanAdapter : Core.Models.StaticStore.Plan
|
||||
var additionalStoragePricePerGb = plan.Storage?.Price ?? 0;
|
||||
var stripeStoragePlanId = plan.Storage?.StripePriceId;
|
||||
short? maxCollections = plan.AdditionalData.TryGetValue("passwordManager.maxCollections", out var value) ? short.Parse(value) : null;
|
||||
var stripePremiumAccessPlanId =
|
||||
plan.AdditionalData.TryGetValue("premiumAccessAddOnPriceId", out var premiumAccessAddOnPriceIdValue)
|
||||
? premiumAccessAddOnPriceIdValue
|
||||
: null;
|
||||
var premiumAccessOptionPrice =
|
||||
plan.AdditionalData.TryGetValue("premiumAccessAddOnPriceAmount", out var premiumAccessAddOnPriceAmountValue)
|
||||
? decimal.Parse(premiumAccessAddOnPriceAmountValue)
|
||||
: 0;
|
||||
|
||||
return new PasswordManagerPlanFeatures
|
||||
{
|
||||
@@ -121,7 +129,9 @@ public record PlanAdapter : Core.Models.StaticStore.Plan
|
||||
HasAdditionalStorageOption = hasAdditionalStorageOption,
|
||||
AdditionalStoragePricePerGb = additionalStoragePricePerGb,
|
||||
StripeStoragePlanId = stripeStoragePlanId,
|
||||
MaxCollections = maxCollections
|
||||
MaxCollections = maxCollections,
|
||||
StripePremiumAccessPlanId = stripePremiumAccessPlanId,
|
||||
PremiumAccessOptionPrice = premiumAccessOptionPrice
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,58 +1,118 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Stripe;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Bit.Core.Models.Business;
|
||||
|
||||
public class SubscriptionInfo
|
||||
{
|
||||
public BillingCustomerDiscount CustomerDiscount { get; set; }
|
||||
public BillingSubscription Subscription { get; set; }
|
||||
public BillingUpcomingInvoice UpcomingInvoice { get; set; }
|
||||
/// <summary>
|
||||
/// Converts Stripe's minor currency units (cents) to major currency units (dollars).
|
||||
/// IMPORTANT: Only supports USD. All Bitwarden subscriptions are USD-only.
|
||||
/// </summary>
|
||||
private const decimal StripeMinorUnitDivisor = 100M;
|
||||
|
||||
/// <summary>
|
||||
/// Converts Stripe's minor currency units (cents) to major currency units (dollars).
|
||||
/// Preserves null semantics to distinguish between "no amount" (null) and "zero amount" (0.00m).
|
||||
/// </summary>
|
||||
/// <param name="amountInCents">The amount in Stripe's minor currency units (e.g., cents for USD).</param>
|
||||
/// <returns>The amount in major currency units (e.g., dollars for USD), or null if the input is null.</returns>
|
||||
private static decimal? ConvertFromStripeMinorUnits(long? amountInCents)
|
||||
{
|
||||
return amountInCents.HasValue ? amountInCents.Value / StripeMinorUnitDivisor : null;
|
||||
}
|
||||
|
||||
public BillingCustomerDiscount? CustomerDiscount { get; set; }
|
||||
public BillingSubscription? Subscription { get; set; }
|
||||
public BillingUpcomingInvoice? UpcomingInvoice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Represents customer discount information from Stripe billing.
|
||||
/// </summary>
|
||||
public class BillingCustomerDiscount
|
||||
{
|
||||
public BillingCustomerDiscount() { }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a BillingCustomerDiscount from a Stripe Discount object.
|
||||
/// </summary>
|
||||
/// <param name="discount">The Stripe discount containing coupon and expiration information.</param>
|
||||
public BillingCustomerDiscount(Discount discount)
|
||||
{
|
||||
Id = discount.Coupon?.Id;
|
||||
// Active = true only for perpetual/recurring discounts (no end date)
|
||||
// This is intentional for Milestone 2 - only perpetual discounts are shown in UI
|
||||
Active = discount.End == null;
|
||||
PercentOff = discount.Coupon?.PercentOff;
|
||||
AppliesTo = discount.Coupon?.AppliesTo?.Products ?? [];
|
||||
AmountOff = ConvertFromStripeMinorUnits(discount.Coupon?.AmountOff);
|
||||
// Stripe's CouponAppliesTo.Products is already IReadOnlyList<string>, so no conversion needed
|
||||
AppliesTo = discount.Coupon?.AppliesTo?.Products;
|
||||
}
|
||||
|
||||
public string Id { get; set; }
|
||||
/// <summary>
|
||||
/// The Stripe coupon ID (e.g., "cm3nHfO1").
|
||||
/// Note: Only specific coupon IDs are displayed in the UI based on feature flag configuration,
|
||||
/// though Stripe may apply additional discounts that are not shown.
|
||||
/// </summary>
|
||||
public string? Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True only for perpetual/recurring discounts (End == null).
|
||||
/// False for any discount with an expiration date, even if not yet expired.
|
||||
/// Product decision for Milestone 2: only show perpetual discounts in UI.
|
||||
/// </summary>
|
||||
public bool Active { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Percentage discount applied to the subscription (e.g., 20.0 for 20% off).
|
||||
/// Null if this is an amount-based discount.
|
||||
/// </summary>
|
||||
public decimal? PercentOff { get; set; }
|
||||
public List<string> AppliesTo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fixed amount discount in USD (e.g., 14.00 for $14 off).
|
||||
/// Converted from Stripe's cent-based values (1400 cents → $14.00).
|
||||
/// Null if this is a percentage-based discount.
|
||||
/// </summary>
|
||||
public decimal? AmountOff { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]).
|
||||
/// <para>
|
||||
/// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe).
|
||||
/// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe).
|
||||
/// Non-empty list: discount applies only to the specified product IDs.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? AppliesTo { get; set; }
|
||||
}
|
||||
|
||||
public class BillingSubscription
|
||||
{
|
||||
public BillingSubscription(Subscription sub)
|
||||
{
|
||||
Status = sub.Status;
|
||||
TrialStartDate = sub.TrialStart;
|
||||
TrialEndDate = sub.TrialEnd;
|
||||
var currentPeriod = sub.GetCurrentPeriod();
|
||||
Status = sub?.Status;
|
||||
TrialStartDate = sub?.TrialStart;
|
||||
TrialEndDate = sub?.TrialEnd;
|
||||
var currentPeriod = sub?.GetCurrentPeriod();
|
||||
if (currentPeriod != null)
|
||||
{
|
||||
var (start, end) = currentPeriod.Value;
|
||||
PeriodStartDate = start;
|
||||
PeriodEndDate = end;
|
||||
}
|
||||
CancelledDate = sub.CanceledAt;
|
||||
CancelAtEndDate = sub.CancelAtPeriodEnd;
|
||||
Cancelled = sub.Status == "canceled" || sub.Status == "unpaid" || sub.Status == "incomplete_expired";
|
||||
if (sub.Items?.Data != null)
|
||||
CancelledDate = sub?.CanceledAt;
|
||||
CancelAtEndDate = sub?.CancelAtPeriodEnd ?? false;
|
||||
var status = sub?.Status;
|
||||
Cancelled = status == "canceled" || status == "unpaid" || status == "incomplete_expired";
|
||||
if (sub?.Items?.Data != null)
|
||||
{
|
||||
Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i));
|
||||
}
|
||||
CollectionMethod = sub.CollectionMethod;
|
||||
GracePeriod = sub.CollectionMethod == "charge_automatically"
|
||||
CollectionMethod = sub?.CollectionMethod;
|
||||
GracePeriod = sub?.CollectionMethod == "charge_automatically"
|
||||
? 14
|
||||
: 30;
|
||||
}
|
||||
@@ -64,10 +124,10 @@ public class SubscriptionInfo
|
||||
public TimeSpan? PeriodDuration => PeriodEndDate - PeriodStartDate;
|
||||
public DateTime? CancelledDate { get; set; }
|
||||
public bool CancelAtEndDate { get; set; }
|
||||
public string Status { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public bool Cancelled { get; set; }
|
||||
public IEnumerable<BillingSubscriptionItem> Items { get; set; } = new List<BillingSubscriptionItem>();
|
||||
public string CollectionMethod { get; set; }
|
||||
public string? CollectionMethod { get; set; }
|
||||
public DateTime? SuspensionDate { get; set; }
|
||||
public DateTime? UnpaidPeriodEndDate { get; set; }
|
||||
public int GracePeriod { get; set; }
|
||||
@@ -80,7 +140,7 @@ public class SubscriptionInfo
|
||||
{
|
||||
ProductId = item.Plan.ProductId;
|
||||
Name = item.Plan.Nickname;
|
||||
Amount = item.Plan.Amount.GetValueOrDefault() / 100M;
|
||||
Amount = ConvertFromStripeMinorUnits(item.Plan.Amount) ?? 0;
|
||||
Interval = item.Plan.Interval;
|
||||
|
||||
if (item.Metadata != null)
|
||||
@@ -90,15 +150,15 @@ public class SubscriptionInfo
|
||||
}
|
||||
|
||||
Quantity = (int)item.Quantity;
|
||||
SponsoredSubscriptionItem = Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id);
|
||||
SponsoredSubscriptionItem = item.Plan != null && Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id);
|
||||
}
|
||||
|
||||
public bool AddonSubscriptionItem { get; set; }
|
||||
public string ProductId { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string? ProductId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
public string Interval { get; set; }
|
||||
public string? Interval { get; set; }
|
||||
public bool SponsoredSubscriptionItem { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -109,7 +169,7 @@ public class SubscriptionInfo
|
||||
|
||||
public BillingUpcomingInvoice(Invoice inv)
|
||||
{
|
||||
Amount = inv.AmountDue / 100M;
|
||||
Amount = ConvertFromStripeMinorUnits(inv.AmountDue) ?? 0;
|
||||
Date = inv.Created;
|
||||
}
|
||||
|
||||
|
||||
@@ -643,9 +643,21 @@ public class StripePaymentService : IPaymentService
|
||||
var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId,
|
||||
new SubscriptionGetOptions { Expand = ["customer.discount.coupon.applies_to", "discounts.coupon.applies_to", "test_clock"] });
|
||||
|
||||
if (subscription == null)
|
||||
{
|
||||
return subscriptionInfo;
|
||||
}
|
||||
|
||||
subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(subscription);
|
||||
|
||||
var discount = subscription.Customer.Discount ?? subscription.Discounts.FirstOrDefault();
|
||||
// Discount selection priority:
|
||||
// 1. Customer-level discount (applies to all subscriptions for the customer)
|
||||
// 2. First subscription-level discount (if multiple exist, FirstOrDefault() selects the first one)
|
||||
// Note: When multiple subscription-level discounts exist, only the first one is used.
|
||||
// This matches Stripe's behavior where the first discount in the list is applied.
|
||||
// Defensive null checks: Even though we expand "customer" and "discounts", external APIs
|
||||
// may not always return the expected data structure, so we use null-safe operators.
|
||||
var discount = subscription.Customer?.Discount ?? subscription.Discounts?.FirstOrDefault();
|
||||
|
||||
if (discount != null)
|
||||
{
|
||||
|
||||
800
test/Api.Test/Billing/Controllers/AccountsControllerTests.cs
Normal file
800
test/Api.Test/Billing/Controllers/AccountsControllerTests.cs
Normal file
@@ -0,0 +1,800 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Api.Billing.Controllers;
|
||||
using Bit.Core;
|
||||
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.KeyManagement.Queries.Interfaces;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Test.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NSubstitute;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.Test.Billing.Controllers;
|
||||
|
||||
[SubscriptionInfoCustomize]
|
||||
public class AccountsControllerTests : IDisposable
|
||||
{
|
||||
private const string TestMilestone2CouponId = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount;
|
||||
|
||||
private readonly IUserService _userService;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
|
||||
private readonly IUserAccountKeysQuery _userAccountKeysQuery;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
private readonly AccountsController _sut;
|
||||
|
||||
public AccountsControllerTests()
|
||||
{
|
||||
_userService = Substitute.For<IUserService>();
|
||||
_featureService = Substitute.For<IFeatureService>();
|
||||
_paymentService = Substitute.For<IPaymentService>();
|
||||
_twoFactorIsEnabledQuery = Substitute.For<ITwoFactorIsEnabledQuery>();
|
||||
_userAccountKeysQuery = Substitute.For<IUserAccountKeysQuery>();
|
||||
_globalSettings = new GlobalSettings { SelfHosted = false };
|
||||
|
||||
_sut = new AccountsController(
|
||||
_userService,
|
||||
_twoFactorIsEnabledQuery,
|
||||
_userAccountKeysQuery,
|
||||
_featureService
|
||||
);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_sut?.Dispose();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WhenFeatureFlagEnabled_IncludesDiscount(
|
||||
User user,
|
||||
SubscriptionInfo subscriptionInfo,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange
|
||||
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = TestMilestone2CouponId,
|
||||
Active = true,
|
||||
PercentOff = 20m,
|
||||
AmountOff = null,
|
||||
AppliesTo = new List<string> { "product1" }
|
||||
};
|
||||
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
|
||||
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
|
||||
|
||||
user.Gateway = GatewayType.Stripe; // User has payment gateway
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
|
||||
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WhenFeatureFlagDisabled_ExcludesDiscount(
|
||||
User user,
|
||||
SubscriptionInfo subscriptionInfo,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange
|
||||
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = TestMilestone2CouponId,
|
||||
Active = true,
|
||||
PercentOff = 20m,
|
||||
AmountOff = null,
|
||||
AppliesTo = new List<string> { "product1" }
|
||||
};
|
||||
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false);
|
||||
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
|
||||
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
|
||||
|
||||
user.Gateway = GatewayType.Stripe; // User has payment gateway
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.CustomerDiscount); // Should be null when feature flag is disabled
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WithNonMatchingCouponId_ExcludesDiscount(
|
||||
User user,
|
||||
SubscriptionInfo subscriptionInfo,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange
|
||||
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = "different-coupon-id", // Non-matching coupon ID
|
||||
Active = true,
|
||||
PercentOff = 20m,
|
||||
AmountOff = null,
|
||||
AppliesTo = new List<string> { "product1" }
|
||||
};
|
||||
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
|
||||
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
|
||||
|
||||
user.Gateway = GatewayType.Stripe; // User has payment gateway
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.CustomerDiscount); // Should be null when coupon ID doesn't match
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WhenSelfHosted_ReturnsBasicResponse(User user)
|
||||
{
|
||||
// Arrange
|
||||
var selfHostedSettings = new GlobalSettings { SelfHosted = true };
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WhenNoGateway_ExcludesDiscount(User user, UserLicense license)
|
||||
{
|
||||
// Arrange
|
||||
user.Gateway = null; // No gateway configured
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
_userService.GenerateLicenseAsync(user).Returns(license);
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.CustomerDiscount); // Should be null when no gateway
|
||||
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WithInactiveDiscount_ExcludesDiscount(
|
||||
User user,
|
||||
SubscriptionInfo subscriptionInfo,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange
|
||||
subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = TestMilestone2CouponId,
|
||||
Active = false, // Inactive discount
|
||||
PercentOff = 20m,
|
||||
AmountOff = null,
|
||||
AppliesTo = new List<string> { "product1" }
|
||||
};
|
||||
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
|
||||
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
|
||||
|
||||
user.Gateway = GatewayType.Stripe; // User has payment gateway
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.CustomerDiscount); // Should be null when discount is inactive
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_FullPipeline_ConvertsStripeDiscountToApiResponse(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange - Create a Stripe Discount object with real structure
|
||||
var stripeDiscount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = TestMilestone2CouponId,
|
||||
PercentOff = 25m,
|
||||
AmountOff = 1400, // 1400 cents = $14.00
|
||||
AppliesTo = new CouponAppliesTo
|
||||
{
|
||||
Products = new List<string> { "prod_premium", "prod_families" }
|
||||
}
|
||||
},
|
||||
End = null // Active discount
|
||||
};
|
||||
|
||||
// Convert Stripe Discount to BillingCustomerDiscount (simulating what StripePaymentService does)
|
||||
var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
|
||||
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
CustomerDiscount = billingDiscount
|
||||
};
|
||||
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
|
||||
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
|
||||
|
||||
user.Gateway = GatewayType.Stripe;
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
|
||||
|
||||
// Assert - Verify full pipeline conversion
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
|
||||
// Verify Stripe data correctly converted to API response
|
||||
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
|
||||
Assert.True(result.CustomerDiscount.Active);
|
||||
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
|
||||
|
||||
// Verify cents-to-dollars conversion (1400 cents -> $14.00)
|
||||
Assert.Equal(14.00m, result.CustomerDiscount.AmountOff);
|
||||
|
||||
// Verify AppliesTo products are preserved
|
||||
Assert.NotNull(result.CustomerDiscount.AppliesTo);
|
||||
Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count());
|
||||
Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo);
|
||||
Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_FullPipeline_WithFeatureFlagToggle_ControlsVisibility(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange - Create Stripe Discount
|
||||
var stripeDiscount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = TestMilestone2CouponId,
|
||||
PercentOff = 20m
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
CustomerDiscount = billingDiscount
|
||||
};
|
||||
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
|
||||
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
|
||||
user.Gateway = GatewayType.Stripe;
|
||||
|
||||
// Act & Assert - Feature flag ENABLED
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||
var resultWithFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
|
||||
Assert.NotNull(resultWithFlag.CustomerDiscount);
|
||||
|
||||
// Act & Assert - Feature flag DISABLED
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false);
|
||||
var resultWithoutFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
|
||||
Assert.Null(resultWithoutFlag.CustomerDiscount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineFromStripeToApiResponse(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange - Create a real Stripe Discount object as it would come from Stripe API
|
||||
var stripeDiscount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = TestMilestone2CouponId,
|
||||
PercentOff = 30m,
|
||||
AmountOff = 2000, // 2000 cents = $20.00
|
||||
AppliesTo = new CouponAppliesTo
|
||||
{
|
||||
Products = new List<string> { "prod_premium", "prod_families", "prod_teams" }
|
||||
}
|
||||
},
|
||||
End = null // Active discount (no end date)
|
||||
};
|
||||
|
||||
// Step 1: Map Stripe Discount through SubscriptionInfo.BillingCustomerDiscount
|
||||
// This simulates what StripePaymentService.GetSubscriptionAsync does
|
||||
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
|
||||
|
||||
// Verify the mapping worked correctly
|
||||
Assert.Equal(TestMilestone2CouponId, billingCustomerDiscount.Id);
|
||||
Assert.True(billingCustomerDiscount.Active);
|
||||
Assert.Equal(30m, billingCustomerDiscount.PercentOff);
|
||||
Assert.Equal(20.00m, billingCustomerDiscount.AmountOff); // Converted from cents
|
||||
Assert.NotNull(billingCustomerDiscount.AppliesTo);
|
||||
Assert.Equal(3, billingCustomerDiscount.AppliesTo.Count);
|
||||
|
||||
// Step 2: Create SubscriptionInfo with the mapped discount
|
||||
// This simulates what StripePaymentService returns
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
CustomerDiscount = billingCustomerDiscount
|
||||
};
|
||||
|
||||
// Step 3: Set up controller dependencies
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
|
||||
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
|
||||
user.Gateway = GatewayType.Stripe;
|
||||
|
||||
// Act - Step 4: Call AccountsController.GetSubscriptionAsync
|
||||
// This exercises the complete pipeline:
|
||||
// - Retrieves subscriptionInfo from paymentService (with discount from Stripe)
|
||||
// - Maps through SubscriptionInfo.BillingCustomerDiscount (already done above)
|
||||
// - Filters in SubscriptionResponseModel constructor (based on feature flag, coupon ID, active status)
|
||||
// - Returns via AccountsController
|
||||
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
|
||||
|
||||
// Assert - Verify the complete pipeline worked end-to-end
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
|
||||
// Verify Stripe Discount → SubscriptionInfo.BillingCustomerDiscount mapping
|
||||
// (verified above, but confirming it made it through)
|
||||
|
||||
// Verify SubscriptionInfo.BillingCustomerDiscount → SubscriptionResponseModel.BillingCustomerDiscount filtering
|
||||
// The filter should pass because:
|
||||
// - includeMilestone2Discount = true (feature flag enabled)
|
||||
// - subscription.CustomerDiscount != null
|
||||
// - subscription.CustomerDiscount.Id == Milestone2SubscriptionDiscount
|
||||
// - subscription.CustomerDiscount.Active = true
|
||||
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
|
||||
Assert.True(result.CustomerDiscount.Active);
|
||||
Assert.Equal(30m, result.CustomerDiscount.PercentOff);
|
||||
Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Verify cents-to-dollars conversion
|
||||
|
||||
// Verify AppliesTo products are preserved through the entire pipeline
|
||||
Assert.NotNull(result.CustomerDiscount.AppliesTo);
|
||||
Assert.Equal(3, result.CustomerDiscount.AppliesTo.Count());
|
||||
Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo);
|
||||
Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo);
|
||||
Assert.Contains("prod_teams", result.CustomerDiscount.AppliesTo);
|
||||
|
||||
// Verify the payment service was called correctly
|
||||
await _paymentService.Received(1).GetSubscriptionAsync(user);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_IntegrationTest_MultipleDiscountsInSubscription_PrefersCustomerDiscount(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange - Create Stripe subscription with multiple discounts
|
||||
// Customer discount should be preferred over subscription discounts
|
||||
var customerDiscount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = TestMilestone2CouponId,
|
||||
PercentOff = 30m,
|
||||
AmountOff = null
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
var subscriptionDiscount1 = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = "other-coupon-1",
|
||||
PercentOff = 10m
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
var subscriptionDiscount2 = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = "other-coupon-2",
|
||||
PercentOff = 15m
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
// Map through SubscriptionInfo.BillingCustomerDiscount
|
||||
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customerDiscount);
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
CustomerDiscount = billingCustomerDiscount
|
||||
};
|
||||
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
|
||||
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
|
||||
user.Gateway = GatewayType.Stripe;
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
|
||||
|
||||
// Assert - Should use customer discount, not subscription discounts
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
|
||||
Assert.Equal(30m, result.CustomerDiscount.PercentOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_IntegrationTest_BothPercentOffAndAmountOffPresent_HandlesEdgeCase(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange - Edge case: Stripe coupon with both PercentOff and AmountOff
|
||||
// This tests the scenario mentioned in BillingCustomerDiscountTests.cs line 212-232
|
||||
var stripeDiscount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = TestMilestone2CouponId,
|
||||
PercentOff = 25m,
|
||||
AmountOff = 2000, // 2000 cents = $20.00
|
||||
AppliesTo = new CouponAppliesTo
|
||||
{
|
||||
Products = new List<string> { "prod_premium" }
|
||||
}
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
// Map through SubscriptionInfo.BillingCustomerDiscount
|
||||
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
CustomerDiscount = billingCustomerDiscount
|
||||
};
|
||||
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
|
||||
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
|
||||
user.Gateway = GatewayType.Stripe;
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
|
||||
|
||||
// Assert - Both values should be preserved through the pipeline
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
|
||||
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
|
||||
Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Converted from cents
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_IntegrationTest_BillingSubscriptionMapsThroughPipeline(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange - Create Stripe subscription with subscription details
|
||||
var stripeSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = "active",
|
||||
TrialStart = DateTime.UtcNow.AddDays(-30),
|
||||
TrialEnd = DateTime.UtcNow.AddDays(-20),
|
||||
CanceledAt = null,
|
||||
CancelAtPeriodEnd = false,
|
||||
CollectionMethod = "charge_automatically"
|
||||
};
|
||||
|
||||
// Map through SubscriptionInfo.BillingSubscription
|
||||
var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription);
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
Subscription = billingSubscription,
|
||||
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = TestMilestone2CouponId,
|
||||
Active = true,
|
||||
PercentOff = 20m
|
||||
}
|
||||
};
|
||||
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
|
||||
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
|
||||
user.Gateway = GatewayType.Stripe;
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
|
||||
|
||||
// Assert - Verify BillingSubscription mapped through pipeline
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.Subscription);
|
||||
Assert.Equal("active", result.Subscription.Status);
|
||||
Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_IntegrationTest_BillingUpcomingInvoiceMapsThroughPipeline(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange - Create Stripe invoice for upcoming invoice
|
||||
var stripeInvoice = new Invoice
|
||||
{
|
||||
AmountDue = 2000, // 2000 cents = $20.00
|
||||
Created = DateTime.UtcNow.AddDays(1)
|
||||
};
|
||||
|
||||
// Map through SubscriptionInfo.BillingUpcomingInvoice
|
||||
var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice);
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
UpcomingInvoice = billingUpcomingInvoice,
|
||||
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = TestMilestone2CouponId,
|
||||
Active = true,
|
||||
PercentOff = 20m
|
||||
}
|
||||
};
|
||||
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
|
||||
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
|
||||
user.Gateway = GatewayType.Stripe;
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
|
||||
|
||||
// Assert - Verify BillingUpcomingInvoice mapped through pipeline
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result.UpcomingInvoice);
|
||||
Assert.Equal(20.00m, result.UpcomingInvoice.Amount); // Converted from cents
|
||||
Assert.NotNull(result.UpcomingInvoice.Date);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineWithAllComponents(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange - Complete Stripe objects for full pipeline test
|
||||
var stripeDiscount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = TestMilestone2CouponId,
|
||||
PercentOff = 20m,
|
||||
AmountOff = 1000, // $10.00
|
||||
AppliesTo = new CouponAppliesTo
|
||||
{
|
||||
Products = new List<string> { "prod_premium", "prod_families" }
|
||||
}
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
var stripeSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = "active",
|
||||
CollectionMethod = "charge_automatically"
|
||||
};
|
||||
|
||||
var stripeInvoice = new Invoice
|
||||
{
|
||||
AmountDue = 1500, // $15.00
|
||||
Created = DateTime.UtcNow.AddDays(7)
|
||||
};
|
||||
|
||||
// Map through SubscriptionInfo (simulating StripePaymentService)
|
||||
var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount);
|
||||
var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription);
|
||||
var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice);
|
||||
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
CustomerDiscount = billingCustomerDiscount,
|
||||
Subscription = billingSubscription,
|
||||
UpcomingInvoice = billingUpcomingInvoice
|
||||
};
|
||||
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true);
|
||||
_paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo);
|
||||
_userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license);
|
||||
user.Gateway = GatewayType.Stripe;
|
||||
|
||||
// Act - Full pipeline: Stripe → SubscriptionInfo → SubscriptionResponseModel → API response
|
||||
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
|
||||
|
||||
// Assert - Verify all components mapped correctly through the pipeline
|
||||
Assert.NotNull(result);
|
||||
|
||||
// Verify discount
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id);
|
||||
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
|
||||
Assert.Equal(10.00m, result.CustomerDiscount.AmountOff);
|
||||
Assert.NotNull(result.CustomerDiscount.AppliesTo);
|
||||
Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count());
|
||||
|
||||
// Verify subscription
|
||||
Assert.NotNull(result.Subscription);
|
||||
Assert.Equal("active", result.Subscription.Status);
|
||||
Assert.Equal(14, result.Subscription.GracePeriod);
|
||||
|
||||
// Verify upcoming invoice
|
||||
Assert.NotNull(result.UpcomingInvoice);
|
||||
Assert.Equal(15.00m, result.UpcomingInvoice.Amount);
|
||||
Assert.NotNull(result.UpcomingInvoice.Date);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_SelfHosted_WithDiscountFlagEnabled_NeverIncludesDiscount(User user)
|
||||
{
|
||||
// Arrange - Self-hosted user with discount flag enabled (should still return null)
|
||||
var selfHostedSettings = new GlobalSettings { SelfHosted = true };
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService);
|
||||
|
||||
// Assert - Should never include discount for self-hosted, even with flag enabled
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_NullGateway_WithDiscountFlagEnabled_NeverIncludesDiscount(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange - User with null gateway and discount flag enabled (should still return null)
|
||||
user.Gateway = null; // No gateway configured
|
||||
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
_sut.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext { User = claimsPrincipal }
|
||||
};
|
||||
_userService.GetUserByPrincipalAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);
|
||||
_userService.GenerateLicenseAsync(user).Returns(license);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled
|
||||
|
||||
// Act
|
||||
var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService);
|
||||
|
||||
// Assert - Should never include discount when no gateway, even with flag enabled
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any<User>());
|
||||
}
|
||||
}
|
||||
400
test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs
Normal file
400
test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs
Normal file
@@ -0,0 +1,400 @@
|
||||
using Bit.Api.Models.Response;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Models.Business;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Api.Test.Models.Response;
|
||||
|
||||
public class SubscriptionResponseModelTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_IncludeMilestone2DiscountTrueMatchingCouponId_ReturnsDiscount(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID
|
||||
Active = true,
|
||||
PercentOff = 20m,
|
||||
AmountOff = null,
|
||||
AppliesTo = new List<string> { "product1" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
|
||||
Assert.True(result.CustomerDiscount.Active);
|
||||
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
|
||||
Assert.Null(result.CustomerDiscount.AmountOff);
|
||||
Assert.NotNull(result.CustomerDiscount.AppliesTo);
|
||||
Assert.Single(result.CustomerDiscount.AppliesTo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_IncludeMilestone2DiscountTrueNonMatchingCouponId_ReturnsNull(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = "different-coupon-id", // Non-matching coupon ID
|
||||
Active = true,
|
||||
PercentOff = 20m,
|
||||
AmountOff = null,
|
||||
AppliesTo = new List<string> { "product1" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_IncludeMilestone2DiscountFalseMatchingCouponId_ReturnsNull(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID
|
||||
Active = true,
|
||||
PercentOff = 20m,
|
||||
AmountOff = null,
|
||||
AppliesTo = new List<string> { "product1" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: false);
|
||||
|
||||
// Assert - Should be null because includeMilestone2Discount is false
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_NullCustomerDiscount_ReturnsNull(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
CustomerDiscount = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_AmountOffDiscountMatchingCouponId_ReturnsDiscount(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
|
||||
Active = true,
|
||||
PercentOff = null,
|
||||
AmountOff = 14.00m, // Already converted from cents in BillingCustomerDiscount
|
||||
AppliesTo = new List<string>()
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
|
||||
Assert.Null(result.CustomerDiscount.PercentOff);
|
||||
Assert.Equal(14.00m, result.CustomerDiscount.AmountOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_DefaultIncludeMilestone2DiscountParameter_ReturnsNull(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
|
||||
Active = true,
|
||||
PercentOff = 20m
|
||||
}
|
||||
};
|
||||
|
||||
// Act - Using default parameter (includeMilestone2Discount defaults to false)
|
||||
var result = new SubscriptionResponseModel(user, subscriptionInfo, license);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_NullDiscountIdIncludeMilestone2DiscountTrue_ReturnsNull(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = null, // Null discount ID
|
||||
Active = true,
|
||||
PercentOff = 20m,
|
||||
AmountOff = null,
|
||||
AppliesTo = new List<string> { "product1" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_MatchingCouponIdInactiveDiscount_ReturnsNull(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID
|
||||
Active = false, // Inactive discount
|
||||
PercentOff = 20m,
|
||||
AmountOff = null,
|
||||
AppliesTo = new List<string> { "product1" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_UserOnly_SetsBasicProperties(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Storage = 5368709120; // 5 GB in bytes
|
||||
user.MaxStorageGb = (short)10;
|
||||
user.PremiumExpirationDate = DateTime.UtcNow.AddMonths(12);
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionResponseModel(user);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.StorageName);
|
||||
Assert.Equal(5.0, result.StorageGb);
|
||||
Assert.Equal((short)10, result.MaxStorageGb);
|
||||
Assert.Equal(user.PremiumExpirationDate, result.Expiration);
|
||||
Assert.Null(result.License);
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_UserAndLicense_IncludesLicense(User user, UserLicense license)
|
||||
{
|
||||
// Arrange
|
||||
user.Storage = 1073741824; // 1 GB in bytes
|
||||
user.MaxStorageGb = (short)5;
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionResponseModel(user, license);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.License);
|
||||
Assert.Equal(license, result.License);
|
||||
Assert.Equal(1.0, result.StorageGb);
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_NullStorage_SetsStorageToZero(User user)
|
||||
{
|
||||
// Arrange
|
||||
user.Storage = null;
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionResponseModel(user);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.StorageName);
|
||||
Assert.Equal(0, result.StorageGb);
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_NullLicense_ExcludesLicense(User user)
|
||||
{
|
||||
// Act
|
||||
var result = new SubscriptionResponseModel(user, null);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.License);
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_BothPercentOffAndAmountOffPresent_HandlesEdgeCase(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange - Edge case: Both PercentOff and AmountOff present
|
||||
// This tests the scenario where Stripe coupon has both discount types
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
|
||||
Active = true,
|
||||
PercentOff = 25m,
|
||||
AmountOff = 20.00m, // Already converted from cents
|
||||
AppliesTo = new List<string> { "prod_premium" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
|
||||
|
||||
// Assert - Both values should be preserved
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
|
||||
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
|
||||
Assert.Equal(20.00m, result.CustomerDiscount.AmountOff);
|
||||
Assert.NotNull(result.CustomerDiscount.AppliesTo);
|
||||
Assert.Single(result.CustomerDiscount.AppliesTo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_WithSubscriptionAndInvoice_MapsAllProperties(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange - Test with Subscription, UpcomingInvoice, and CustomerDiscount
|
||||
var stripeSubscription = new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = "active",
|
||||
CollectionMethod = "charge_automatically"
|
||||
};
|
||||
|
||||
var stripeInvoice = new Invoice
|
||||
{
|
||||
AmountDue = 1500, // 1500 cents = $15.00
|
||||
Created = DateTime.UtcNow.AddDays(7)
|
||||
};
|
||||
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
Subscription = new SubscriptionInfo.BillingSubscription(stripeSubscription),
|
||||
UpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice),
|
||||
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
|
||||
Active = true,
|
||||
PercentOff = 20m,
|
||||
AmountOff = null,
|
||||
AppliesTo = new List<string> { "prod_premium" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
|
||||
|
||||
// Assert - Verify all properties are mapped correctly
|
||||
Assert.NotNull(result.Subscription);
|
||||
Assert.Equal("active", result.Subscription.Status);
|
||||
Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days
|
||||
|
||||
Assert.NotNull(result.UpcomingInvoice);
|
||||
Assert.Equal(15.00m, result.UpcomingInvoice.Amount);
|
||||
Assert.NotNull(result.UpcomingInvoice.Date);
|
||||
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
|
||||
Assert.True(result.CustomerDiscount.Active);
|
||||
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_WithNullSubscriptionAndInvoice_HandlesNullsGracefully(
|
||||
User user,
|
||||
UserLicense license)
|
||||
{
|
||||
// Arrange - Test with null Subscription and UpcomingInvoice
|
||||
var subscriptionInfo = new SubscriptionInfo
|
||||
{
|
||||
Subscription = null,
|
||||
UpcomingInvoice = null,
|
||||
CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount
|
||||
{
|
||||
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
|
||||
Active = true,
|
||||
PercentOff = 20m
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true);
|
||||
|
||||
// Assert - Null Subscription and UpcomingInvoice should be handled gracefully
|
||||
Assert.Null(result.Subscription);
|
||||
Assert.Null(result.UpcomingInvoice);
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
}
|
||||
}
|
||||
@@ -269,6 +269,7 @@ public class UpcomingInvoiceHandlerTests
|
||||
Arg.Is<SubscriptionUpdateOptions>(o =>
|
||||
o.Items[0].Id == priceSubscriptionId &&
|
||||
o.Items[0].Price == priceId &&
|
||||
o.Discounts[0].Coupon == CouponIDs.Milestone2SubscriptionDiscount &&
|
||||
o.ProrationBehavior == "none"));
|
||||
|
||||
// Verify the updated invoice email was sent
|
||||
@@ -945,4 +946,527 @@ public class UpcomingInvoiceHandlerTests
|
||||
Arg.Any<bool>(),
|
||||
Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesSubscriptionAndOrganization()
|
||||
{
|
||||
// Arrange
|
||||
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
||||
var customerId = "cus_123";
|
||||
var subscriptionId = "sub_123";
|
||||
var passwordManagerItemId = "si_pm_123";
|
||||
var premiumAccessItemId = "si_premium_123";
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
CustomerId = customerId,
|
||||
AmountDue = 40000,
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
}
|
||||
};
|
||||
|
||||
var families2019Plan = new Families2019Plan();
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = passwordManagerItemId,
|
||||
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = premiumAccessItemId,
|
||||
Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId }
|
||||
}
|
||||
}
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = _organizationId,
|
||||
BillingEmail = "org@example.com",
|
||||
PlanType = PlanType.FamiliesAnnually2019
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeEventUtilityService
|
||||
.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
|
||||
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
Arg.Is(subscriptionId),
|
||||
Arg.Is<SubscriptionUpdateOptions>(o =>
|
||||
o.Items.Count == 2 &&
|
||||
o.Items[0].Id == passwordManagerItemId &&
|
||||
o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId &&
|
||||
o.Items[1].Id == premiumAccessItemId &&
|
||||
o.Items[1].Deleted == true &&
|
||||
o.Discounts.Count == 1 &&
|
||||
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
|
||||
o.ProrationBehavior == ProrationBehavior.None));
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(
|
||||
Arg.Is<Organization>(org =>
|
||||
org.Id == _organizationId &&
|
||||
org.PlanType == PlanType.FamiliesAnnually &&
|
||||
org.Plan == familiesPlan.Name &&
|
||||
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
|
||||
org.Seats == familiesPlan.PasswordManager.BaseSeats));
|
||||
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
|
||||
email.ToEmails.Contains("org@example.com") &&
|
||||
email.Subject == "Your Subscription Will Renew Soon"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_WithoutPremiumAccess_UpdatesSubscriptionAndOrganization()
|
||||
{
|
||||
// Arrange
|
||||
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
||||
var customerId = "cus_123";
|
||||
var subscriptionId = "sub_123";
|
||||
var passwordManagerItemId = "si_pm_123";
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
CustomerId = customerId,
|
||||
AmountDue = 40000,
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
}
|
||||
};
|
||||
|
||||
var families2019Plan = new Families2019Plan();
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = passwordManagerItemId,
|
||||
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
|
||||
}
|
||||
}
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = _organizationId,
|
||||
BillingEmail = "org@example.com",
|
||||
PlanType = PlanType.FamiliesAnnually2019
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeEventUtilityService
|
||||
.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
|
||||
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert
|
||||
await _stripeFacade.Received(1).UpdateSubscription(
|
||||
Arg.Is(subscriptionId),
|
||||
Arg.Is<SubscriptionUpdateOptions>(o =>
|
||||
o.Items.Count == 1 &&
|
||||
o.Items[0].Id == passwordManagerItemId &&
|
||||
o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId &&
|
||||
o.Discounts.Count == 1 &&
|
||||
o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount &&
|
||||
o.ProrationBehavior == ProrationBehavior.None));
|
||||
|
||||
await _organizationRepository.Received(1).ReplaceAsync(
|
||||
Arg.Is<Organization>(org =>
|
||||
org.Id == _organizationId &&
|
||||
org.PlanType == PlanType.FamiliesAnnually &&
|
||||
org.Plan == familiesPlan.Name &&
|
||||
org.UsersGetPremium == familiesPlan.UsersGetPremium &&
|
||||
org.Seats == familiesPlan.PasswordManager.BaseSeats));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WhenMilestone3Disabled_DoesNotUpdateSubscription()
|
||||
{
|
||||
// Arrange
|
||||
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
||||
var customerId = "cus_123";
|
||||
var subscriptionId = "sub_123";
|
||||
var passwordManagerItemId = "si_pm_123";
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
CustomerId = customerId,
|
||||
AmountDue = 40000,
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
}
|
||||
};
|
||||
|
||||
var families2019Plan = new Families2019Plan();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = passwordManagerItemId,
|
||||
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
|
||||
}
|
||||
}
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = _organizationId,
|
||||
BillingEmail = "org@example.com",
|
||||
PlanType = PlanType.FamiliesAnnually2019
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeEventUtilityService
|
||||
.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false);
|
||||
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert - should not update subscription or organization when feature flag is disabled
|
||||
await _stripeFacade.DidNotReceive().UpdateSubscription(
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<SubscriptionUpdateOptions>(o => o.Discounts != null));
|
||||
|
||||
await _organizationRepository.DidNotReceive().ReplaceAsync(
|
||||
Arg.Is<Organization>(org => org.PlanType == PlanType.FamiliesAnnually));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WhenMilestone3Enabled_ButNotFamilies2019Plan_DoesNotUpdateSubscription()
|
||||
{
|
||||
// Arrange
|
||||
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
||||
var customerId = "cus_123";
|
||||
var subscriptionId = "sub_123";
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
CustomerId = customerId,
|
||||
AmountDue = 40000,
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
}
|
||||
};
|
||||
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "si_pm_123",
|
||||
Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId }
|
||||
}
|
||||
}
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = _organizationId,
|
||||
BillingEmail = "org@example.com",
|
||||
PlanType = PlanType.FamiliesAnnually // Already on the new plan
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeEventUtilityService
|
||||
.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
|
||||
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert - should not update subscription when not on FamiliesAnnually2019 plan
|
||||
await _stripeFacade.DidNotReceive().UpdateSubscription(
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<SubscriptionUpdateOptions>(o => o.Discounts != null));
|
||||
|
||||
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WhenMilestone3Enabled_AndPasswordManagerItemNotFound_LogsWarning()
|
||||
{
|
||||
// Arrange
|
||||
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
||||
var customerId = "cus_123";
|
||||
var subscriptionId = "sub_123";
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
CustomerId = customerId,
|
||||
AmountDue = 40000,
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
}
|
||||
};
|
||||
|
||||
var families2019Plan = new Families2019Plan();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "si_different_item",
|
||||
Price = new Price { Id = "different-price-id" }
|
||||
}
|
||||
}
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = _organizationId,
|
||||
BillingEmail = "org@example.com",
|
||||
PlanType = PlanType.FamiliesAnnually2019
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeEventUtilityService
|
||||
.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
|
||||
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert
|
||||
_logger.Received(1).Log(
|
||||
LogLevel.Warning,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Is<object>(o =>
|
||||
o.ToString().Contains($"Could not find Organization's ({_organizationId}) password manager item") &&
|
||||
o.ToString().Contains(parsedEvent.Id)),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception, string>>());
|
||||
|
||||
// Should not update subscription or organization when password manager item not found
|
||||
await _stripeFacade.DidNotReceive().UpdateSubscription(
|
||||
Arg.Any<string>(),
|
||||
Arg.Is<SubscriptionUpdateOptions>(o => o.Discounts != null));
|
||||
|
||||
await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any<Organization>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsError()
|
||||
{
|
||||
// Arrange
|
||||
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
|
||||
var customerId = "cus_123";
|
||||
var subscriptionId = "sub_123";
|
||||
var passwordManagerItemId = "si_pm_123";
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
CustomerId = customerId,
|
||||
AmountDue = 40000,
|
||||
NextPaymentAttempt = DateTime.UtcNow.AddDays(7),
|
||||
Lines = new StripeList<InvoiceLineItem>
|
||||
{
|
||||
Data = new List<InvoiceLineItem> { new() { Description = "Test Item" } }
|
||||
}
|
||||
};
|
||||
|
||||
var families2019Plan = new Families2019Plan();
|
||||
var familiesPlan = new FamiliesPlan();
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = subscriptionId,
|
||||
CustomerId = customerId,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data = new List<SubscriptionItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = passwordManagerItemId,
|
||||
Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId }
|
||||
}
|
||||
}
|
||||
},
|
||||
AutomaticTax = new SubscriptionAutomaticTax { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var customer = new Customer
|
||||
{
|
||||
Id = customerId,
|
||||
Subscriptions = new StripeList<Subscription> { Data = new List<Subscription> { subscription } },
|
||||
Address = new Address { Country = "US" }
|
||||
};
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Id = _organizationId,
|
||||
BillingEmail = "org@example.com",
|
||||
PlanType = PlanType.FamiliesAnnually2019
|
||||
};
|
||||
|
||||
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
|
||||
_stripeFacade.GetCustomer(customerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
|
||||
_stripeEventUtilityService
|
||||
.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(new Tuple<Guid?, Guid?, Guid?>(_organizationId, null, null));
|
||||
_organizationRepository.GetByIdAsync(_organizationId).Returns(organization);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan);
|
||||
_pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan);
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true);
|
||||
_stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false);
|
||||
|
||||
// Simulate update failure
|
||||
_stripeFacade
|
||||
.UpdateSubscription(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>())
|
||||
.ThrowsAsync(new Exception("Stripe API error"));
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(parsedEvent);
|
||||
|
||||
// Assert
|
||||
_logger.Received(1).Log(
|
||||
LogLevel.Error,
|
||||
Arg.Any<EventId>(),
|
||||
Arg.Is<object>(o =>
|
||||
o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") &&
|
||||
o.ToString().Contains(parsedEvent.Type) &&
|
||||
o.ToString().Contains(parsedEvent.Id)),
|
||||
Arg.Any<Exception>(),
|
||||
Arg.Any<Func<object, Exception, string>>());
|
||||
|
||||
// Should still attempt to send email despite the failure
|
||||
await _mailer.Received(1).SendEmail(
|
||||
Arg.Is<UpdatedInvoiceUpcomingMail>(email =>
|
||||
email.ToEmails.Contains("org@example.com") &&
|
||||
email.Subject == "Your Subscription Will Renew Soon"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Enums.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Test.AdminConsole.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
||||
|
||||
[SutProviderCustomize]
|
||||
public class AutomaticUserConfirmationPolicyEventHandlerTests
|
||||
{
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_SingleOrgNotEnabled_ReturnsError(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns((Policy?)null);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_SingleOrgPolicyDisabled_ReturnsError(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_UsersNotCompliantWithSingleOrg_ReturnsError(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
Guid nonCompliantUserId,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var orgUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
UserId = nonCompliantUserId,
|
||||
Email = "user@example.com"
|
||||
};
|
||||
|
||||
var otherOrgUser = new OrganizationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = Guid.NewGuid(),
|
||||
UserId = nonCompliantUserId,
|
||||
Status = OrganizationUserStatusType.Confirmed
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([orgUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([otherOrgUser]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_UserWithInvitedStatusInOtherOrg_ValidationPasses(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
Guid userId,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var orgUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
UserId = userId,
|
||||
Email = "test@email.com"
|
||||
};
|
||||
|
||||
var otherOrgUser = new OrganizationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = Guid.NewGuid(),
|
||||
UserId = null, // invited users do not have a user id
|
||||
Status = OrganizationUserStatusType.Invited,
|
||||
Email = orgUser.Email
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([orgUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([otherOrgUser]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_ProviderUsersExist_ReturnsError(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var providerUser = new ProviderUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ProviderId = Guid.NewGuid(),
|
||||
UserId = Guid.NewGuid(),
|
||||
Status = ProviderUserStatusType.Confirmed
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([providerUser]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Provider user type", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_AllValidationsPassed_ReturnsEmptyString(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var orgUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
UserId = Guid.NewGuid(),
|
||||
Email = "user@example.com"
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([orgUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_PolicyAlreadyEnabled_ReturnsEmptyString(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, currentPolicy);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
await sutProvider.GetDependency<IPolicyRepository>()
|
||||
.DidNotReceive()
|
||||
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_DisablingPolicy_ReturnsEmptyString(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, currentPolicy);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
await sutProvider.GetDependency<IPolicyRepository>()
|
||||
.DidNotReceive()
|
||||
.GetByOrganizationIdTypeAsync(Arg.Any<Guid>(), Arg.Any<PolicyType>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
Guid nonCompliantOwnerId,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var ownerUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Type = OrganizationUserType.Owner,
|
||||
Status = OrganizationUserStatusType.Confirmed,
|
||||
UserId = nonCompliantOwnerId,
|
||||
Email = "owner@example.com"
|
||||
};
|
||||
|
||||
var otherOrgUser = new OrganizationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = Guid.NewGuid(),
|
||||
UserId = nonCompliantOwnerId,
|
||||
Status = OrganizationUserStatusType.Confirmed
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([ownerUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([otherOrgUser]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_InvitedUsersExcluded_FromComplianceCheck(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var invitedUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Invited,
|
||||
UserId = Guid.NewGuid(),
|
||||
Email = "invited@example.com"
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([invitedUser]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_RevokedUsersExcluded_FromComplianceCheck(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var revokedUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Revoked,
|
||||
UserId = Guid.NewGuid(),
|
||||
Email = "revoked@example.com"
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([revokedUser]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_AcceptedUsersIncluded_InComplianceCheck(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
Guid nonCompliantUserId,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var acceptedUser = new OrganizationUserUserDetails
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = policyUpdate.OrganizationId,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Accepted,
|
||||
UserId = nonCompliantUserId,
|
||||
Email = "accepted@example.com"
|
||||
};
|
||||
|
||||
var otherOrgUser = new OrganizationUser
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OrganizationId = Guid.NewGuid(),
|
||||
UserId = nonCompliantUserId,
|
||||
Status = OrganizationUserStatusType.Confirmed
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([acceptedUser]);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
|
||||
.Returns([otherOrgUser]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_EnablingPolicy_EmptyOrganization_ReturnsEmptyString(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ValidateAsync_WithSavePolicyModel_CallsValidateWithPolicyUpdate(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
sutProvider.GetDependency<IPolicyRepository>()
|
||||
.GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg)
|
||||
.Returns(singleOrgPolicy);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationUserRepository>()
|
||||
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([]);
|
||||
|
||||
sutProvider.GetDependency<IProviderUserRepository>()
|
||||
.GetManyByOrganizationAsync(policyUpdate.OrganizationId)
|
||||
.Returns([]);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null);
|
||||
|
||||
// Assert
|
||||
Assert.True(string.IsNullOrEmpty(result));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task OnSaveSideEffectsAsync_EnablingPolicy_SetsUseAutomaticUserConfirmationToTrue(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
Organization organization,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.Id = policyUpdate.OrganizationId;
|
||||
organization.UseAutomaticUserConfirmation = false;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.Received(1)
|
||||
.UpsertAsync(Arg.Is<Organization>(o =>
|
||||
o.Id == organization.Id &&
|
||||
o.UseAutomaticUserConfirmation == true &&
|
||||
o.RevisionDate > DateTime.MinValue));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task OnSaveSideEffectsAsync_DisablingPolicy_SetsUseAutomaticUserConfirmationToFalse(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate,
|
||||
Organization organization,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.Id = policyUpdate.OrganizationId;
|
||||
organization.UseAutomaticUserConfirmation = true;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.Received(1)
|
||||
.UpsertAsync(Arg.Is<Organization>(o =>
|
||||
o.Id == organization.Id &&
|
||||
o.UseAutomaticUserConfirmation == false &&
|
||||
o.RevisionDate > DateTime.MinValue));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task OnSaveSideEffectsAsync_OrganizationNotFound_DoesNotThrowException(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns((Organization?)null);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.DidNotReceive()
|
||||
.UpsertAsync(Arg.Any<Organization>());
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task ExecutePreUpsertSideEffectAsync_CallsOnSaveSideEffectsAsync(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
[Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy,
|
||||
Organization organization,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.Id = policyUpdate.OrganizationId;
|
||||
currentPolicy.OrganizationId = policyUpdate.OrganizationId;
|
||||
|
||||
var savePolicyModel = new SavePolicyModel(policyUpdate);
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, currentPolicy);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.Received(1)
|
||||
.UpsertAsync(Arg.Is<Organization>(o =>
|
||||
o.Id == organization.Id &&
|
||||
o.UseAutomaticUserConfirmation == policyUpdate.Enabled));
|
||||
}
|
||||
|
||||
[Theory, BitAutoData]
|
||||
public async Task OnSaveSideEffectsAsync_UpdatesRevisionDate(
|
||||
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
|
||||
Organization organization,
|
||||
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
|
||||
{
|
||||
// Arrange
|
||||
organization.Id = policyUpdate.OrganizationId;
|
||||
var originalRevisionDate = DateTime.UtcNow.AddDays(-1);
|
||||
organization.RevisionDate = originalRevisionDate;
|
||||
|
||||
sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.GetByIdAsync(policyUpdate.OrganizationId)
|
||||
.Returns(organization);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null);
|
||||
|
||||
// Assert
|
||||
await sutProvider.GetDependency<IOrganizationRepository>()
|
||||
.Received(1)
|
||||
.UpsertAsync(Arg.Is<Organization>(o =>
|
||||
o.Id == organization.Id &&
|
||||
o.RevisionDate > originalRevisionDate));
|
||||
}
|
||||
}
|
||||
497
test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs
Normal file
497
test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs
Normal file
@@ -0,0 +1,497 @@
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Test.Common.AutoFixture.Attributes;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Models.Business;
|
||||
|
||||
public class BillingCustomerDiscountTests
|
||||
{
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_PercentageDiscount_SetsIdActivePercentOffAndAppliesTo(string couponId)
|
||||
{
|
||||
// Arrange
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = couponId,
|
||||
PercentOff = 25.5m,
|
||||
AmountOff = null,
|
||||
AppliesTo = new CouponAppliesTo
|
||||
{
|
||||
Products = new List<string> { "product1", "product2" }
|
||||
}
|
||||
},
|
||||
End = null // Active discount
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(couponId, result.Id);
|
||||
Assert.True(result.Active);
|
||||
Assert.Equal(25.5m, result.PercentOff);
|
||||
Assert.Null(result.AmountOff);
|
||||
Assert.NotNull(result.AppliesTo);
|
||||
Assert.Equal(2, result.AppliesTo.Count);
|
||||
Assert.Contains("product1", result.AppliesTo);
|
||||
Assert.Contains("product2", result.AppliesTo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_AmountDiscount_ConvertsFromCentsToDollars(string couponId)
|
||||
{
|
||||
// Arrange - Stripe sends 1400 cents for $14.00
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = couponId,
|
||||
PercentOff = null,
|
||||
AmountOff = 1400, // 1400 cents
|
||||
AppliesTo = new CouponAppliesTo
|
||||
{
|
||||
Products = new List<string>()
|
||||
}
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(couponId, result.Id);
|
||||
Assert.True(result.Active);
|
||||
Assert.Null(result.PercentOff);
|
||||
Assert.Equal(14.00m, result.AmountOff); // Converted to dollars
|
||||
Assert.NotNull(result.AppliesTo);
|
||||
Assert.Empty(result.AppliesTo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_InactiveDiscount_SetsActiveToFalse(string couponId)
|
||||
{
|
||||
// Arrange
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = couponId,
|
||||
PercentOff = 15m
|
||||
},
|
||||
End = DateTime.UtcNow.AddDays(-1) // Expired discount
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(couponId, result.Id);
|
||||
Assert.False(result.Active);
|
||||
Assert.Equal(15m, result.PercentOff);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullCoupon_SetsDiscountPropertiesToNull()
|
||||
{
|
||||
// Arrange
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = null,
|
||||
End = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.Id);
|
||||
Assert.True(result.Active);
|
||||
Assert.Null(result.PercentOff);
|
||||
Assert.Null(result.AmountOff);
|
||||
Assert.Null(result.AppliesTo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_NullAmountOff_SetsAmountOffToNull(string couponId)
|
||||
{
|
||||
// Arrange
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = couponId,
|
||||
PercentOff = 10m,
|
||||
AmountOff = null
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.AmountOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_ZeroAmountOff_ConvertsCorrectly(string couponId)
|
||||
{
|
||||
// Arrange
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = couponId,
|
||||
AmountOff = 0
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0m, result.AmountOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_LargeAmountOff_ConvertsCorrectly(string couponId)
|
||||
{
|
||||
// Arrange - $100.00 discount
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = couponId,
|
||||
AmountOff = 10000 // 10000 cents = $100.00
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(100.00m, result.AmountOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_SmallAmountOff_ConvertsCorrectly(string couponId)
|
||||
{
|
||||
// Arrange - $0.50 discount
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = couponId,
|
||||
AmountOff = 50 // 50 cents = $0.50
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0.50m, result.AmountOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_BothDiscountTypes_SetsPercentOffAndAmountOff(string couponId)
|
||||
{
|
||||
// Arrange - Coupon with both percentage and amount (edge case)
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = couponId,
|
||||
PercentOff = 20m,
|
||||
AmountOff = 500 // $5.00
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(20m, result.PercentOff);
|
||||
Assert.Equal(5.00m, result.AmountOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_WithNullAppliesTo_SetsAppliesToNull(string couponId)
|
||||
{
|
||||
// Arrange
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = couponId,
|
||||
PercentOff = 10m,
|
||||
AppliesTo = null
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.AppliesTo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_WithNullProductsList_SetsAppliesToNull(string couponId)
|
||||
{
|
||||
// Arrange
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = couponId,
|
||||
PercentOff = 10m,
|
||||
AppliesTo = new CouponAppliesTo
|
||||
{
|
||||
Products = null
|
||||
}
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.AppliesTo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_WithDecimalAmountOff_RoundsCorrectly(string couponId)
|
||||
{
|
||||
// Arrange - 1425 cents = $14.25
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = couponId,
|
||||
AmountOff = 1425
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(14.25m, result.AmountOff);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultConstructor_InitializesAllPropertiesToNullOrFalse()
|
||||
{
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.Id);
|
||||
Assert.False(result.Active);
|
||||
Assert.Null(result.PercentOff);
|
||||
Assert.Null(result.AmountOff);
|
||||
Assert.Null(result.AppliesTo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_WithFutureEndDate_SetsActiveToFalse(string couponId)
|
||||
{
|
||||
// Arrange - Discount expires in the future
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = couponId,
|
||||
PercentOff = 20m
|
||||
},
|
||||
End = DateTime.UtcNow.AddDays(30) // Expires in 30 days
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Active); // Should be inactive because End is not null
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_WithPastEndDate_SetsActiveToFalse(string couponId)
|
||||
{
|
||||
// Arrange - Discount already expired
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = couponId,
|
||||
PercentOff = 20m
|
||||
},
|
||||
End = DateTime.UtcNow.AddDays(-30) // Expired 30 days ago
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Active); // Should be inactive because End is not null
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithNullCouponId_SetsIdToNull()
|
||||
{
|
||||
// Arrange
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = null,
|
||||
PercentOff = 20m
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.Id);
|
||||
Assert.True(result.Active);
|
||||
Assert.Equal(20m, result.PercentOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_WithNullPercentOff_SetsPercentOffToNull(string couponId)
|
||||
{
|
||||
// Arrange
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = couponId,
|
||||
PercentOff = null,
|
||||
AmountOff = 1000
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.PercentOff);
|
||||
Assert.Equal(10.00m, result.AmountOff);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithCompleteStripeDiscount_MapsAllProperties()
|
||||
{
|
||||
// Arrange - Comprehensive test with all Stripe Discount properties set
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = "premium_discount_2024",
|
||||
PercentOff = 25m,
|
||||
AmountOff = 1500, // $15.00
|
||||
AppliesTo = new CouponAppliesTo
|
||||
{
|
||||
Products = new List<string> { "prod_premium", "prod_family", "prod_teams" }
|
||||
}
|
||||
},
|
||||
End = null // Active
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert - Verify all properties mapped correctly
|
||||
Assert.Equal("premium_discount_2024", result.Id);
|
||||
Assert.True(result.Active);
|
||||
Assert.Equal(25m, result.PercentOff);
|
||||
Assert.Equal(15.00m, result.AmountOff);
|
||||
Assert.NotNull(result.AppliesTo);
|
||||
Assert.Equal(3, result.AppliesTo.Count);
|
||||
Assert.Contains("prod_premium", result.AppliesTo);
|
||||
Assert.Contains("prod_family", result.AppliesTo);
|
||||
Assert.Contains("prod_teams", result.AppliesTo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithMinimalStripeDiscount_HandlesNullsGracefully()
|
||||
{
|
||||
// Arrange - Minimal Stripe Discount with most properties null
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = null,
|
||||
PercentOff = null,
|
||||
AmountOff = null,
|
||||
AppliesTo = null
|
||||
},
|
||||
End = DateTime.UtcNow.AddDays(10) // Has end date
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert - Should handle all nulls gracefully
|
||||
Assert.Null(result.Id);
|
||||
Assert.False(result.Active);
|
||||
Assert.Null(result.PercentOff);
|
||||
Assert.Null(result.AmountOff);
|
||||
Assert.Null(result.AppliesTo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public void Constructor_WithEmptyProductsList_PreservesEmptyList(string couponId)
|
||||
{
|
||||
// Arrange
|
||||
var discount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = couponId,
|
||||
PercentOff = 10m,
|
||||
AppliesTo = new CouponAppliesTo
|
||||
{
|
||||
Products = new List<string>() // Empty but not null
|
||||
}
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingCustomerDiscount(discount);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.AppliesTo);
|
||||
Assert.Empty(result.AppliesTo);
|
||||
}
|
||||
}
|
||||
125
test/Core.Test/Models/Business/SubscriptionInfoTests.cs
Normal file
125
test/Core.Test/Models/Business/SubscriptionInfoTests.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using Bit.Core.Models.Business;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Core.Test.Models.Business;
|
||||
|
||||
public class SubscriptionInfoTests
|
||||
{
|
||||
[Fact]
|
||||
public void BillingSubscriptionItem_NullPlan_HandlesGracefully()
|
||||
{
|
||||
// Arrange - SubscriptionItem with null Plan
|
||||
var subscriptionItem = new SubscriptionItem
|
||||
{
|
||||
Plan = null,
|
||||
Quantity = 1
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem);
|
||||
|
||||
// Assert - Should handle null Plan gracefully
|
||||
Assert.Null(result.ProductId);
|
||||
Assert.Null(result.Name);
|
||||
Assert.Equal(0m, result.Amount); // Defaults to 0 when Plan is null
|
||||
Assert.Null(result.Interval);
|
||||
Assert.Equal(1, result.Quantity);
|
||||
Assert.False(result.SponsoredSubscriptionItem);
|
||||
Assert.False(result.AddonSubscriptionItem);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BillingSubscriptionItem_NullAmount_SetsToZero()
|
||||
{
|
||||
// Arrange - SubscriptionItem with Plan but null Amount
|
||||
var subscriptionItem = new SubscriptionItem
|
||||
{
|
||||
Plan = new Plan
|
||||
{
|
||||
ProductId = "prod_test",
|
||||
Nickname = "Test Plan",
|
||||
Amount = null, // Null amount
|
||||
Interval = "month"
|
||||
},
|
||||
Quantity = 1
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem);
|
||||
|
||||
// Assert - Should default to 0 when Amount is null
|
||||
Assert.Equal("prod_test", result.ProductId);
|
||||
Assert.Equal("Test Plan", result.Name);
|
||||
Assert.Equal(0m, result.Amount); // Business rule: defaults to 0 when null
|
||||
Assert.Equal("month", result.Interval);
|
||||
Assert.Equal(1, result.Quantity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BillingSubscriptionItem_ZeroAmount_PreservesZero()
|
||||
{
|
||||
// Arrange - SubscriptionItem with Plan and zero Amount
|
||||
var subscriptionItem = new SubscriptionItem
|
||||
{
|
||||
Plan = new Plan
|
||||
{
|
||||
ProductId = "prod_test",
|
||||
Nickname = "Test Plan",
|
||||
Amount = 0, // Zero amount (0 cents)
|
||||
Interval = "month"
|
||||
},
|
||||
Quantity = 1
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem);
|
||||
|
||||
// Assert - Should preserve zero amount
|
||||
Assert.Equal("prod_test", result.ProductId);
|
||||
Assert.Equal("Test Plan", result.Name);
|
||||
Assert.Equal(0m, result.Amount); // Zero amount preserved
|
||||
Assert.Equal("month", result.Interval);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BillingUpcomingInvoice_ZeroAmountDue_ConvertsToZero()
|
||||
{
|
||||
// Arrange - Invoice with zero AmountDue
|
||||
// Note: Stripe's Invoice.AmountDue is non-nullable long, so we test with 0
|
||||
// The null-coalescing operator (?? 0) in the constructor handles the case where
|
||||
// ConvertFromStripeMinorUnits returns null, but since AmountDue is non-nullable,
|
||||
// this test verifies the conversion path works correctly for zero values
|
||||
var invoice = new Invoice
|
||||
{
|
||||
AmountDue = 0, // Zero amount due (0 cents)
|
||||
Created = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice);
|
||||
|
||||
// Assert - Should convert zero correctly
|
||||
Assert.Equal(0m, result.Amount);
|
||||
Assert.NotNull(result.Date);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BillingUpcomingInvoice_ValidAmountDue_ConvertsCorrectly()
|
||||
{
|
||||
// Arrange - Invoice with valid AmountDue
|
||||
var invoice = new Invoice
|
||||
{
|
||||
AmountDue = 2500, // 2500 cents = $25.00
|
||||
Created = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice);
|
||||
|
||||
// Assert - Should convert correctly
|
||||
Assert.Equal(25.00m, result.Amount); // Converted from cents
|
||||
Assert.NotNull(result.Date);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Models.StaticStore.Plans;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Tax.Requests;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Test.Common.AutoFixture;
|
||||
@@ -515,4 +516,399 @@ public class StripePaymentServiceTests
|
||||
options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse
|
||||
));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WithCustomerDiscount_ReturnsDiscountFromCustomer(
|
||||
SutProvider<StripePaymentService> sutProvider,
|
||||
User subscriber)
|
||||
{
|
||||
// Arrange
|
||||
subscriber.Gateway = GatewayType.Stripe;
|
||||
subscriber.GatewayCustomerId = "cus_test123";
|
||||
subscriber.GatewaySubscriptionId = "sub_test123";
|
||||
|
||||
var customerDiscount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
|
||||
PercentOff = 20m,
|
||||
AmountOff = 1400
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = "active",
|
||||
CollectionMethod = "charge_automatically",
|
||||
Customer = new Customer
|
||||
{
|
||||
Discount = customerDiscount
|
||||
},
|
||||
Discounts = new List<Discount>(), // Empty list
|
||||
Items = new StripeList<SubscriptionItem> { Data = [] }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(
|
||||
subscriber.GatewaySubscriptionId,
|
||||
Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
|
||||
Assert.Equal(20m, result.CustomerDiscount.PercentOff);
|
||||
Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); // Converted from cents
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WithoutCustomerDiscount_FallsBackToSubscriptionDiscounts(
|
||||
SutProvider<StripePaymentService> sutProvider,
|
||||
User subscriber)
|
||||
{
|
||||
// Arrange
|
||||
subscriber.Gateway = GatewayType.Stripe;
|
||||
subscriber.GatewayCustomerId = "cus_test123";
|
||||
subscriber.GatewaySubscriptionId = "sub_test123";
|
||||
|
||||
var subscriptionDiscount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
|
||||
PercentOff = 15m,
|
||||
AmountOff = null
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = "active",
|
||||
CollectionMethod = "charge_automatically",
|
||||
Customer = new Customer
|
||||
{
|
||||
Discount = null // No customer discount
|
||||
},
|
||||
Discounts = new List<Discount> { subscriptionDiscount },
|
||||
Items = new StripeList<SubscriptionItem> { Data = [] }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(
|
||||
subscriber.GatewaySubscriptionId,
|
||||
Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
|
||||
|
||||
// Assert - Should use subscription discount as fallback
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
|
||||
Assert.Equal(15m, result.CustomerDiscount.PercentOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WithBothDiscounts_PrefersCustomerDiscount(
|
||||
SutProvider<StripePaymentService> sutProvider,
|
||||
User subscriber)
|
||||
{
|
||||
// Arrange
|
||||
subscriber.Gateway = GatewayType.Stripe;
|
||||
subscriber.GatewayCustomerId = "cus_test123";
|
||||
subscriber.GatewaySubscriptionId = "sub_test123";
|
||||
|
||||
var customerDiscount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount,
|
||||
PercentOff = 25m
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
var subscriptionDiscount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = "different-coupon-id",
|
||||
PercentOff = 10m
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = "active",
|
||||
CollectionMethod = "charge_automatically",
|
||||
Customer = new Customer
|
||||
{
|
||||
Discount = customerDiscount // Should prefer this
|
||||
},
|
||||
Discounts = new List<Discount> { subscriptionDiscount },
|
||||
Items = new StripeList<SubscriptionItem> { Data = [] }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(
|
||||
subscriber.GatewaySubscriptionId,
|
||||
Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
|
||||
|
||||
// Assert - Should prefer customer discount over subscription discount
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id);
|
||||
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WithNoDiscounts_ReturnsNullDiscount(
|
||||
SutProvider<StripePaymentService> sutProvider,
|
||||
User subscriber)
|
||||
{
|
||||
// Arrange
|
||||
subscriber.Gateway = GatewayType.Stripe;
|
||||
subscriber.GatewayCustomerId = "cus_test123";
|
||||
subscriber.GatewaySubscriptionId = "sub_test123";
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = "active",
|
||||
CollectionMethod = "charge_automatically",
|
||||
Customer = new Customer
|
||||
{
|
||||
Discount = null
|
||||
},
|
||||
Discounts = new List<Discount>(), // Empty list, no discounts
|
||||
Items = new StripeList<SubscriptionItem> { Data = [] }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(
|
||||
subscriber.GatewaySubscriptionId,
|
||||
Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
|
||||
|
||||
// Assert
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WithMultipleSubscriptionDiscounts_SelectsFirstDiscount(
|
||||
SutProvider<StripePaymentService> sutProvider,
|
||||
User subscriber)
|
||||
{
|
||||
// Arrange - Multiple subscription-level discounts, no customer discount
|
||||
subscriber.Gateway = GatewayType.Stripe;
|
||||
subscriber.GatewayCustomerId = "cus_test123";
|
||||
subscriber.GatewaySubscriptionId = "sub_test123";
|
||||
|
||||
var firstDiscount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = "coupon-10-percent",
|
||||
PercentOff = 10m
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
var secondDiscount = new Discount
|
||||
{
|
||||
Coupon = new Coupon
|
||||
{
|
||||
Id = "coupon-20-percent",
|
||||
PercentOff = 20m
|
||||
},
|
||||
End = null
|
||||
};
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = "active",
|
||||
CollectionMethod = "charge_automatically",
|
||||
Customer = new Customer
|
||||
{
|
||||
Discount = null // No customer discount
|
||||
},
|
||||
// Multiple subscription discounts - FirstOrDefault() should select the first one
|
||||
Discounts = new List<Discount> { firstDiscount, secondDiscount },
|
||||
Items = new StripeList<SubscriptionItem> { Data = [] }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(
|
||||
subscriber.GatewaySubscriptionId,
|
||||
Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
|
||||
|
||||
// Assert - Should select the first discount from the list (FirstOrDefault() behavior)
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
Assert.Equal("coupon-10-percent", result.CustomerDiscount.Id);
|
||||
Assert.Equal(10m, result.CustomerDiscount.PercentOff);
|
||||
// Verify the second discount was not selected
|
||||
Assert.NotEqual("coupon-20-percent", result.CustomerDiscount.Id);
|
||||
Assert.NotEqual(20m, result.CustomerDiscount.PercentOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WithNullCustomer_HandlesGracefully(
|
||||
SutProvider<StripePaymentService> sutProvider,
|
||||
User subscriber)
|
||||
{
|
||||
// Arrange - Subscription with null Customer (defensive null check scenario)
|
||||
subscriber.Gateway = GatewayType.Stripe;
|
||||
subscriber.GatewayCustomerId = "cus_test123";
|
||||
subscriber.GatewaySubscriptionId = "sub_test123";
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = "active",
|
||||
CollectionMethod = "charge_automatically",
|
||||
Customer = null, // Customer not expanded or null
|
||||
Discounts = new List<Discount>(), // Empty discounts
|
||||
Items = new StripeList<SubscriptionItem> { Data = [] }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(
|
||||
subscriber.GatewaySubscriptionId,
|
||||
Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
|
||||
|
||||
// Assert - Should handle null Customer gracefully without throwing NullReferenceException
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WithNullDiscounts_HandlesGracefully(
|
||||
SutProvider<StripePaymentService> sutProvider,
|
||||
User subscriber)
|
||||
{
|
||||
// Arrange - Subscription with null Discounts (defensive null check scenario)
|
||||
subscriber.Gateway = GatewayType.Stripe;
|
||||
subscriber.GatewayCustomerId = "cus_test123";
|
||||
subscriber.GatewaySubscriptionId = "sub_test123";
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = "active",
|
||||
CollectionMethod = "charge_automatically",
|
||||
Customer = new Customer
|
||||
{
|
||||
Discount = null // No customer discount
|
||||
},
|
||||
Discounts = null, // Discounts not expanded or null
|
||||
Items = new StripeList<SubscriptionItem> { Data = [] }
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.SubscriptionGetAsync(
|
||||
subscriber.GatewaySubscriptionId,
|
||||
Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
|
||||
|
||||
// Assert - Should handle null Discounts gracefully without throwing NullReferenceException
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_VerifiesCorrectExpandOptions(
|
||||
SutProvider<StripePaymentService> sutProvider,
|
||||
User subscriber)
|
||||
{
|
||||
// Arrange
|
||||
subscriber.Gateway = GatewayType.Stripe;
|
||||
subscriber.GatewayCustomerId = "cus_test123";
|
||||
subscriber.GatewaySubscriptionId = "sub_test123";
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = "active",
|
||||
CollectionMethod = "charge_automatically",
|
||||
Customer = new Customer { Discount = null },
|
||||
Discounts = new List<Discount>(), // Empty list
|
||||
Items = new StripeList<SubscriptionItem> { Data = [] }
|
||||
};
|
||||
|
||||
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
|
||||
stripeAdapter
|
||||
.SubscriptionGetAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
// Act
|
||||
await sutProvider.Sut.GetSubscriptionAsync(subscriber);
|
||||
|
||||
// Assert - Verify expand options are correct
|
||||
await stripeAdapter.Received(1).SubscriptionGetAsync(
|
||||
subscriber.GatewaySubscriptionId,
|
||||
Arg.Is<SubscriptionGetOptions>(o =>
|
||||
o.Expand.Contains("customer.discount.coupon.applies_to") &&
|
||||
o.Expand.Contains("discounts.coupon.applies_to") &&
|
||||
o.Expand.Contains("test_clock")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WithEmptyGatewaySubscriptionId_ReturnsEmptySubscriptionInfo(
|
||||
SutProvider<StripePaymentService> sutProvider,
|
||||
User subscriber)
|
||||
{
|
||||
// Arrange
|
||||
subscriber.GatewaySubscriptionId = null;
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Null(result.Subscription);
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
Assert.Null(result.UpcomingInvoice);
|
||||
|
||||
// Verify no Stripe API calls were made
|
||||
await sutProvider.GetDependency<IStripeAdapter>()
|
||||
.DidNotReceive()
|
||||
.SubscriptionGetAsync(Arg.Any<string>(), Arg.Any<SubscriptionGetOptions>());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user