fix(billing): handle cross-product Phase 2 price overlay for Families 2019 (#7408)

This commit is contained in:
Alex Morask
2026-04-07 12:32:51 -05:00
committed by GitHub
parent 1ece1145e3
commit c04ee9c0a9
4 changed files with 395 additions and 4 deletions

22
.vscode/launch.json vendored
View File

@@ -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
View File

@@ -26,6 +26,20 @@
"$msCompile"
]
},
{
"label": "buildIdentityApiAdminBilling",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildIdentity",
"buildAPI",
"buildAdmin",
"buildBilling"
],
"problemMatcher": [
"$msCompile"
]
},
{
"label": "buildIdentityApiSso",
"hide": true,

View File

@@ -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);
}
}

View File

@@ -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]