Skip to content

Commit 51455ec

Browse files
committed
[PM-34866][PM-34865] Fix EnableAutomaticTaxAsync to update schedule phases (#7437)
* [PM-34866] Fix EnableAutomaticTaxAsync to update schedule phases * Use test clock frozen time for phase filtering * Expand test_clock on customer subscription fetches
1 parent 5e99098 commit 51455ec

4 files changed

Lines changed: 277 additions & 28 deletions

File tree

src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public async Task HandleAsync(Event parsedEvent)
5050

5151
var customer =
5252
await stripeAdapter.GetCustomerAsync(invoice.CustomerId,
53-
new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] });
53+
new CustomerGetOptions { Expand = ["subscriptions", "subscriptions.data.test_clock", "tax", "tax_ids"] });
5454

5555
var subscription = customer.Subscriptions.FirstOrDefault();
5656

@@ -574,6 +574,47 @@ private async Task EnableAutomaticTaxAsync(Subscription subscription)
574574

575575
if (activeSchedule != null)
576576
{
577+
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
578+
var phases = new List<SubscriptionSchedulePhaseOptions>();
579+
580+
for (var i = 0; i < activeSchedule.Phases.Count; i++)
581+
{
582+
var phase = activeSchedule.Phases[i];
583+
584+
// Skip phases that have already completed
585+
if (phase.EndDate <= now)
586+
{
587+
continue;
588+
}
589+
590+
// When a phase's predecessor has ended, the phase is already active and
591+
// its one-time migration discount has been applied and consumed.
592+
// Re-including it would cause Stripe to re-apply it.
593+
var discountConsumed = i > 0 && activeSchedule.Phases[i - 1].EndDate <= now;
594+
595+
phases.Add(new SubscriptionSchedulePhaseOptions
596+
{
597+
StartDate = phase.StartDate,
598+
EndDate = phase.EndDate,
599+
Items = phase.Items.Select(item => new SubscriptionSchedulePhaseItemOptions
600+
{
601+
Price = item.PriceId,
602+
Quantity = item.Quantity
603+
}).ToList(),
604+
Discounts = discountConsumed
605+
? []
606+
: phase.Discounts?.Select(d => new SubscriptionSchedulePhaseDiscountOptions
607+
{
608+
Coupon = d.CouponId
609+
}).ToList(),
610+
ProrationBehavior = phase.ProrationBehavior,
611+
AutomaticTax = new SubscriptionSchedulePhaseAutomaticTaxOptions
612+
{
613+
Enabled = true
614+
}
615+
});
616+
}
617+
577618
await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id,
578619
new SubscriptionScheduleUpdateOptions
579620
{
@@ -583,7 +624,8 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id,
583624
{
584625
Enabled = true
585626
}
586-
}
627+
},
628+
Phases = phases
587629
});
588630
return;
589631
}

src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ await stripeAdapter.UpdateCustomerAsync(subscriber.GatewayCustomerId,
6060
City = billingAddress.City,
6161
State = billingAddress.State
6262
},
63-
Expand = ["subscriptions"]
63+
Expand = ["subscriptions", "subscriptions.data.test_clock"]
6464
});
6565

6666
await EnableAutomaticTaxAsync(subscriber, customer);
@@ -86,7 +86,7 @@ private async Task<BillingCommandResult<BillingAddress>> UpdateBusinessBillingAd
8686
City = billingAddress.City,
8787
State = billingAddress.State
8888
},
89-
Expand = ["subscriptions", "tax_ids"],
89+
Expand = ["subscriptions", "subscriptions.data.test_clock", "tax_ids"],
9090
TaxExempt = determinedTaxExemptStatus
9191
});
9292

