Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ public async Task HandleAsync(Event parsedEvent)
Status: not StripeConstants.InvoiceStatus.Paid,
CollectionMethod: "charge_automatically",
BillingReason:
"subscription_cycle" or
"automatic_pending_invoice_item_invoice",
StripeConstants.BillingReasons.SubscriptionCycle or
StripeConstants.BillingReasons.AutomaticPendingInvoiceItemInvoice,
Parent.SubscriptionDetails.Subscription: not null
})
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ invoice is
AmountDue: > 0,
Status: not StripeConstants.InvoiceStatus.Paid,
CollectionMethod: "charge_automatically",
BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice",
BillingReason: StripeConstants.BillingReasons.SubscriptionCycle or StripeConstants.BillingReasons.AutomaticPendingInvoiceItemInvoice,
Parent.SubscriptionDetails: not null
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ invoice is
AmountDue: > 0,
Status: not StripeConstants.InvoiceStatus.Paid,
CollectionMethod: "charge_automatically",
BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice",
BillingReason: StripeConstants.BillingReasons.SubscriptionCycle or StripeConstants.BillingReasons.AutomaticPendingInvoiceItemInvoice,
Parent.SubscriptionDetails: not null
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,15 @@ public async Task HandleAsync(Event parsedEvent)

var currentPeriodEnd = subscription.GetCurrentPeriodEnd();

if (SubscriptionWentUnpaid(parsedEvent, subscription) ||
SubscriptionWentIncompleteExpired(parsedEvent, subscription))
if (SubscriptionWentUnpaid(parsedEvent, subscription))
{
if (!await SkipUnpaidBillingAutomationsForExemptOrganizationAsync(subscriberId))
{
await DisableSubscriberAsync(subscriberId, currentPeriodEnd);
await SetSubscriptionToCancelAsync(subscription);
}
}
Comment thread
kdenney marked this conversation as resolved.
else if (SubscriptionWentIncompleteExpired(parsedEvent, subscription))
{
await DisableSubscriberAsync(subscriberId, currentPeriodEnd);
await SetSubscriptionToCancelAsync(subscription);
Expand Down Expand Up @@ -130,7 +137,7 @@ SubscriptionStatus.Active or
} && currentSubscription is
{
Status: SubscriptionStatus.Unpaid,
LatestInvoice.BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle
LatestInvoice.BillingReason: BillingReasons.SubscriptionCycle
};

private static bool SubscriptionWentIncompleteExpired(
Expand Down Expand Up @@ -189,6 +196,31 @@ private Task DisableSubscriberAsync(SubscriberId subscriberId, DateTime? current
}
});

private Task<bool> SkipUnpaidBillingAutomationsForExemptOrganizationAsync(SubscriberId subscriberId) =>
subscriberId.Match(
_ => Task.FromResult(false),
async organizationId =>
{
var organization = await _organizationRepository.GetByIdAsync(organizationId.Value);

if (organization is not { Enabled: true, ExemptFromBillingAutomation: true })
{
return false;
}

// PM-31781: skip automations but set exempt to false so they aren't skipped forever
organization.ExemptFromBillingAutomation = false;
organization.RevisionDate = DateTime.UtcNow;
await _organizationRepository.ReplaceAsync(organization);

_logger.LogInformation(
"Skipping billing automations for exempt organization ({OrganizationId}). Exemption has been cleared",
organizationId.Value);

return true;
},
_ => Task.FromResult(false));

private Task EnableSubscriberAsync(SubscriberId subscriberId, DateTime? currentPeriodEnd) =>
subscriberId.Match(
async userId =>
Expand Down
2 changes: 2 additions & 0 deletions src/Core/Billing/Constants/StripeConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ public static class AutomaticTaxStatus

public static class BillingReasons
{
public const string AutomaticPendingInvoiceItemInvoice = "automatic_pending_invoice_item_invoice";
public const string SubscriptionCreate = "subscription_create";
public const string SubscriptionCycle = "subscription_cycle";
public const string SubscriptionUpdate = "subscription_update";
}

public static class CollectionMethod
Expand Down
219 changes: 219 additions & 0 deletions test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ await _stripeAdapter.Received(1).UpdateSubscriptionAsync(
options.ProrationBehavior == ProrationBehavior.None &&
options.CancellationDetails != null &&
options.CancellationDetails.Comment != null));
await _organizationRepository.DidNotReceive()
.ReplaceAsync(Arg.Any<Organization>());
}

