Skip to content

Commit 995ccbb

Browse files
[PM-34565] Save Cancellation Details for Scheduled Subscriptions (#7535)
* refactor(billing): add constant for deferred price increase cancellation * feat(billing): update cancellation logic to release schedules and set metadata * feat(billing): update reinstatement logic to recreate schedules * style(billing): cleanup formatting * fix(billing) run dotnet format * docs(billing): clarify stripe subscription update behavior regarding schedules
1 parent 329b144 commit 995ccbb

6 files changed

Lines changed: 58 additions & 567 deletions

File tree

src/Billing/Services/IStripeWebhookHandler.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,4 @@ public interface ICouponDeletedHandler : IStripeWebhookHandler;
7777
/// Defines the contract for handling Stripe checkout session completed events.
7878
/// </summary>
7979
public interface ICheckoutSessionCompletedHandler : IStripeWebhookHandler;
80+

src/Core/Billing/Constants/StripeConstants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ public static class MetadataKeys
9393
public const string OriginatingPlatform = "originatingPlatform";
9494
public const string OriginatingAppVersion = "originatingAppVersion";
9595
public const string TrialInitiationPath = "trialInitiationPath";
96+
public const string CancelledDuringDeferredPriceIncrease = "cancelled_during_deferred_price_increase";
9697
}
9798

9899
public static class PaymentBehavior

src/Core/Billing/Services/Implementations/SubscriberService.cs

Lines changed: 12 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -261,67 +261,33 @@ private async Task CancelSubscriptionAtPeriodEndAsync(
261261
SubscriptionCancellationDetailsOptions? cancellationDetails,
262262
Dictionary<string, string>? cancellingUserMetadata)
263263
{
264-
var updateOptions = new SubscriptionUpdateOptions();
264+
var updateOptions = new SubscriptionUpdateOptions
265+
{
266+
CancelAtPeriodEnd = true,
267+
CancellationDetails = cancellationDetails,
268+
Metadata = cancellingUserMetadata
269+
};
265270

266271
if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal))
267272
{
268273
var activeSchedule = await GetActiveScheduleAsync(subscription);
269274

270275
if (activeSchedule is { Phases.Count: > 0 })
271276
{
272-
if (activeSchedule.Phases.Count > 2)
273-
{
274-
logger.LogWarning(
275-
"{Service}: Subscription schedule ({ScheduleId}) has {PhaseCount} phases (expected 1-2), updating to only one phase for cancellation",
276-
GetType().Name, activeSchedule.Id, activeSchedule.Phases.Count);
277-
}
278-
279277
logger.LogInformation(
280-
"{Service}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), updating schedule phases",
278+
"{Service}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), releasing schedule before cancellation",
281279
GetType().Name, activeSchedule.Id, subscription.Id);
282280

283-
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
284-
var currentPhase = activeSchedule.Phases.FirstOrDefault(p => p.EndDate > now)
285-
?? activeSchedule.Phases[^1];
281+
await stripeAdapter.ReleaseSubscriptionScheduleAsync(activeSchedule.Id);
286282

287-
await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id,
288-
new SubscriptionScheduleUpdateOptions
289-
{
290-
EndBehavior = SubscriptionScheduleEndBehavior.Cancel,
291-
Phases =
292-
[
293-
new SubscriptionSchedulePhaseOptions
294-
{
295-
StartDate = currentPhase.StartDate,
296-
EndDate = currentPhase.EndDate,
297-
Items = currentPhase.Items.Select(i => new SubscriptionSchedulePhaseItemOptions
298-
{
299-
Price = i.PriceId,
300-
Quantity = i.Quantity
301-
}).ToList(),
302-
Discounts = currentPhase.StartDate <= now
303-
? []
304-
: currentPhase.Discounts?.Select(d =>
305-
new SubscriptionSchedulePhaseDiscountOptions { Coupon = d.CouponId }).ToList(),
306-
ProrationBehavior = ProrationBehavior.None,
307-
Metadata = cancellingUserMetadata
308-
}
309-
]
310-
});
311-
return;
283+
updateOptions.Metadata = new Dictionary<string, string>(cancellingUserMetadata ?? [])
284+
{
285+
[MetadataKeys.CancelledDuringDeferredPriceIncrease] = "true"
286+
};
312287
}
313288
}
314289

