Skip to content

Commit 3923e93

Browse files
fix(billing): preserve existing discounts in price migration schedules
1 parent 995ccbb commit 3923e93

5 files changed

Lines changed: 384 additions & 14 deletions

File tree

src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,12 @@ public async Task HandleAsync(Event parsedEvent)
4848
{
4949
var invoice = await stripeEventService.GetInvoice(parsedEvent);
5050

51+
// "subscriptions.data.discounts" is required by IPriceIncreaseScheduler.Schedule
52+
// to preserve pre-existing subscription-level discounts when constructing Phase 2.
53+
// See PM-35909.
5154
var customer =
5255
await stripeAdapter.GetCustomerAsync(invoice.CustomerId,
53-
new CustomerGetOptions { Expand = ["subscriptions", "subscriptions.data.test_clock", "tax", "tax_ids"] });
56+
new CustomerGetOptions { Expand = ["subscriptions", "subscriptions.data.discounts", "subscriptions.data.test_clock", "tax", "tax_ids"] });
5457

5558
var subscription = customer.Subscriptions.FirstOrDefault();
5659

src/Core/Billing/Pricing/PriceIncreaseScheduler.cs

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,19 @@ public async Task Release(string customerId, string subscriptionId)
172172
return null;
173173
}
174174

175+
// Detect callers who fetched the subscription without expanding "discounts".
176+
// Stripe.NET deserializes the unexpanded ID-array form as a list of null entries,
177+
// which would silently drop pre-existing discounts from Phase 2 (the bug PM-35909 fixed).
178+
if (subscription.Discounts is { Count: > 0 } && subscription.Discounts.Any(d => d == null))
179+
{
180+
logger.LogError(
181+
"Subscription ({SubscriptionId}) was loaded without expanding 'discounts'; " +
182+
"{Count} pre-existing discount(s) would be silently dropped from Phase 2. " +
183+
"Caller must include \"discounts\" in the Stripe Expand list.",
184+
subscription.Id, subscription.DiscountIds?.Count ?? 0);
185+
return null;
186+
}
187+
175188
try
176189
{
177190
SubscriberId subscriberId = subscription;
@@ -190,7 +203,7 @@ public async Task Release(string customerId, string subscriptionId)
190203
catch (Exception ex)
191204
{
192205
logger.LogError(ex,
193-
"Failed to resolve subscriber type for subscription ({SubscriptionId}), cannot determine price migration path",
206+
"Failed to resolve Phase 2 options for subscription ({SubscriptionId}), cannot determine price migration path",
194207
subscription.Id);
195208
return null;
196209
}
@@ -244,12 +257,21 @@ public async Task Release(string customerId, string subscriptionId)
244257
return null;
245258
}
246259

260+
var discounts = subscription.Discounts?
261+
.Select(d => new SubscriptionSchedulePhaseDiscountOptions { Coupon = d.Coupon.Id })
262+
.ToList() ?? [];
263+
264+
discounts.Add(new SubscriptionSchedulePhaseDiscountOptions
265+
{
266+
Coupon = CouponIDs.Milestone2SubscriptionDiscount
267+
});
268+
247269
return new SubscriptionSchedulePhaseOptions
248270
{
249271
StartDate = startDate,
250272
EndDate = startDate.Value.AddYears(1),
251273
Items = items,
252-
Discounts = [new() { Coupon = CouponIDs.Milestone2SubscriptionDiscount }],
274+
Discounts = discounts,
253275
ProrationBehavior = ProrationBehavior.None
254276
};
255277
}
@@ -291,12 +313,17 @@ public async Task Release(string customerId, string subscriptionId)
291313
});
292314
}
293315

294-
var discounts = oldPlan.Type == PlanType.FamiliesAnnually2019
295-
? new List<SubscriptionSchedulePhaseDiscountOptions>
316+
var discounts = subscription.Discounts?
317+
.Select(d => new SubscriptionSchedulePhaseDiscountOptions { Coupon = d.Coupon.Id })
318+
.ToList() ?? [];
319+
320+
if (oldPlan.Type == PlanType.FamiliesAnnually2019)
321+
{
322+
discounts.Add(new SubscriptionSchedulePhaseDiscountOptions
296323
{
297-
new() { Coupon = CouponIDs.Milestone3SubscriptionDiscount }
298-
}
299-
: null;
324+
Coupon = CouponIDs.Milestone3SubscriptionDiscount
325+
});
326+
}
300327

301328
var startDate = subscription.GetCurrentPeriodEnd();
302329
if (startDate == null)
@@ -312,7 +339,7 @@ public async Task Release(string customerId, string subscriptionId)
312339
StartDate = startDate,
313340
EndDate = startDate.Value.AddYears(1),
314341
Items = items,
315-
Discounts = discounts,
342+
Discounts = discounts.Count > 0 ? discounts : null,
316343
ProrationBehavior = ProrationBehavior.None
317344
};
318345
}

src/Core/Billing/Subscriptions/Commands/ReinstateSubscriptionCommand.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ public class ReinstateSubscriptionCommand(
2828

2929
public Task<BillingCommandResult<None>> Run(ISubscriber subscriber) => HandleAsync<None>(async () =>
3030
{
31-
var subscription = await stripeAdapter.GetSubscriptionAsync(subscriber.GatewaySubscriptionId);
31+
var subscription = await stripeAdapter.GetSubscriptionAsync(
32+
subscriber.GatewaySubscriptionId,
33+
new SubscriptionGetOptions { Expand = ["discounts"] });
3234

3335
if (subscription is not
3436
{

0 commit comments

Comments
 (0)