@@ -149,6 +149,43 @@ private async Task EnableAutomaticTaxAsync(
149149

150150
if (activeSchedule != null)
151151
{
152+
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
153+
var phases = new List<SubscriptionSchedulePhaseOptions>();
154+
155+
for (var i = 0; i < activeSchedule.Phases.Count; i++)
156+
{
157+
var phase = activeSchedule.Phases[i];
158+
159+
if (phase.EndDate <= now)
160+
{
161+
continue;
162+
}
163+
164+
var discountConsumed = i > 0 && activeSchedule.Phases[i - 1].EndDate <= now;
165+
166+
phases.Add(new SubscriptionSchedulePhaseOptions
167+
{
168+
StartDate = phase.StartDate,
169+
EndDate = phase.EndDate,
170+
Items = phase.Items.Select(item => new SubscriptionSchedulePhaseItemOptions
171+
{
172+
Price = item.PriceId,
173+
Quantity = item.Quantity
174+
}).ToList(),
175+
Discounts = discountConsumed
176+
? []
177+
: phase.Discounts?.Select(d => new SubscriptionSchedulePhaseDiscountOptions
178+
{
179+
Coupon = d.CouponId
180+
}).ToList(),
181+
ProrationBehavior = phase.ProrationBehavior,
182+
AutomaticTax = new SubscriptionSchedulePhaseAutomaticTaxOptions
183+
{
184+
Enabled = true
185+
}
186+
});
187+
}
188+
152189
await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id,
153190
new SubscriptionScheduleUpdateOptions
154191
{
@@ -158,7 +195,8 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id,
158195
{
159196
Enabled = true
160197
}
161-
}
198+
},
199+
Phases = phases
162200
});
163201
return;
164202
}

test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs

Lines changed: 148 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3104,7 +3104,7 @@ await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(
31043104
#region Schedule-Aware Tax Handling
31053105

31063106
[Fact]
3107-
public async Task HandleAsync_WhenOrganizationTaxNotEnabled_FlagOn_SchedulePresent_UpdatesScheduleDefaultSettings()
3107+
public async Task HandleAsync_WhenOrganizationTaxNotEnabled_FlagOn_SchedulePresent_UpdatesSchedulePhasesAndDefaultSettings()
31083108
{
31093109
// Arrange
31103110
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
@@ -3125,6 +3125,10 @@ public async Task HandleAsync_WhenOrganizationTaxNotEnabled_FlagOn_SchedulePrese
31253125
};
31263126
var organization = new Organization { Id = _organizationId, PlanType = PlanType.TeamsAnnually, BillingEmail = "test@test.com" };
31273127

3128+
var phase1Start = DateTime.UtcNow.AddDays(-10);
3129+
var phase1End = DateTime.UtcNow.AddDays(5);
3130+
var phase2End = DateTime.UtcNow.AddDays(370);
3131+
31283132
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
31293133
_stripeAdapter.GetCustomerAsync(invoice.CustomerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
31303134
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
@@ -3142,19 +3146,44 @@ public async Task HandleAsync_WhenOrganizationTaxNotEnabled_FlagOn_SchedulePrese
31423146
{
31433147
Id = "sub_sched_123",
31443148
SubscriptionId = "sub_123",
3145-
Status = SubscriptionScheduleStatus.Active
3149+
Status = SubscriptionScheduleStatus.Active,
3150+
Phases = new List<SubscriptionSchedulePhase>
3151+
{
3152+
new()
3153+
{
3154+
StartDate = phase1Start,
3155+
EndDate = phase1End,
3156+
Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_old", Quantity = 1 }],
3157+
Discounts = [],
3158+
ProrationBehavior = "none"
3159+
},
3160+
new()
3161+
{
3162+
StartDate = phase1End,
3163+
EndDate = phase2End,
3164+
Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_new", Quantity = 1 }],
3165+
Discounts = [new SubscriptionSchedulePhaseDiscount { CouponId = "milestone-coupon" }],
3166+
ProrationBehavior = "none"
3167+
}
3168+
}
31463169
}
31473170
]
31483171
});
31493172

31503173
// Act
31513174
await _sut.HandleAsync(parsedEvent);
31523175

3153-
// Assert — schedule's default_settings updated
3176+
// Assert — schedule updated with phases and default_settings
31543177
await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync(
31553178
Arg.Is("sub_sched_123"),
31563179
Arg.Is<SubscriptionScheduleUpdateOptions>(o =>
3157-
o.DefaultSettings.AutomaticTax.Enabled == true));
3180+
o.DefaultSettings.AutomaticTax.Enabled == true &&
3181+
o.Phases.Count == 2 &&
3182+
o.Phases[0].AutomaticTax.Enabled == true &&
3183+
o.Phases[0].Items[0].Price == "price_old" &&
3184+
o.Phases[1].AutomaticTax.Enabled == true &&
3185+
o.Phases[1].Items[0].Price == "price_new" &&
3186+
o.Phases[1].Discounts[0].Coupon == "milestone-coupon"));
31583187