315-
updateOptions.CancelAtPeriodEnd = true;
316-
317-
if (cancellationDetails != null)
318-
{
319-
updateOptions.CancellationDetails = cancellationDetails;
320-
updateOptions.Metadata = cancellingUserMetadata;
321-
}
322-
323290
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, updateOptions);
324-
325291
}
326292

327293
private async Task<SubscriptionSchedule?> GetActiveScheduleAsync(Subscription subscription)

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

Lines changed: 14 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -41,55 +41,25 @@ public Task<BillingCommandResult<None>> Run(ISubscriber subscriber) => HandleAsy
4141

4242
if (featureService.IsEnabled(FeatureFlagKeys.PM32645_DeferPriceMigrationToRenewal))
4343
{
44-
var activeSchedule = await GetActiveScheduleAsync(subscription);
45-
46-
// if there is an active schedule, we need to update it to include Phase 2 because it was removed during cancellation
47-
if (activeSchedule is { Phases.Count: > 0 })
44+
if (subscription.Metadata?.ContainsKey(MetadataKeys.CancelledDuringDeferredPriceIncrease) == true)
4845
{
49-
if (activeSchedule.Phases.Count > 1)
50-
{
51-
_logger.LogError(
52-
"{Command}: Subscription schedule ({ScheduleId}) has {PhaseCount} phases (expected 1 after cancellation), updating to add Phase 2",
53-
CommandName, activeSchedule.Id, activeSchedule.Phases.Count);
54-
return DefaultConflict;
55-
}
56-
5746
_logger.LogInformation(
58-
"{Command}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), updating schedule phases",
59-
CommandName, activeSchedule.Id, subscription.Id);
47+
"{Command}: Subscription ({SubscriptionId}) has pending price increase, clearing flag and recreating schedule",
48+
CommandName, subscription.Id);
6049

61-
var phase2 = await priceIncreaseScheduler.ResolvePhase2Async(subscription);
62-
if (phase2 == null)
50+
// Clear pending cancellation and flag BEFORE attaching a schedule.
51+
// Stripe discourages direct subscription updates once a schedule is attached as it can create inconsistencies in phases.
52+
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions
6353
{
64-
_logger.LogError("Failed to resolve Phase 2 for Subscription {SubscriptionId}", subscription.Id);
65-
return DefaultConflict;
66-
}
67-
var phase1 = activeSchedule.Phases[0];
68-
69-
await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id,
70-
new SubscriptionScheduleUpdateOptions
54+
CancelAtPeriodEnd = false,
55+
Metadata = new Dictionary<string, string>
7156
{
72-
EndBehavior = SubscriptionScheduleEndBehavior.Release,
73-
Phases =
74-
[
75-
new SubscriptionSchedulePhaseOptions
76-
{
77-
StartDate = phase1.StartDate,
78-
EndDate = phase1.EndDate,
79-
Items = phase1.Items.Select(i => new SubscriptionSchedulePhaseItemOptions
80-
{
81-
Price = i.PriceId,
82-
Quantity = i.Quantity
83-
}).ToList(),
84-
Discounts = phase1.Discounts?.Select(d => new SubscriptionSchedulePhaseDiscountOptions
85-
{
86-
Coupon = d.CouponId
87-
}).ToList(),
88-
ProrationBehavior = ProrationBehavior.None
89-
},
90-
phase2
91-
]
92-
});
57+
[MetadataKeys.CancelledDuringDeferredPriceIncrease] = ""
58+
}
59+
});
60+
61+
await priceIncreaseScheduler.Schedule(subscription);
62+
9363
return new None();
9464
}
9565
}
@@ -103,14 +73,4 @@ await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id,
10373

10474
return new None();
10575
});
106-
107-
private async Task<SubscriptionSchedule?> GetActiveScheduleAsync(Subscription subscription)
108-
{
109-
var schedules = await stripeAdapter.ListSubscriptionSchedulesAsync(
110-
new SubscriptionScheduleListOptions { Customer = subscription.CustomerId });
111-
112-
return schedules.Data.FirstOrDefault(s =>
113-
s.SubscriptionId == subscription.Id &&
114-
s.Status == SubscriptionScheduleStatus.Active);
115-
}
11676
}

