Skip to content
Merged

release #3189

Show file tree
Hide file tree
Changes from 43 commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
e63acf3
fix(revenuecat): clear cancellation when a past-due product renews
SirTenzin Aug 31, 2026
c1027d7
Merge pull request #3171 from useautumn/fix/revenuecat-renewal-stale-…
SirTenzin Aug 31, 2026
c0df6ab
feat: show deferred plans as pending before payment
charlietlamb Aug 31, 2026
a957834
feat: insert pending plans for deferred checkouts
charlietlamb Aug 31, 2026
b57c014
test: cover checkout completion for pending plans
charlietlamb Aug 31, 2026
606562f
fix: persist custom catalog rows before pending plans
charlietlamb Aug 31, 2026
860dbe5
fix: expire pending plans that already have a subscription
charlietlamb Aug 31, 2026
35c8a37
chore: drop unrelated past-due scenario file
charlietlamb Aug 31, 2026
8b704fd
fix: never let pending cleanup block invoice void
charlietlamb Aug 31, 2026
9821317
fix: keep pending plans out of RELEVANT_STATUSES
charlietlamb Aug 31, 2026
832ff54
refactor: filter abandoned rows at the call site
charlietlamb Sep 1, 2026
938d98e
fix: hide row actions on pending plans
charlietlamb Sep 1, 2026
71199be
feat: cancel a pending plan by discarding it
charlietlamb Sep 1, 2026
e5517b3
fix: guard pending expiry against a concurrent promotion
charlietlamb Sep 1, 2026
ad89db1
fix: promote pending plans to the status the plan chose
charlietlamb Sep 1, 2026
47f7121
fix: carry rollovers over when a pending plan goes live
charlietlamb Sep 1, 2026
ee654e5
fix: only defer a wholly deferred multi-subscription request
charlietlamb Sep 1, 2026
f09c545
feat: link to the payment a pending plan is waiting on
charlietlamb Sep 1, 2026
d832709
fix: cancel a pending plan without pricing a subscription change
charlietlamb Sep 1, 2026
5c6858b
fix: discard a pending plan cancelled through billing.update
charlietlamb Sep 1, 2026
c3e9b63
fix: scope the pending payment link to the request org
charlietlamb Sep 1, 2026
c21ff2f
refactor: drop comments that restate the code
charlietlamb Sep 1, 2026
aeea69b
refactor: reuse the existing Stripe operations
charlietlamb Sep 1, 2026
61b4ed3
feat: edit a plan awaiting payment
charlietlamb Sep 1, 2026
890c082
fix: only reissue payment when the edit changes billing
charlietlamb Sep 1, 2026
2bbe338
fix: keep the payment when an edit only changes entitlements
charlietlamb Sep 1, 2026
01b9e07
fix: warn about reissuing only once an edit would rebill
charlietlamb Sep 1, 2026
bf5d24d
fix(analytics): preserve billing cycle boundaries
SirTenzin Sep 1, 2026
fe5dc5a
test: give every pending test a unique customer
charlietlamb Sep 1, 2026
ff3bb07
Merge pull request #3179 from useautumn/capy/use-base-price-billing
SirTenzin Sep 1, 2026
ab6c0b5
fix: keep the payment linked when a non-billing edit replaces the row
charlietlamb Sep 1, 2026
a2aab8c
copy: warn that updating creates a new payment link
charlietlamb Sep 1, 2026
bab5fca
test: seed a paid plan with a pending upgrade
charlietlamb Sep 1, 2026
30a5c3b
refactor: collapse the cancel discard to one call
charlietlamb Sep 1, 2026
a6b3ea4
fix(billing): 🐛 don't bill usage a grant covered when it becomes unli…
SirTenzin Sep 1, 2026
c697b23
refactor: resolve the pending plan inside the action
charlietlamb Sep 1, 2026
1106275
fix: prevent lazy usage-window reset stampedes
SirTenzin Sep 1, 2026
26f0790
refactor: let the pending plan code speak for itself
charlietlamb Sep 1, 2026
be37757
refactor: share featureOptionsAreSame
charlietlamb Sep 1, 2026
c808cda
Merge pull request #3172 from useautumn/charlie/pending-customer-prod…
charlietlamb Sep 1, 2026
8f131c2
fix(billing): 🐛 keep overage accrued before a grant becomes unlimited
SirTenzin Sep 1, 2026
f0c8064
fix: stop reporting idle SQS workers to Sentry
SirTenzin Sep 1, 2026
3d976b1
Merge pull request #3188 from useautumn/capy/stop-reporting-idle-sqs
og2701 Sep 1, 2026
478c2d6
Merge pull request #3183 from useautumn/fix/unlimited-carryover-overage
SirTenzin Sep 1, 2026
9360b2b
feat(attach): default trial-on-end to revert, swap checkbox for a select
ay-rod Sep 1, 2026
13d4168
Merge pull request #3190 from useautumn/feat/trial-on-end-select
ay-rod Sep 1, 2026
cf4545b
fix(billing): preserve terms across version updates
charlietlamb Sep 1, 2026
7c954f5
test(billing): 🧪 assert accruedOverage in carry-over shape expectations
SirTenzin Sep 1, 2026
abdd06f
fix(dashboard): collapse hidden billing prompt spacing
charlietlamb Sep 1, 2026
38212f0
Merge pull request #3187 from useautumn/capy/prevent-lazy-usage-window
SirTenzin Sep 1, 2026
dfb3644
Merge pull request #3192 from useautumn/charlie/billing-generate-pres…
charlietlamb Sep 1, 2026
2deaaae
Merge pull request #3193 from useautumn/fix/existing-usages-accrued-o…
SirTenzin Sep 1, 2026
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
20 changes: 20 additions & 0 deletions server/src/cron/invoiceCron/runInvoiceCron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { addDays } from "date-fns";
import { and, asc, eq, isNotNull, lt, or, sql } from "drizzle-orm";
import type { Stripe } from "stripe";
import { withStatementTimeout } from "@/db/withStatementTimeout.js";
import { resolveRedisV2 } from "@/external/redis/resolveRedisV2.js";
import { expirePendingCustomerProducts } from "@/internal/billing/v2/execute/expirePendingCustomerProducts";
import { OrgService } from "@/internal/orgs/OrgService";
import { createStripeCli } from "../../external/connect/createStripeCli";
import { stripeInvoiceToStripeSubscriptionId } from "../../external/stripe/invoices/utils/convertStripeInvoice";
Expand Down Expand Up @@ -69,6 +71,22 @@ export const handleVoidInvoiceCron = async ({

const subId = stripeInvoiceToStripeSubscriptionId(invoice);
const voidSub = metadata.type === MetadataType.InvoiceCheckout;
const expirePendingRows = async () => {
try {
await expirePendingCustomerProducts({
ctx: {
db,
logger,
org: { id: org.id },
env: customer.env,
redisV2: resolveRedisV2(),
},
metadataId: metadata.id,
});
} catch (error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When expiration hits a database failure, this catch logs it and returns normally, so the caller deletes metadata and permanently loses the retry path for the pending rows. Propagate the error or delete metadata only after expiration succeeds.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/cron/invoiceCron/runInvoiceCron.ts, line 86:

<comment>When expiration hits a database failure, this catch logs it and returns normally, so the caller deletes metadata and permanently loses the retry path for the pending rows. Propagate the error or delete metadata only after expiration succeeds.</comment>

<file context>
@@ -69,6 +71,22 @@ export const handleVoidInvoiceCron = async ({
+				},
+				metadataId: metadata.id,
+			});
+		} catch (error) {
+			logger.error(`Error expiring pending customer products: ${error}`);
+		}
</file context>

logger.error(`Error expiring pending customer products: ${error}`);
}
};