[Fact]
Expand Down Expand Up @@ -1934,4 +1936,221 @@ public async Task HandleAsync_ScheduleTriggered_MultipleItems_MatchesFamiliesPri
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(o => o.Id == organizationId));
}

[Fact]
public async Task HandleAsync_UnpaidOrganizationSubscription_WithExemptOrganization_DoesNotDisableAndClearsExemption()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);

var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active
};

var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionCycle }
};

var organization = new Organization
{
Id = organizationId,
Enabled = true,
ExemptFromBillingAutomation = true,
PlanType = PlanType.EnterpriseAnnually2023
};

var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};

_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);

_organizationRepository.GetByIdAsync(organizationId).Returns(organization);

var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
_pricingClient.ListPlans().Returns(MockPlans.Plans);

// Act
await _sut.HandleAsync(parsedEvent);

// Assert
await _organizationDisableCommand.DidNotReceive()
.DisableAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
await _stripeAdapter.DidNotReceive()
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await _organizationRepository.Received(1).ReplaceAsync(
Arg.Is<Organization>(o => o.Id == organizationId && !o.ExemptFromBillingAutomation));
}

[Fact]
public async Task HandleAsync_UnpaidOrganizationSubscription_WithSubscriptionUpdateBillingReason_DoesNotDisableAndPreservesExemption()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);

var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active
};

var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.SubscriptionUpdate }
};

var organization = new Organization
{
Id = organizationId,
Enabled = true,
ExemptFromBillingAutomation = true,
PlanType = PlanType.EnterpriseAnnually2023
};

var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};

_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);

_organizationRepository.GetByIdAsync(organizationId).Returns(organization);

var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
_pricingClient.ListPlans().Returns(MockPlans.Plans);

// Act
await _sut.HandleAsync(parsedEvent);

// Assert β€” subscription_update billing reason does not match SubscriptionWentUnpaid
// (which filters on subscription_create and subscription_cycle only), so no disable,
// no cancellation, and the exempt flag is left unchanged.
await _organizationDisableCommand.DidNotReceive()
.DisableAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
await _stripeAdapter.DidNotReceive()
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await _organizationRepository.DidNotReceive()
.ReplaceAsync(Arg.Any<Organization>());
}

[Fact]
public async Task HandleAsync_UnpaidOrganizationSubscription_WithAutomaticPendingInvoiceItemInvoiceBillingReason_DoesNotDisableAndPreservesExemption()
{
// Arrange
var organizationId = Guid.NewGuid();
var subscriptionId = "sub_123";
var currentPeriodEnd = DateTime.UtcNow.AddDays(30);

var previousSubscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Active
};

var subscription = new Subscription
{
Id = subscriptionId,
Status = SubscriptionStatus.Unpaid,
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Plan = new Plan { Id = "2023-enterprise-org-seat-annually" }
}
]
},
Metadata = new Dictionary<string, string> { { "organizationId", organizationId.ToString() } },
LatestInvoice = new Invoice { BillingReason = BillingReasons.AutomaticPendingInvoiceItemInvoice }
};

var organization = new Organization
{
Id = organizationId,
Enabled = true,
ExemptFromBillingAutomation = true,
PlanType = PlanType.EnterpriseAnnually2023
};

var parsedEvent = new Event
{
Data = new EventData
{
Object = subscription,
PreviousAttributes = JObject.FromObject(previousSubscription)
}
};

_stripeEventService.GetSubscription(Arg.Any<Event>(), Arg.Any<bool>(), Arg.Any<List<string>>())
.Returns(subscription);

_organizationRepository.GetByIdAsync(organizationId).Returns(organization);

var plan = new Enterprise2023Plan(true);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);
_pricingClient.ListPlans().Returns(MockPlans.Plans);

// Act
await _sut.HandleAsync(parsedEvent);

// Assert β€” automatic_pending_invoice_item_invoice does not match SubscriptionWentUnpaid
// (which filters on subscription_create and subscription_cycle only), so no disable,
// no cancellation, and the exempt flag is left unchanged.
await _organizationDisableCommand.DidNotReceive()
.DisableAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
await _stripeAdapter.DidNotReceive()
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await _organizationRepository.DidNotReceive()
.ReplaceAsync(Arg.Any<Organization>());
}
}
Loading