Skip to content
Closed
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 @@ -56,6 +56,27 @@ export const isStripeSubscriptionCanceled = (
return stripeSubscription.status === "canceled";
};

const UNHEALTHY_STATUSES: Stripe.Subscription.Status[] = [
"past_due",
"incomplete",
"incomplete_expired",
"unpaid",
"paused",
];

/**
* Checks if a Stripe subscription has a payment-problematic status.
* These statuses all map to CusProductStatus.PastDue and indicate the subscription
* should not be used as a merge target for new products.
*/
export const isStripeSubscriptionUnhealthy = (
stripeSubscription?: Stripe.Subscription,
): boolean => {
if (!stripeSubscription) return false;

return UNHEALTHY_STATUSES.includes(stripeSubscription.status);
};

/**
* Checks if a Stripe subscription has any metered price items.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { BillingPlan } from "@autumn/shared";
import { CusProductStatus } from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli";
import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
import type Stripe from "stripe";

/**
* Removes expired product items from an old subscription when transitioning to a new subscription.
*
* When a product transitions away from an unhealthy (past_due) subscription to a new one,
* the old product's items remain on the old subscription. This function cleans them up
* to prevent future charges for the expired product.
*
* If removing the items leaves the old subscription with no items, the subscription is canceled.
*/
export const cleanupOldSubscriptionItems = async ({
ctx,
billingPlan,
newStripeSubscription,
}: {
ctx: AutumnContext;
billingPlan: BillingPlan;
newStripeSubscription?: Stripe.Subscription;
}) => {
const { updateCustomerProduct } = billingPlan.autumn;
if (!updateCustomerProduct) return;

// Only clean up when the old product is being expired (immediate transition)
if (updateCustomerProduct.updates.status !== CusProductStatus.Expired) return;

// Cleanup only applies when transitioning to a new subscription
if (!newStripeSubscription) return;

const oldCustomerProduct = updateCustomerProduct.customerProduct;
const oldSubscriptionId = oldCustomerProduct.subscription_ids?.[0];

if (!oldSubscriptionId) return;

// Same subscription -- items already handled by buildStripeSubscriptionItemsUpdate diff
if (oldSubscriptionId === newStripeSubscription.id) return;

const { logger } = ctx;
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });

const oldSubscription =
await stripeCli.subscriptions.retrieve(oldSubscriptionId);

if (isStripeSubscriptionCanceled(oldSubscription)) return;

// Get the old product's Stripe price IDs
const { recurringItems } = customerProductToStripeItemSpecs({
ctx,
customerProduct: oldCustomerProduct,
});

const oldPriceIds = new Set(
recurringItems.map((item) => item.stripePriceId),
);

// Find subscription items belonging to the expired product
const itemsToRemove = oldSubscription.items.data.filter((item) =>
oldPriceIds.has(item.price.id),
);

if (itemsToRemove.length === 0) return;

// If ALL items would be removed, cancel the entire subscription
if (itemsToRemove.length === oldSubscription.items.data.length) {
logger.debug(
`[cleanupOldSubscriptionItems] Canceling old subscription ${oldSubscriptionId} (no items remaining)`,
);
await stripeCli.subscriptions.cancel(oldSubscriptionId);
return;
}

// Otherwise, remove only the expired product's items
logger.debug(
`[cleanupOldSubscriptionItems] Removing ${itemsToRemove.length} items from old subscription ${oldSubscriptionId}`,
);
// No proration -- the product is expired and the subscription is already past_due,
// so we don't want to generate prorated credits for items that weren't being paid for
await stripeCli.subscriptions.update(oldSubscriptionId, {
items: itemsToRemove.map((item) => ({ id: item.id, deleted: true })),
proration_behavior: "none",
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { executeStripeCheckoutSessionAction } from "@/internal/billing/v2/provid
import { executeStripeInvoiceAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction";
import { executeStripeSubscriptionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction";
import { executeStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction";
import { cleanupOldSubscriptionItems } from "@/internal/billing/v2/providers/stripe/execute/cleanupOldSubscriptionItems";
import { createStripeInvoiceItems } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps";

export const executeStripeBillingPlan = async ({
Expand Down Expand Up @@ -94,6 +95,13 @@ export const executeStripeBillingPlan = async ({
if (subscriptionResult?.deferred) return subscriptionResult;
stripeSubscription =
subscriptionResult.stripeSubscription ?? stripeSubscription;

// Clean up expired product items from old subscription when transitioning across subscriptions
await cleanupOldSubscriptionItems({
ctx,
billingPlan,
newStripeSubscription: subscriptionResult.stripeSubscription,
});
}

if (stripeSubscriptionScheduleAction && !isReleaseAction) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
import { createStripeCli } from "@server/external/connect/createStripeCli";
import type { StripeSubscriptionWithDiscounts } from "@server/external/stripe/subscriptions";
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import {
isStripeSubscriptionCanceled,
isStripeSubscriptionUnhealthy,
} from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";

/**
* Fetches a Stripe subscription with expanded discounts for billing operations.
Expand Down Expand Up @@ -63,5 +66,14 @@ export const fetchStripeSubscriptionForBilling = async ({
});
}

// Don't merge new products onto unhealthy subscriptions (past_due, incomplete, etc.)
// Force creation of a new subscription so the new product isn't affected by existing payment issues
if (isStripeSubscriptionUnhealthy(sub)) {
ctx.logger.info(
`[fetchStripeSubscriptionForBilling] Skipping unhealthy subscription ${subId} (status: ${sub.status}), will create new subscription`,
);
return undefined;
}

return sub as StripeSubscriptionWithDiscounts;
};