console.log(
`Invoice: ${metadata.stripe_invoice_id} for customer ${customer.id} (org: ${org.slug}) - status: ${invoice.status}`,
Expand All @@ -90,6 +108,7 @@ export const handleVoidInvoiceCron = async ({
}
}

await expirePendingRows();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When this cron expires a pending product, it updates Postgres without invalidating the full-subject Redis cache. Cached customer reads can therefore continue returning the product as pending until another invalidation; invalidate the affected customer cache during this cleanup.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/cron/invoiceCron/runInvoiceCron.ts, line 111:

<comment>When this cron expires a pending product, it updates Postgres without invalidating the full-subject Redis cache. Cached customer reads can therefore continue returning the product as pending until another invalidation; invalidate the affected customer cache during this cleanup.</comment>

<file context>
@@ -90,6 +108,7 @@ export const handleVoidInvoiceCron = async ({
 				}
 			}
 
+			await expirePendingRows();
 			await MetadataService.delete({
 				db,
</file context>

await MetadataService.delete({
db,
id: metadata.id,
Expand Down Expand Up @@ -123,6 +142,7 @@ export const handleVoidInvoiceCron = async ({
}
}
} else if (invoice.status === "void" || invoice.status === "uncollectible") {
await expirePendingRows();
await MetadataService.delete({
db,
id: metadata.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,18 @@ export const handleRenewal = async ({
return { success: true };
}

// Past-due → active recovery.
// Past-due → active recovery. A product cancelled before it went past due
// needs uncancel, since markActive leaves the cancellation fields set.
if (curSameProduct && curSameProduct.status === CusProductStatus.PastDue) {
logger.info(
`Renewal for existing past due product ${product.id}, marking as active`,
);

await customerProductActions.markActive({
const recoverPastDueProduct = curSameProduct.canceled
? customerProductActions.uncancel
: customerProductActions.markActive;

await recoverPastDueProduct({
ctx: customerCtx,
customerProduct: curSameProduct,
fullCustomer: customer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ const formatCustomerProductStatus = (
const statusLabels: Record<CusProductStatus, string> = {
[CusProductStatus.Active]: "✓ active",
[CusProductStatus.Scheduled]: "⏳ scheduled",
[CusProductStatus.Pending]: "⧖ pending",
[CusProductStatus.Expired]: "✗ expired",
[CusProductStatus.PastDue]: "⚠ past_due",
[CusProductStatus.Trialing]: "🔄 trialing",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from "@/internal/billing/v2/actions/createSchedule/utils/persistDeferredCreateSchedule";
import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan";
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
import { promotePendingCustomerProducts } from "@/internal/billing/v2/execute/promotePendingCustomerProducts";
import { publishBillingTransition } from "@/internal/billing/v2/publish/publishBillingTransition.js";
import { buildBillingLockKey } from "@/internal/billing/v2/utils/billingLock/buildBillingLockKey";
import { withBillingLock } from "@/internal/billing/v2/utils/billingLock/withBillingLock";
Expand Down Expand Up @@ -165,6 +166,13 @@ const executeCheckoutSessionMetadataV2 = async ({
billingContext: updatedDeferredData.billingContext,
});

await promotePendingCustomerProducts({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: For a deferred create-schedule checkout, this call removes the promoted IDs before persistDeferredCreateSchedule builds phases, causing Deferred create_schedule phases did not match billing plan. Preserve the original product IDs for schedule persistence or persist before mutating the plan.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts, line 169:

<comment>For a deferred create-schedule checkout, this call removes the promoted IDs before `persistDeferredCreateSchedule` builds phases, causing `Deferred create_schedule phases did not match billing plan`. Preserve the original product IDs for schedule persistence or persist before mutating the plan.</comment>

<file context>
@@ -165,6 +166,13 @@ const executeCheckoutSessionMetadataV2 = async ({
 		billingContext: updatedDeferredData.billingContext,
 	});
 
+	await promotePendingCustomerProducts({
+		ctx,
+		autumnBillingPlan: updatedDeferredData.billingPlan.autumn,
</file context>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: After promotion, the reward loop sees an empty insertCustomerProducts array and skips grantCheckoutReward for paid products. Preserve the promoted products for reward dispatch or dispatch rewards from the promotion result.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts, line 169:

<comment>After promotion, the reward loop sees an empty `insertCustomerProducts` array and skips `grantCheckoutReward` for paid products. Preserve the promoted products for reward dispatch or dispatch rewards from the promotion result.</comment>

<file context>
@@ -165,6 +166,13 @@ const executeCheckoutSessionMetadataV2 = async ({
 		billingContext: updatedDeferredData.billingContext,
 	});
 
+	await promotePendingCustomerProducts({
+		ctx,
+		autumnBillingPlan: updatedDeferredData.billingPlan.autumn,
</file context>

ctx,
autumnBillingPlan: updatedDeferredData.billingPlan.autumn,
fullCustomer: updatedDeferredData.billingContext.fullCustomer,
metadataId: metadata.id,
});

// Execute autumn billing plan (includes customer products, upsertSubscription, upsertInvoice)
await executeAutumnBillingPlan({
ctx,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { CusProductStatus } from "@autumn/shared";
import type Stripe from "stripe";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import {
expireCustomerProducts,
expirePendingCustomerProducts,
} from "@/internal/billing/v2/execute/expirePendingCustomerProducts";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { MetadataService } from "@/internal/metadata/MetadataService";

Expand Down Expand Up @@ -32,6 +36,10 @@ export const handleStripeCheckoutSessionExpired = async ({
// Try to clean up the metadata row even if no cusProduct ever got created
// (e.g. a deferred-flow checkout that expired).
if (session.metadata?.autumn_metadata_id) {
await expirePendingCustomerProducts({
ctx,
metadataId: session.metadata.autumn_metadata_id,
});
await MetadataService.delete({
db: ctx.db,
id: session.metadata.autumn_metadata_id,
Expand All @@ -40,21 +48,14 @@ export const handleStripeCheckoutSessionExpired = async ({
return;
}

const now = Date.now();

for (const cusProduct of cusProducts) {
// If the success-path webhook already linked a subscription, leave it.
if ((cusProduct.subscription_ids ?? []).length > 0) continue;
const abandonedCusProducts = cusProducts.filter(
(cusProduct) => (cusProduct.subscription_ids ?? []).length === 0,
);

await CusProductService.update({
ctx,
cusProductId: cusProduct.id,
updates: {
status: CusProductStatus.Expired,
ended_at: now,
},
});
}
await expireCustomerProducts({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a session-linked row is still Pending, this call can overwrite a concurrent invoice.paid promotion and leave the paid product Expired. Route pending rows through a conditional expireIfPending update, and reserve the unconditional update for rows that are intentionally active.

(Based on your team's feedback about conditional expiry of pending rows.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionExpired/handleStripeCheckoutSessionExpired.ts, line 55:

<comment>When a session-linked row is still `Pending`, this call can overwrite a concurrent `invoice.paid` promotion and leave the paid product `Expired`. Route pending rows through a conditional `expireIfPending` update, and reserve the unconditional update for rows that are intentionally active.

(Based on your team's feedback about conditional expiry of pending rows.) </comment>

<file context>
@@ -40,21 +48,14 @@ export const handleStripeCheckoutSessionExpired = async ({
-			},
-		});
-	}
+	await expireCustomerProducts({
+		ctx,
+		customerProducts: abandonedCusProducts,
</file context>

ctx,
customerProducts: abandonedCusProducts,
});

if (session.metadata?.autumn_metadata_id) {
await MetadataService.delete({
Expand Down
11 changes: 9 additions & 2 deletions server/src/internal/analytics/analyticsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
ErrCode,
type FullCusProduct,
type FullCustomer,
type FullCustomerEntitlement,
type FullProduct,
RecaseError,
type Subscription,
Expand Down Expand Up @@ -310,6 +309,14 @@ function calculateBillingCycleResult(
const gap = currentEndDate.getTime() - currentStartDate.getTime();
const gapDays = Math.floor(gap / (1000 * 60 * 60 * 24));

if (intervalType === "1bc") {
return {
startDate: startDates[0],
endDate: endDates[0],
gap: gapDays,
};
}

if (intervalType === "last_cycle") {
const earliestCreation = createdDates.reduce((earliest, current) => {
const currentDate = new Date(current);
Expand Down Expand Up @@ -339,7 +346,7 @@ function calculateBillingCycleResult(
};
}

const gapMultiplier = intervalType === "1bc" ? 1 : 3;
const gapMultiplier = 3;
const now = new Date();

// For analytics, we look BACKWARD from today for N billing cycles
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ATTACH_CONFLICT_STATUSES,
cusProductToPrices,
ErrCode,
type FullCusProduct,
Expand All @@ -12,7 +13,6 @@ import {
isCustomerProductPaidRecurring,
isCustomerProductRecurring,
isPrepaidPrice,
RELEVANT_STATUSES,
RecaseError,
type UpdateSubscriptionV1Params,
} from "@autumn/shared";
Expand Down Expand Up @@ -145,7 +145,7 @@ export const findTargetCustomerProduct = async ({

const candidates = fullCustomerToPlanProducts({ fullCustomer }).filter(
(cp) => {
if (!RELEVANT_STATUSES.includes(cp.status)) return false;
if (!ATTACH_CONFLICT_STATUSES.includes(cp.status)) return false;
return isCusProductOnEntity({ cusProduct: cp, internalEntityId });
},
);
Expand All @@ -160,7 +160,7 @@ export const findTargetCustomerProduct = async ({
const fallback = await CusProductService.getFull({
db: ctx.db,
id: params.customer_product_id,
inStatuses: RELEVANT_STATUSES,
inStatuses: ATTACH_CONFLICT_STATUSES,
});
const belongsToCustomer =
fallback?.internal_customer_id === fullCustomer.internal_id;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type {
AttachParamsV1,
BillingContext,
FullCusProduct,
UpdateSubscriptionV1Params,
} from "@autumn/shared";

export const buildPendingReattachParams = ({
params,
billingContext,
customerProduct,
}: {
params: UpdateSubscriptionV1Params;
billingContext: BillingContext;
customerProduct: FullCusProduct;
}): AttachParamsV1 => {
const { invoiceMode } = billingContext;

const featureQuantities =
params.feature_quantities ??
billingContext.featureQuantities.map((featureQuantity) => ({
feature_id: featureQuantity.feature_id,
quantity: featureQuantity.quantity ?? 0,
}));
Comment on lines +20 to +24

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When an update changes only one prepaid feature on a pending plan, this nullish selection drops the other feature quantities. Merge requested quantities with billingContext.featureQuantities before reattaching, so omitted features retain their pending values instead of being initialized to zero.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/buildPendingReattachParams.ts, line 20:

<comment>When an update changes only one prepaid feature on a pending plan, this nullish selection drops the other feature quantities. Merge requested quantities with `billingContext.featureQuantities` before reattaching, so omitted features retain their pending values instead of being initialized to zero.</comment>

<file context>
@@ -0,0 +1,41 @@
+	const { invoiceMode } = billingContext;
+
+	const featureQuantities =
+		params.feature_quantities ??
+		billingContext.featureQuantities.map((featureQuantity) => ({
+			feature_id: featureQuantity.feature_id,
</file context>
Suggested change
params.feature_quantities ??
billingContext.featureQuantities.map((featureQuantity) => ({
feature_id: featureQuantity.feature_id,
quantity: featureQuantity.quantity ?? 0,
}));
[
...billingContext.featureQuantities.map((featureQuantity) => {
const requestedQuantity = params.feature_quantities?.find(
(requested) => requested.feature_id === featureQuantity.feature_id,
);
return {
feature_id: featureQuantity.feature_id,
quantity: requestedQuantity?.quantity ?? featureQuantity.quantity ?? 0,
};
}),
...(params.feature_quantities ?? []).filter(
({ feature_id }) =>
!billingContext.featureQuantities.some(
(featureQuantity) => featureQuantity.feature_id === feature_id,
),
),
]


return {
customer_id: params.customer_id,
plan_id: customerProduct.product.id,
entity_id: params.entity_id ?? customerProduct.entity_id ?? undefined,
feature_quantities: featureQuantities,
customize: params.customize,
version: params.version,
enable_plan_immediately: billingContext.enablePlanImmediately,
invoice_mode: invoiceMode && {
enabled: true,
enable_plan_immediately: invoiceMode.enableProductImmediately,
finalize: invoiceMode.finalizeInvoice,
net_terms_days: invoiceMode.daysUntilDue,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the original deferred attach used an invoice template, reattaching its pending product loses the template footer and memo. Preserve the original invoice settings or template identity instead of rebuilding invoice mode with only finalize, immediacy, and terms.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/buildPendingReattachParams.ts, line 38:

<comment>When the original deferred attach used an invoice template, reattaching its pending product loses the template footer and memo. Preserve the original invoice settings or template identity instead of rebuilding invoice mode with only finalize, immediacy, and terms.</comment>

<file context>
@@ -0,0 +1,41 @@
+			enabled: true,
+			enable_plan_immediately: invoiceMode.enableProductImmediately,
+			finalize: invoiceMode.finalizeInvoice,
+			net_terms_days: invoiceMode.daysUntilDue,
+		},
+	} as AttachParamsV1;
</file context>

},
} as AttachParamsV1;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { FullCusProduct } from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli";
import { expireStripeCheckoutSession } from "@/external/stripe/checkoutSessions/operations/expireStripeCheckoutSession";
import { voidStripeInvoiceIfOpen } from "@/external/stripe/invoices/operations/voidStripeInvoiceIfOpen";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { MetadataService } from "@/internal/metadata/MetadataService";

export const discardPendingCustomerProduct = async ({
ctx,
customerProduct,
}: {
ctx: AutumnContext;
customerProduct: FullCusProduct;
}) => {
const metadataId = customerProduct.metadata_id;

if (metadataId) {
const metadata = await MetadataService.get({ db: ctx.db, id: metadataId });

if (metadata?.stripe_checkout_session_id) {
await expireStripeCheckoutSession({
ctx,
checkoutSessionId: metadata.stripe_checkout_session_id,
});
Comment on lines +22 to +25

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When the checkout session is completing or already completed, expireStripeCheckoutSession returns false, but this function still deletes the metadata and expires the pending product. Return without cleanup when expiration returns false, so the successful payment webhook can promote the product.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/discardPendingCustomerProduct.ts, line 22:

<comment>When the checkout session is completing or already completed, `expireStripeCheckoutSession` returns `false`, but this function still deletes the metadata and expires the pending product. Return without cleanup when expiration returns false, so the successful payment webhook can promote the product.</comment>

<file context>
@@ -0,0 +1,44 @@
+		const metadata = await MetadataService.get({ db: ctx.db, id: metadataId });
+
+		if (metadata?.stripe_checkout_session_id) {
+			await expireStripeCheckoutSession({
+				ctx,
+				checkoutSessionId: metadata.stripe_checkout_session_id,
</file context>
Suggested change
await expireStripeCheckoutSession({
ctx,
checkoutSessionId: metadata.stripe_checkout_session_id,
});
const expired = await expireStripeCheckoutSession({
ctx,
checkoutSessionId: metadata.stripe_checkout_session_id,
});
if (!expired) return;

}

if (metadata?.stripe_invoice_id) {
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const stripeInvoice = await stripeCli.invoices.retrieve(
metadata.stripe_invoice_id,
);

await voidStripeInvoiceIfOpen({ ctx, stripeInvoice });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When the retrieved invoice is already paid, voidStripeInvoiceIfOpen does nothing, but this function still deletes the metadata and expires the pending product. Preserve the metadata and return for paid invoices so invoice completion can promote the product.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/discardPendingCustomerProduct.ts, line 34:

<comment>When the retrieved invoice is already `paid`, `voidStripeInvoiceIfOpen` does nothing, but this function still deletes the metadata and expires the pending product. Preserve the metadata and return for paid invoices so invoice completion can promote the product.</comment>

<file context>
@@ -0,0 +1,44 @@
+				metadata.stripe_invoice_id,
+			);
+
+			await voidStripeInvoiceIfOpen({ ctx, stripeInvoice });
+		}
+
</file context>
Suggested change
await voidStripeInvoiceIfOpen({ ctx, stripeInvoice });
if (stripeInvoice.status === "paid") return;
await voidStripeInvoiceIfOpen({ ctx, stripeInvoice });

}

await MetadataService.delete({ db: ctx.db, id: metadataId });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: If expireIfPending fails after the metadata delete, the pending row retains a dangling metadata ID and metadata-driven cleanup cannot find it. Expire the customer product before deleting its metadata.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/discardPendingCustomerProduct.ts, line 37:

<comment>If `expireIfPending` fails after the metadata delete, the pending row retains a dangling metadata ID and metadata-driven cleanup cannot find it. Expire the customer product before deleting its metadata.</comment>

<file context>
@@ -0,0 +1,44 @@
+			await voidStripeInvoiceIfOpen({ ctx, stripeInvoice });
+		}
+
+		await MetadataService.delete({ db: ctx.db, id: metadataId });
+	}
+
</file context>

}

await CusProductService.expireIfPending({
ctx,
cusProductId: customerProduct.id,
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { discardPendingCustomerProduct } from "@/internal/billing/v2/execute/discardPendingCustomerProduct";
import { findPendingCustomerProduct } from "@/internal/billing/v2/execute/findPendingCustomerProduct";

export const discardPendingPlanIfAny = async ({
ctx,
customerId,
productId,
entityId,
}: {
ctx: AutumnContext;
customerId: string;
productId?: string;
entityId?: string;
}) => {
const customerProduct = await findPendingCustomerProduct({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a customer has more than the configured customer-product limit, this lookup can miss the requested pending plan and continue into active-subscription cancellation. Use an unpaginated pending-product lookup for this path.

(Based on your team's feedback about unpaginated pending cancellation.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/discardPendingPlanIfAny.ts, line 16:

<comment>When a customer has more than the configured customer-product limit, this lookup can miss the requested pending plan and continue into active-subscription cancellation. Use an unpaginated pending-product lookup for this path.

(Based on your team's feedback about unpaginated pending cancellation.) </comment>

<file context>
@@ -0,0 +1,27 @@
+	productId?: string;
+	entityId?: string;
+}) => {
+	const customerProduct = await findPendingCustomerProduct({
+		ctx,
+		customerId,
</file context>

ctx,
customerId,
productId,
entityId,
});

if (!customerProduct) return false;

await discardPendingCustomerProduct({ ctx, customerProduct });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a payment promotion activates the row after the lookup, conditional expiry does nothing but this function still reports that it discarded the plan. Propagate the conditional-expiry result and continue normal cancellation when the row is no longer pending.

(Based on your team's feedback about conditional pending expiry.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At server/src/internal/billing/v2/execute/discardPendingPlanIfAny.ts, line 25:

<comment>When a payment promotion activates the row after the lookup, conditional expiry does nothing but this function still reports that it discarded the plan. Propagate the conditional-expiry result and continue normal cancellation when the row is no longer pending.

(Based on your team's feedback about conditional pending expiry.) </comment>

<file context>
@@ -0,0 +1,27 @@
+
+	if (!customerProduct) return false;
+
+	await discardPendingCustomerProduct({ ctx, customerProduct });
+	return true;
+};
</file context>

return true;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { AutumnBillingPlan } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService";
import { FreeTrialService } from "@/internal/products/free-trials/FreeTrialService";
import { PriceService } from "@/internal/products/prices/PriceService";

/** Custom catalog rows must exist before any customer row can reference them. */
export const insertCustomCatalogRows = async ({
ctx,
autumnBillingPlan,
}: {
ctx: AutumnContext;
autumnBillingPlan: AutumnBillingPlan;
}) => {
const { customPrices, customEntitlements, customFreeTrial } =
autumnBillingPlan;

if (customEntitlements) {
await EntitlementService.insert({ db: ctx.db, data: customEntitlements });
}

if (customPrices) {
await PriceService.insert({ db: ctx.db, data: customPrices });
}

if (customFreeTrial) {
await FreeTrialService.insert({ db: ctx.db, data: customFreeTrial });
}
};
Loading
Loading