mirror of
https://github.com/bitwarden/server.git
synced 2026-05-02 15:51:48 -05:00
fix(billing): handle cross-product Phase 2 price overlay for Families 2019 (#7408)
This commit is contained in:
22
.vscode/launch.json
vendored
22
.vscode/launch.json
vendored
@@ -33,6 +33,22 @@
|
||||
"preLaunchTask": "buildIdentityApiAdmin",
|
||||
"stopAll": true
|
||||
},
|
||||
{
|
||||
"name": "Admin, API, Identity, Billing",
|
||||
"configurations": [
|
||||
"run-Admin",
|
||||
"run-API",
|
||||
"run-Identity",
|
||||
"run-Billing"
|
||||
],
|
||||
"presentation": {
|
||||
"hidden": false,
|
||||
"group": "AA_compounds",
|
||||
"order": 4
|
||||
},
|
||||
"preLaunchTask": "buildIdentityApiAdminBilling",
|
||||
"stopAll": true
|
||||
},
|
||||
{
|
||||
"name": "API, Identity, SSO",
|
||||
"configurations": [
|
||||
@@ -43,7 +59,7 @@
|
||||
"presentation": {
|
||||
"hidden": false,
|
||||
"group": "AA_compounds",
|
||||
"order": 4
|
||||
"order": 5
|
||||
},
|
||||
"preLaunchTask": "buildIdentityApiSso",
|
||||
"stopAll": true
|
||||
@@ -64,7 +80,7 @@
|
||||
"presentation": {
|
||||
"hidden": false,
|
||||
"group": "AA_compounds",
|
||||
"order": 5
|
||||
"order": 6
|
||||
},
|
||||
"preLaunchTask": "buildFullServer",
|
||||
"stopAll": true
|
||||
@@ -86,7 +102,7 @@
|
||||
"presentation": {
|
||||
"hidden": false,
|
||||
"group": "AA_compounds",
|
||||
"order": 6
|
||||
"order": 7
|
||||
},
|
||||
"preLaunchTask": "buildFullServerWithSeederApi",
|
||||
"stopAll": true
|
||||
|
||||
14
.vscode/tasks.json
vendored
14
.vscode/tasks.json
vendored
@@ -26,6 +26,20 @@
|
||||
"$msCompile"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "buildIdentityApiAdminBilling",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildIdentity",
|
||||
"buildAPI",
|
||||
"buildAdmin",
|
||||
"buildBilling"
|
||||
],
|
||||
"problemMatcher": [
|
||||
"$msCompile"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "buildIdentityApiSso",
|
||||
"hide": true,
|
||||
|
||||
@@ -716,7 +716,7 @@ public class StripePaymentService : IStripePaymentService
|
||||
var schedule = await _stripeAdapter.GetSubscriptionScheduleAsync(subscription.ScheduleId,
|
||||
new SubscriptionScheduleGetOptions
|
||||
{
|
||||
Expand = ["phases.discounts.coupon", "phases.items.price"]
|
||||
Expand = ["phases.discounts.coupon.applies_to", "phases.items.price"]
|
||||
});
|
||||
|
||||
if (schedule.Status != StripeConstants.SubscriptionScheduleStatus.Active || schedule.Phases.Count < 2)
|
||||
@@ -736,6 +736,10 @@ public class StripePaymentService : IStripePaymentService
|
||||
if (phase2.Items != null && subscriptionInfo.Subscription?.Items != null)
|
||||
{
|
||||
var items = subscriptionInfo.Subscription.Items.ToList();
|
||||
var matchedPhase1Items = new HashSet<SubscriptionInfo.BillingSubscription.BillingSubscriptionItem>();
|
||||
var unmatchedPhase2Items = new List<SubscriptionSchedulePhaseItem>();
|
||||
|
||||
// Pass 1: Match by product ID
|
||||
foreach (var phase2Item in phase2.Items)
|
||||
{
|
||||
if (phase2Item.Price is not { UnitAmount: not null, ProductId: not null })
|
||||
@@ -747,6 +751,34 @@ public class StripePaymentService : IStripePaymentService
|
||||
if (matchingItem != null)
|
||||
{
|
||||
matchingItem.Amount = phase2Item.Price.UnitAmount.Value / 100M;
|
||||
matchedPhase1Items.Add(matchingItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
unmatchedPhase2Items.Add(phase2Item);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Fallback for cross-product migrations (e.g., Families 2019 → current Families)
|
||||
// where the old and new plans use different Stripe products.
|
||||
foreach (var phase2Item in unmatchedPhase2Items)
|
||||
{
|
||||
var fallbackItem = items.FirstOrDefault(i =>
|
||||
!matchedPhase1Items.Contains(i) && !i.AddonSubscriptionItem);
|
||||
|
||||
if (fallbackItem != null)
|
||||
{
|
||||
fallbackItem.Amount = phase2Item.Price.UnitAmount!.Value / 100M;
|
||||
fallbackItem.ProductId = phase2Item.Price.ProductId;
|
||||
fallbackItem.Name = phase2Item.Price.Nickname;
|
||||
matchedPhase1Items.Add(fallbackItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Phase 2 item with product {ProductId} could not be matched to any Phase 1 item for subscription schedule ({ScheduleId})",
|
||||
phase2Item.Price.ProductId,
|
||||
subscription.ScheduleId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -819,6 +819,335 @@ public class StripePaymentServiceTests
|
||||
Assert.Null(result.CustomerDiscount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WithActiveSchedule_CrossProductMigration_OverridesPriceProductIdAndName(
|
||||
SutProvider<StripePaymentService> sutProvider,
|
||||
User subscriber)
|
||||
{
|
||||
// Arrange — Phase 1 item uses a different Stripe product than Phase 2 (Families 2019 → current)
|
||||
subscriber.Gateway = GatewayType.Stripe;
|
||||
subscriber.GatewayCustomerId = "cus_test123";
|
||||
subscriber.GatewaySubscriptionId = "sub_test123";
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = "active",
|
||||
CollectionMethod = "charge_automatically",
|
||||
ScheduleId = "sub_sched_test123",
|
||||
Customer = new Customer { Discount = null },
|
||||
Discounts = new List<Discount>(),
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
Plan = new Plan { ProductId = "prod_old_families", Nickname = "Families 2019", Amount = 1200, Interval = "year" },
|
||||
Quantity = 1
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var schedule = new SubscriptionSchedule
|
||||
{
|
||||
Status = SubscriptionScheduleStatus.Active,
|
||||
Phases =
|
||||
[
|
||||
new SubscriptionSchedulePhase { StartDate = DateTime.UtcNow.AddDays(-30) },
|
||||
new SubscriptionSchedulePhase
|
||||
{
|
||||
StartDate = DateTime.UtcNow.AddDays(10),
|
||||
Items =
|
||||
[
|
||||
new SubscriptionSchedulePhaseItem
|
||||
{
|
||||
Price = new Price { UnitAmount = 4788, ProductId = "prod_families", Nickname = "Families" }
|
||||
}
|
||||
],
|
||||
Discounts =
|
||||
[
|
||||
new SubscriptionSchedulePhaseDiscount
|
||||
{
|
||||
Coupon = new Coupon { Id = CouponIDs.Milestone3SubscriptionDiscount, PercentOff = 25m }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.GetSubscriptionAsync(subscriber.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.GetSubscriptionScheduleAsync("sub_sched_test123", Arg.Any<SubscriptionScheduleGetOptions>())
|
||||
.Returns(schedule);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
|
||||
|
||||
// Assert — price, product ID, and name overridden with Phase 2 values
|
||||
var item = Assert.Single(result.Subscription!.Items);
|
||||
Assert.Equal(47.88m, item.Amount);
|
||||
Assert.Equal("prod_families", item.ProductId);
|
||||
Assert.Equal("Families", item.Name);
|
||||
|
||||
// Assert — discount overridden with Phase 2 discount
|
||||
Assert.NotNull(result.CustomerDiscount);
|
||||
Assert.Equal(CouponIDs.Milestone3SubscriptionDiscount, result.CustomerDiscount.Id);
|
||||
Assert.Equal(25m, result.CustomerDiscount.PercentOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WithActiveSchedule_CrossProductMigration_WithStorage_OverridesCorrectly(
|
||||
SutProvider<StripePaymentService> sutProvider,
|
||||
User subscriber)
|
||||
{
|
||||
// Arrange — storage matches by product ID (Pass 1), main plan falls back (Pass 2)
|
||||
subscriber.Gateway = GatewayType.Stripe;
|
||||
subscriber.GatewayCustomerId = "cus_test123";
|
||||
subscriber.GatewaySubscriptionId = "sub_test123";
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = "active",
|
||||
CollectionMethod = "charge_automatically",
|
||||
ScheduleId = "sub_sched_test123",
|
||||
Customer = new Customer { Discount = null },
|
||||
Discounts = new List<Discount>(),
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
Plan = new Plan { ProductId = "prod_old_families", Nickname = "Families 2019", Amount = 1200, Interval = "year" },
|
||||
Quantity = 1
|
||||
},
|
||||
new SubscriptionItem
|
||||
{
|
||||
Plan = new Plan { ProductId = "prod_storage", Nickname = "Storage", Amount = 400, Interval = "year" },
|
||||
Quantity = 2
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var schedule = new SubscriptionSchedule
|
||||
{
|
||||
Status = SubscriptionScheduleStatus.Active,
|
||||
Phases =
|
||||
[
|
||||
new SubscriptionSchedulePhase { StartDate = DateTime.UtcNow.AddDays(-30) },
|
||||
new SubscriptionSchedulePhase
|
||||
{
|
||||
StartDate = DateTime.UtcNow.AddDays(10),
|
||||
Items =
|
||||
[
|
||||
new SubscriptionSchedulePhaseItem
|
||||
{
|
||||
Price = new Price { UnitAmount = 4788, ProductId = "prod_families", Nickname = "Families" }
|
||||
},
|
||||
new SubscriptionSchedulePhaseItem
|
||||
{
|
||||
Price = new Price { UnitAmount = 400, ProductId = "prod_storage", Nickname = "Storage" }
|
||||
}
|
||||
],
|
||||
Discounts =
|
||||
[
|
||||
new SubscriptionSchedulePhaseDiscount
|
||||
{
|
||||
Coupon = new Coupon { Id = CouponIDs.Milestone3SubscriptionDiscount, PercentOff = 25m }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.GetSubscriptionAsync(subscriber.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.GetSubscriptionScheduleAsync("sub_sched_test123", Arg.Any<SubscriptionScheduleGetOptions>())
|
||||
.Returns(schedule);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
|
||||
|
||||
// Assert — main plan overridden via fallback
|
||||
var items = result.Subscription!.Items.ToList();
|
||||
Assert.Equal(2, items.Count);
|
||||
|
||||
var mainItem = items.First(i => i.ProductId == "prod_families");
|
||||
Assert.Equal(47.88m, mainItem.Amount);
|
||||
Assert.Equal("Families", mainItem.Name);
|
||||
|
||||
// Assert — storage matched by product ID, amount updated
|
||||
var storageItem = items.First(i => i.ProductId == "prod_storage");
|
||||
Assert.Equal(4.00m, storageItem.Amount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WithActiveSchedule_CrossProductMigration_SkipsAddonItems(
|
||||
SutProvider<StripePaymentService> sutProvider,
|
||||
User subscriber)
|
||||
{
|
||||
// Arrange — Phase 1 has main plan + addon; fallback should pick main plan, not addon
|
||||
subscriber.Gateway = GatewayType.Stripe;
|
||||
subscriber.GatewayCustomerId = "cus_test123";
|
||||
subscriber.GatewaySubscriptionId = "sub_test123";
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = "active",
|
||||
CollectionMethod = "charge_automatically",
|
||||
ScheduleId = "sub_sched_test123",
|
||||
Customer = new Customer { Discount = null },
|
||||
Discounts = new List<Discount>(),
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
Plan = new Plan { ProductId = "prod_premium_access", Nickname = "Premium Access", Amount = 0, Interval = "year" },
|
||||
Quantity = 1,
|
||||
Metadata = new Dictionary<string, string> { { "isAddOn", "true" } }
|
||||
},
|
||||
new SubscriptionItem
|
||||
{
|
||||
Plan = new Plan { ProductId = "prod_old_families", Nickname = "Families 2019", Amount = 1200, Interval = "year" },
|
||||
Quantity = 1
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var schedule = new SubscriptionSchedule
|
||||
{
|
||||
Status = SubscriptionScheduleStatus.Active,
|
||||
Phases =
|
||||
[
|
||||
new SubscriptionSchedulePhase { StartDate = DateTime.UtcNow.AddDays(-30) },
|
||||
new SubscriptionSchedulePhase
|
||||
{
|
||||
StartDate = DateTime.UtcNow.AddDays(10),
|
||||
Items =
|
||||
[
|
||||
new SubscriptionSchedulePhaseItem
|
||||
{
|
||||
Price = new Price { UnitAmount = 4788, ProductId = "prod_families", Nickname = "Families" }
|
||||
}
|
||||
],
|
||||
Discounts = new List<SubscriptionSchedulePhaseDiscount>()
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.GetSubscriptionAsync(subscriber.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.GetSubscriptionScheduleAsync("sub_sched_test123", Arg.Any<SubscriptionScheduleGetOptions>())
|
||||
.Returns(schedule);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
|
||||
|
||||
// Assert — main plan overridden, addon untouched
|
||||
var items = result.Subscription!.Items.ToList();
|
||||
Assert.Equal(2, items.Count);
|
||||
|
||||
var mainItem = items.First(i => i.ProductId == "prod_families");
|
||||
Assert.Equal(47.88m, mainItem.Amount);
|
||||
Assert.Equal("Families", mainItem.Name);
|
||||
|
||||
var addonItem = items.First(i => i.ProductId == "prod_premium_access");
|
||||
Assert.Equal(0m, addonItem.Amount);
|
||||
Assert.True(addonItem.AddonSubscriptionItem);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[BitAutoData]
|
||||
public async Task GetSubscriptionAsync_WithActiveSchedule_CrossProductMigration_NoFallbackTarget_GracefullyIgnored(
|
||||
SutProvider<StripePaymentService> sutProvider,
|
||||
User subscriber)
|
||||
{
|
||||
// Arrange — Phase 1 has only an addon item; no eligible fallback target for Phase 2
|
||||
subscriber.Gateway = GatewayType.Stripe;
|
||||
subscriber.GatewayCustomerId = "cus_test123";
|
||||
subscriber.GatewaySubscriptionId = "sub_test123";
|
||||
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Id = "sub_test123",
|
||||
Status = "active",
|
||||
CollectionMethod = "charge_automatically",
|
||||
ScheduleId = "sub_sched_test123",
|
||||
Customer = new Customer { Discount = null },
|
||||
Discounts = new List<Discount>(),
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem
|
||||
{
|
||||
Plan = new Plan { ProductId = "prod_premium_access", Nickname = "Premium Access", Amount = 0, Interval = "year" },
|
||||
Quantity = 1,
|
||||
Metadata = new Dictionary<string, string> { { "isAddOn", "true" } }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var schedule = new SubscriptionSchedule
|
||||
{
|
||||
Status = SubscriptionScheduleStatus.Active,
|
||||
Phases =
|
||||
[
|
||||
new SubscriptionSchedulePhase { StartDate = DateTime.UtcNow.AddDays(-30) },
|
||||
new SubscriptionSchedulePhase
|
||||
{
|
||||
StartDate = DateTime.UtcNow.AddDays(10),
|
||||
Items =
|
||||
[
|
||||
new SubscriptionSchedulePhaseItem
|
||||
{
|
||||
Price = new Price { UnitAmount = 4788, ProductId = "prod_families", Nickname = "Families" }
|
||||
}
|
||||
],
|
||||
Discounts = new List<SubscriptionSchedulePhaseDiscount>()
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.GetSubscriptionAsync(subscriber.GatewaySubscriptionId, Arg.Any<SubscriptionGetOptions>())
|
||||
.Returns(subscription);
|
||||
|
||||
sutProvider.GetDependency<IStripeAdapter>()
|
||||
.GetSubscriptionScheduleAsync("sub_sched_test123", Arg.Any<SubscriptionScheduleGetOptions>())
|
||||
.Returns(schedule);
|
||||
|
||||
// Act
|
||||
var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber);
|
||||
|
||||
// Assert — addon item is untouched, Phase 2 item was not applied
|
||||
var item = Assert.Single(result.Subscription!.Items);
|
||||
Assert.Equal("prod_premium_access", item.ProductId);
|
||||
Assert.Equal(0m, item.Amount);
|
||||
Assert.True(item.AddonSubscriptionItem);
|
||||
}
|
||||
|
||||
#region AdjustSubscription — CompleteSubscriptionUpdate tax exempt alignment
|
||||
|
||||
[Theory, BitAutoData]
|
||||
|
||||
Reference in New Issue
Block a user