31593188
// Assert — subscription NOT updated directly for tax
31603189
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(
@@ -3208,7 +3237,7 @@ await _stripeAdapter.DidNotReceive().UpdateSubscriptionScheduleAsync(
32083237
}
32093238

32103239
[Fact]
3211-
public async Task HandleAsync_WhenPremiumUserTaxNotEnabled_FlagOn_SchedulePresent_UpdatesScheduleDefaultSettings()
3240+
public async Task HandleAsync_WhenPremiumUserTaxNotEnabled_FlagOn_SchedulePresent_UpdatesSchedulePhasesAndDefaultSettings()
32123241
{
32133242
// Arrange
32143243
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
@@ -3229,6 +3258,10 @@ public async Task HandleAsync_WhenPremiumUserTaxNotEnabled_FlagOn_SchedulePresen
32293258
};
32303259
var user = new User { Id = _userId, Email = "test@test.com", Premium = true };
32313260

3261+
var phase1Start = DateTime.UtcNow.AddDays(-10);
3262+
var phase1End = DateTime.UtcNow.AddDays(5);
3263+
var phase2End = DateTime.UtcNow.AddDays(370);
3264+
32323265
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
32333266
_stripeAdapter.GetCustomerAsync(invoice.CustomerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
32343267
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
@@ -3245,19 +3278,44 @@ public async Task HandleAsync_WhenPremiumUserTaxNotEnabled_FlagOn_SchedulePresen
32453278
{
32463279
Id = "sub_sched_456",
32473280
SubscriptionId = "sub_123",
3248-
Status = SubscriptionScheduleStatus.Active
3281+
Status = SubscriptionScheduleStatus.Active,
3282+
Phases = new List<SubscriptionSchedulePhase>
3283+
{
3284+
new()
3285+
{
3286+
StartDate = phase1Start,
3287+
EndDate = phase1End,
3288+
Items = [new SubscriptionSchedulePhaseItem { PriceId = "premium-annually", Quantity = 1 }],
3289+
Discounts = [],
3290+
ProrationBehavior = "none"
3291+
},
3292+
new()
3293+
{
3294+
StartDate = phase1End,
3295+
EndDate = phase2End,
3296+
Items = [new SubscriptionSchedulePhaseItem { PriceId = "premium-annually-new", Quantity = 1 }],
3297+
Discounts = [new SubscriptionSchedulePhaseDiscount { CouponId = "milestone-2c" }],
3298+
ProrationBehavior = "none"
3299+
}
3300+
}
32493301
}
32503302
]
32513303
});
32523304

32533305
// Act
32543306
await _sut.HandleAsync(parsedEvent);
32553307

3256-
// Assert — schedule's default_settings updated
3308+
// Assert — schedule updated with phases and default_settings
32573309
await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync(
32583310
Arg.Is("sub_sched_456"),
32593311
Arg.Is<SubscriptionScheduleUpdateOptions>(o =>
3260-
o.DefaultSettings.AutomaticTax.Enabled == true));
3312+
o.DefaultSettings.AutomaticTax.Enabled == true &&
3313+
o.Phases.Count == 2 &&
3314+
o.Phases[0].AutomaticTax.Enabled == true &&
3315+
o.Phases[0].Items[0].Price == "premium-annually" &&
3316+
o.Phases[1].AutomaticTax.Enabled == true &&
3317+
o.Phases[1].Items[0].Price == "premium-annually-new" &&
3318+
o.Phases[1].Discounts[0].Coupon == "milestone-2c"));
32613319

32623320
// Assert — subscription NOT updated directly for tax
32633321
await _stripeAdapter.DidNotReceive().UpdateSubscriptionAsync(
@@ -3309,6 +3367,88 @@ await _stripeAdapter.DidNotReceive().UpdateSubscriptionScheduleAsync(
33093367
Arg.Any<string>(), Arg.Any<SubscriptionScheduleUpdateOptions>());
33103368
}
33113369