test/Core.Test/Billing/Services/SubscriberServiceTests.cs

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ await sutProvider.GetDependency<IPriceIncreaseScheduler>()
362362
}
363363

364364
[Theory, BitAutoData]
365-
public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule_UpdatesScheduleEndBehaviorToCancel(
365+
public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule_ReleasesScheduleAndSetsCancelAtPeriodEnd(
366366
Organization organization,
367367
SutProvider<SubscriberService> sutProvider)
368368
{
@@ -413,21 +413,20 @@ public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule
413413

414414
await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false);
415415

416-
await stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync(scheduleId,
417-
Arg.Is<SubscriptionScheduleUpdateOptions>(o =>
418-
o.EndBehavior == StripeConstants.SubscriptionScheduleEndBehavior.Cancel &&
419-
o.Phases.Count == 1 &&
420-
o.Phases[0].Items.Any(i => i.Price == "old-price") &&
421-
o.Phases[0].Metadata == null));
416+
await stripeAdapter.Received(1).ReleaseSubscriptionScheduleAsync(scheduleId);
417+
await stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId,
418+
Arg.Is<SubscriptionUpdateOptions>(o =>
419+
o.CancelAtPeriodEnd == true &&
420+
o.Metadata.ContainsKey(StripeConstants.MetadataKeys.CancelledDuringDeferredPriceIncrease)));
422421

423422
await stripeAdapter.DidNotReceiveWithAnyArgs()
424-
.CancelSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
423+
.UpdateSubscriptionScheduleAsync(Arg.Any<string>(), Arg.Any<SubscriptionScheduleUpdateOptions>());
425424
await stripeAdapter.DidNotReceiveWithAnyArgs()
426-
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
425+
.CancelSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
427426
}
428427

429428
[Theory, BitAutoData]
430-
public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule_WithSurvey_AlsoUpdatesSubscriptionWithCancellationDetails(
429+
public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule_WithSurvey_ReleasesScheduleAndUpdatesCancellationDetails(
431430
Organization organization,
432431
SutProvider<SubscriberService> sutProvider)
433432
{
@@ -486,12 +485,17 @@ public async Task CancelSubscription_CancelAtEndOfPeriod_FlagOn_TwoPhaseSchedule
486485

487486
await sutProvider.Sut.CancelSubscription(organization, cancelImmediately: false, offboardingSurveyResponse);
488487

489-
await stripeAdapter.Received(1).UpdateSubscriptionScheduleAsync(scheduleId,
490-
Arg.Is<SubscriptionScheduleUpdateOptions>(o =>
491-
o.EndBehavior == StripeConstants.SubscriptionScheduleEndBehavior.Cancel &&
492-
o.Phases.Count == 1 &&
493-
o.Phases[0].Metadata["cancellingUserId"] == userId.ToString()));
488+
await stripeAdapter.Received(1).ReleaseSubscriptionScheduleAsync(scheduleId);
489+
await stripeAdapter.Received(1).UpdateSubscriptionAsync(subscriptionId,
490+
Arg.Is<SubscriptionUpdateOptions>(o =>
491+
o.CancelAtPeriodEnd == true &&
492+
o.CancellationDetails.Comment == "Too pricey" &&
493+
o.CancellationDetails.Feedback == "too_expensive" &&
494+
o.Metadata["cancellingUserId"] == userId.ToString() &&
495+
o.Metadata.ContainsKey(StripeConstants.MetadataKeys.CancelledDuringDeferredPriceIncrease)));
494496

497+
await stripeAdapter.DidNotReceiveWithAnyArgs()
498+
.UpdateSubscriptionScheduleAsync(Arg.Any<string>(), Arg.Any<SubscriptionScheduleUpdateOptions>());
495499
await stripeAdapter.DidNotReceiveWithAnyArgs()
496500
.CancelSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
497501
}

0 commit comments

Comments
 (0)