mirror of
https://github.com/bitwarden/server
synced 2025-12-15 15:53:59 +00:00
[PM 26682]milestone 2d display discount on subscription page (#6542)
* The discount badge implementation * Address the claude pr comments * Add more unit testing * Add more test * used existing flag * Add the coupon Ids * Add more code documentation * Add some recommendation from claude * Fix addition comments and prs * Add more integration test * Fix some comment and add more test * rename the test methods * Add more unit test and comments * Resolve the null issues * Add more test * reword the comments * Rename Variable * Some code refactoring * Change the coupon ID to milestone-2c * Fix the failing Test
This commit is contained in:
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