Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,80 @@ public Task<BillingCommandResult<Subscription>> Run(
items.Add(validationResult.AsT0);
}

var schedules = await stripeAdapter.ListSubscriptionSchedulesAsync(
new SubscriptionScheduleListOptions { Customer = subscription.CustomerId });
var activeSchedule = schedules.Data.FirstOrDefault(s =>
s.Status == SubscriptionScheduleStatus.Active && s.SubscriptionId == subscription.Id);

// An active schedule here means PriceIncreaseScheduler created a schedule to defer a
// Families price migration to renewal. A 2-phase schedule is the standard migration
// state; a 1-phase schedule means the subscription was cancelled (end-of-period) while
// a schedule was attached (PM-33897). Either way, we update via the schedule to avoid
// conflicting with Stripe's schedule ownership of the subscription.
if (activeSchedule is { Phases.Count: > 0 })
{
if (activeSchedule.Phases.Count > 2)
{
_logger.LogWarning(
"{Command}: Subscription schedule ({ScheduleId}) has {PhaseCount} phases (expected 1-2), only updating first two",
CommandName, activeSchedule.Id, activeSchedule.Phases.Count);
}

_logger.LogInformation(
"{Command}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), updating schedule phases",
CommandName, activeSchedule.Id, subscription.Id);

var phase1 = activeSchedule.Phases[0];

// This applies the change set's price IDs (which are Phase 1 / current-plan prices)
// to both phases. This works because storage prices are uniform across the Families
// migration. If storage prices ever differ between phases, both this command and
// UpdatePremiumStorageCommand would need plan-aware price resolution (e.g. matching
// Phase 2's seat price to determine the correct storage price).
var phases = new List<SubscriptionSchedulePhaseOptions>
{
new()
{
StartDate = phase1.StartDate,
EndDate = phase1.EndDate,
Items = ApplyChangesToPhaseItems(phase1.Items, changeSet.Changes),
Discounts = phase1.Discounts?.Select(d =>
new SubscriptionSchedulePhaseDiscountOptions { Coupon = d.CouponId }).ToList(),
ProrationBehavior = prorationBehavior
}
};

if (activeSchedule.Phases.Count >= 2)
{
var phase2 = activeSchedule.Phases[1];
phases.Add(new SubscriptionSchedulePhaseOptions
{
StartDate = phase2.StartDate,
EndDate = phase2.EndDate,
Items = ApplyChangesToPhaseItems(phase2.Items, changeSet.Changes),
Discounts = phase2.Discounts?.Select(d =>
new SubscriptionSchedulePhaseDiscountOptions { Coupon = d.CouponId }).ToList(),
ProrationBehavior = phase2.ProrationBehavior
});
}

// Note: the schedule phase API does not support PendingInvoiceItemInterval. For annual
// subscribers, the non-schedule path invoices prorations monthly. Here, prorations
// remain pending until the next invoice (~15 days, when the schedule was created on
// invoice.upcoming). Accepted trade-off for the migration window.
await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id,
new SubscriptionScheduleUpdateOptions
{
EndBehavior = activeSchedule.EndBehavior,
Phases = phases
});

// Note: this returns the pre-update subscription. The schedule update modified the
// subscription via Stripe, but we don't re-fetch it. Callers currently only check
// success/failure. If a caller ever needs the post-update state, re-fetch here.
return subscription;
}

var options = new SubscriptionUpdateOptions { Items = items, ProrationBehavior = prorationBehavior };

if (paymentBehavior is not null)
Expand Down Expand Up @@ -245,4 +319,65 @@ private static OneOf<SubscriptionItemOptions, BadRequest> ValidateItemRemoval(
Deleted = true
};
}