3370+
[Fact]
3371+
public async Task HandleAsync_WhenTaxNotEnabled_FlagOn_Phase2Active_SkipsCompletedPhaseAndClearsConsumedDiscounts()
3372+
{
3373+
// Arrange — Phase 1 has ended, Phase 2 is now the active phase.
3374+
// Phase 2's one-time migration discount was consumed at transition and must not be re-included.
3375+
var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" };
3376+
var invoice = new Invoice { CustomerId = "cus_123", Lines = new StripeList<InvoiceLineItem> { Data = [] } };
3377+
var subscription = new Subscription
3378+
{
3379+
Id = "sub_123",
3380+
CustomerId = "cus_123",
3381+
AutomaticTax = new SubscriptionAutomaticTax { Enabled = false },
3382+
Items = new StripeList<SubscriptionItem> { Data = [] },
3383+
Metadata = new Dictionary<string, string> { { "userId", _userId.ToString() } }
3384+
};
3385+
var customer = new Customer
3386+
{
3387+
Id = "cus_123",
3388+
Subscriptions = new StripeList<Subscription> { Data = [subscription] },
3389+
Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }
3390+
};
3391+
var user = new User { Id = _userId, Email = "test@test.com", Premium = true };
3392+
3393+
// Phase 1 ended yesterday, Phase 2 active now
3394+
var phase1Start = DateTime.UtcNow.AddDays(-375);
3395+
var phase1End = DateTime.UtcNow.AddDays(-1);
3396+
var phase2End = DateTime.UtcNow.AddDays(364);
3397+
3398+
_stripeEventService.GetInvoice(parsedEvent).Returns(invoice);
3399+
_stripeAdapter.GetCustomerAsync(invoice.CustomerId, Arg.Any<CustomerGetOptions>()).Returns(customer);
3400+
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
3401+
.Returns(new Tuple<Guid?, Guid?, Guid?>(null, _userId, null));
3402+
_userRepository.GetByIdAsync(_userId).Returns(user);
3403+
_featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal).Returns(true);
3404+
3405+
_stripeAdapter.ListSubscriptionSchedulesAsync(Arg.Any<SubscriptionScheduleListOptions>())
3406+
.Returns(new StripeList<SubscriptionSchedule>
3407+
{
3408+
Data =
3409+
[
3410+
new SubscriptionSchedule
3411+
{
3412+
Id = "sub_sched_789",
3413+
SubscriptionId = "sub_123",
3414+
Status = SubscriptionScheduleStatus.Active,
3415+
Phases = new List<SubscriptionSchedulePhase>
3416+
{
3417+
new()
3418+
{
3419+
StartDate = phase1Start,
3420+
EndDate = phase1End,
3421+
Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_old", Quantity = 1 }],
3422+
Discounts = [],
3423+
ProrationBehavior = "none"
3424+
},
3425+
new()
3426+
{
3427+
StartDate = phase1End,
3428+
EndDate = phase2End,
3429+
Items = [new SubscriptionSchedulePhaseItem { PriceId = "price_new", Quantity = 1 }],
3430+
Discounts = [new SubscriptionSchedulePhaseDiscount { CouponId = "milestone-2c" }],
3431+
ProrationBehavior = "none"
3432+
}
3433+
}
3434+
}
3435+
]
3436+
});
3437+
3438+
// Act
3439+
await _sut.HandleAsync(parsedEvent);
3440+
3441+
// Assert — schedule updated: Phase 1 skipped, Phase 2 included with cleared discounts
3442+
await _stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync(
3443+
Arg.Is("sub_sched_789"),
3444+
Arg.Is<SubscriptionScheduleUpdateOptions>(o =>
3445+
o.DefaultSettings.AutomaticTax.Enabled == true &&
3446+
o.Phases.Count == 1 &&
3447+
o.Phases[0].AutomaticTax.Enabled == true &&
3448+
o.Phases[0].Items[0].Price == "price_new" &&
3449+
o.Phases[0].Discounts.Count == 0));
3450+
}
3451+
33123452
#endregion
33133453
}
33143454

0 commit comments

Comments
 (0)