private static List<SubscriptionSchedulePhaseItemOptions> ApplyChangesToPhaseItems(
IList<SubscriptionSchedulePhaseItem> phaseItems,
IReadOnlyList<OrganizationSubscriptionChange> changes)
{
// Note: when a change targets a price ID not present in this phase (e.g. Phase 2 has
// migrated prices), the change is silently skipped. This is safe because subscription-
// level validation (ValidateItemAddition, ValidateItemPriceChange, etc.) already ran
// before this method is called.
var items = phaseItems
.Select(i => new SubscriptionSchedulePhaseItemOptions { Price = i.PriceId, Quantity = i.Quantity })
.ToList();

foreach (var change in changes)
{
change.Switch(
addItem => items.Add(new SubscriptionSchedulePhaseItemOptions
{
Price = addItem.PriceId,
Quantity = addItem.Quantity
}),
changeItemPrice =>
{
var existing = items.FirstOrDefault(i => i.Price == changeItemPrice.CurrentPriceId);
if (existing != null)
{
existing.Price = changeItemPrice.UpdatedPriceId;
if (changeItemPrice.Quantity.HasValue)
{
existing.Quantity = changeItemPrice.Quantity.Value;
}
}
},
removeItem => items.RemoveAll(i => i.Price == removeItem.PriceId),
updateItemQuantity =>
{
if (updateItemQuantity.Quantity == 0)
{
items.RemoveAll(i => i.Price == updateItemQuantity.PriceId);
}
else
{
var existing = items.FirstOrDefault(i => i.Price == updateItemQuantity.PriceId);
if (existing != null)
{
existing.Quantity = updateItemQuantity.Quantity;
}
else
{
items.Add(new SubscriptionSchedulePhaseItemOptions
{
Price = updateItemQuantity.PriceId,
Quantity = updateItemQuantity.Quantity
});
}
}
});
}

return items;
}
}
115 changes: 100 additions & 15 deletions src/Core/Billing/Premium/Commands/UpdatePremiumStorageCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public class UpdatePremiumStorageCommand(
ILogger<UpdatePremiumStorageCommand> logger)
: BaseBillingCommand<UpdatePremiumStorageCommand>(logger), IUpdatePremiumStorageCommand
{
private readonly ILogger<UpdatePremiumStorageCommand> _logger = logger;

public Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb) => HandleAsync<None>(async () =>
{
if (user is not { Premium: true, GatewaySubscriptionId: not null and not "" })
Expand Down Expand Up @@ -134,16 +136,87 @@ public Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb

var usingPayPal = subscription.Customer.Metadata.ContainsKey(MetadataKeys.BraintreeCustomerId);

if (usingPayPal)
var schedules = await stripeAdapter.ListSubscriptionSchedulesAsync(
new SubscriptionScheduleListOptions { Customer = subscription.CustomerId });
var activeSchedule = schedules.Data.FirstOrDefault(s =>
s.Status == SubscriptionScheduleStatus.Active && s.SubscriptionId == subscription.Id);

// An active schedule here means PriceIncreaseScheduler created a schedule to defer a
// Premium price migration to renewal. A 2-phase schedule is the standard migration state;
// a 1-phase schedule means the subscription was cancelled (end-of-period) while a schedule
// was attached (PM-33897). Either way, we update via the schedule to avoid conflicting
// with Stripe's schedule ownership of the subscription.
if (activeSchedule is { Phases.Count: > 0 })
{
var options = new SubscriptionUpdateOptions
if (activeSchedule.Phases.Count > 2)
{
Items = subscriptionItemOptions,
ProrationBehavior = ProrationBehavior.CreateProrations
_logger.LogWarning(
"{Command}: Subscription schedule ({ScheduleId}) has {PhaseCount} phases (expected 1-2), only updating first two",
CommandName, activeSchedule.Id, activeSchedule.Phases.Count);
}

_logger.LogInformation(
"{Command}: Active subscription schedule ({ScheduleId}) found for subscription ({SubscriptionId}), updating schedule phases",
CommandName, activeSchedule.Id, subscription.Id);

var phase1 = activeSchedule.Phases[0];

// Storage prices are uniform across the Premium migration, so we use the same price
// for both phases. If storage prices ever differ between phases, this would need
// plan-aware price resolution (e.g. matching Phase 2's seat price to a plan).
var storagePriceId = premiumPlan.Storage.StripePriceId;

var phases = new List<SubscriptionSchedulePhaseOptions>
{
new()
{
StartDate = phase1.StartDate,
EndDate = phase1.EndDate,
Items = BuildPhaseItemsWithStorage(phase1.Items, storagePriceId, additionalStorageGb),
Discounts = phase1.Discounts?.Select(d =>
new SubscriptionSchedulePhaseDiscountOptions { Coupon = d.CouponId }).ToList(),
ProrationBehavior = usingPayPal
? ProrationBehavior.CreateProrations
: ProrationBehavior.AlwaysInvoice
}
};

await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options);
if (activeSchedule.Phases.Count >= 2)
{
var phase2 = activeSchedule.Phases[1];
phases.Add(new SubscriptionSchedulePhaseOptions
{
StartDate = phase2.StartDate,
EndDate = phase2.EndDate,
Items = BuildPhaseItemsWithStorage(phase2.Items, storagePriceId, additionalStorageGb),
Discounts = phase2.Discounts?.Select(d =>
new SubscriptionSchedulePhaseDiscountOptions { Coupon = d.CouponId }).ToList(),
ProrationBehavior = phase2.ProrationBehavior
});
}

await stripeAdapter.UpdateSubscriptionScheduleAsync(activeSchedule.Id,
new SubscriptionScheduleUpdateOptions
{
EndBehavior = activeSchedule.EndBehavior,
Phases = phases
});
}
else
{
// CreateProrations for PayPal: pending proration items are picked up by the manual invoice below.
// AlwaysInvoice for card: Stripe generates and charges the proration invoice automatically.
await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, new SubscriptionUpdateOptions
{
Items = subscriptionItemOptions,
ProrationBehavior = usingPayPal
? ProrationBehavior.CreateProrations
: ProrationBehavior.AlwaysInvoice
});
}

if (usingPayPal)
{
var draftInvoice = await stripeAdapter.CreateInvoiceAsync(new InvoiceCreateOptions
{
Customer = subscription.CustomerId,
Expand All @@ -157,21 +230,33 @@ public Task<BillingCommandResult<None>> Run(User user, short additionalStorageGb

await braintreeService.PayInvoice(new UserId(user.Id), finalizedInvoice);
}
else
{
var options = new SubscriptionUpdateOptions
{
Items = subscriptionItemOptions,
ProrationBehavior = ProrationBehavior.AlwaysInvoice
};

await stripeAdapter.UpdateSubscriptionAsync(subscription.Id, options);
}

// Update the user's max storage
user.MaxStorageGb = maxStorageGb;
await userService.SaveUserAsync(user);

return new None();
});

private static List<SubscriptionSchedulePhaseItemOptions> BuildPhaseItemsWithStorage(
IList<SubscriptionSchedulePhaseItem> phaseItems,
string storagePriceId,
short additionalStorageGb)
{
var items = phaseItems
.Where(i => i.PriceId != storagePriceId)
.Select(i => new SubscriptionSchedulePhaseItemOptions { Price = i.PriceId, Quantity = i.Quantity })
.ToList();

if (additionalStorageGb > 0)
{
items.Add(new SubscriptionSchedulePhaseItemOptions
{
Price = storagePriceId,
Quantity = additionalStorageGb
});
}

return items;
}
}
Loading
Loading